Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,37 @@ agility sync --sourceGuid="abc123" --targetGuid="def456" --models-with-deps="Pro

---

### Component Availability Rules

A component model can carry rules about **who** can add it to a page, **where** it can be
placed (sitemaps, page models and zones), and whether it's **disabled**. The CLI moves them
like this:

- **Pull** saves each model's rules to `model-availability/{modelID}.json`. An instance that
doesn't support the rules yet is pulled without them.
- **Sync** sends the **where** rules with the models, as the last step, after the pages of
every locale. The target checks each page's new placements against its rules, and on a new
target every placement is new, so pages that are valid on the source are synced before the
rules arrive. The rules are matched on the target by name: sitemaps by reference name, page
models by name, and zones by page model name plus zone reference name (and by ID when the
sync has already mapped that page model or zone).
- A source model with no where rules clears the target's, but only when the source has at least
one rule on some model. An instance that hasn't been upgraded for the rules yet reads as "no
rules", so a source with none at all never clears anything.
- If the target's rules can't be read, the where rules aren't synced, and the sync says so.
- A sitemap, page model or zone that was deleted on the source after a rule was saved is left
out. If that would leave a rule's sitemaps, or its page models and zones, empty, the model's
where rules aren't synced and the sync warns: sending the rule empty would allow the component
everywhere, when on the source it's allowed nowhere.
- **Who** rules and **disabled** belong to each instance and are never overwritten. A model the
sync creates is disabled at the end of the sync if it's disabled on the source.
- A page the target rejects because of a rule is reported with the reason, for example
`Rejected by a component availability rule: The "Hero" component isn't allowed in the "Main"
zone of the "Landing Page" page model.` Once the rules are on the target, a later sync that
adds a placement the rules don't allow is rejected this way.

---

### Selective Page Sync

`--pages` narrows a sync to one or more page structures. Use it when you want to promote the
Expand Down Expand Up @@ -576,6 +607,7 @@ agility-files/
│ │ ├── json/ # Asset metadata
│ ├── galleries/ # Gallery definitions
│ ├── models/ # Content models
│ ├── model-availability/ # Component availability rules, one file per model that has any
│ ├── containers/ # Content containers
│ ├── templates/ # Page templates
│ ├── sitemap/ # Flat sitemap
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@
},
"dependencies": {
"@agility/content-sync": "^1.2.0",
"@agility/management-sdk": "^0.1.38",
"@agility/management-sdk": "^0.2.0-beta.1",
"ansi-colors": "^4.1.3",
"axios": "^0.27.2",
"cli-progress": "^3.11.2",
Expand Down
3 changes: 3 additions & 0 deletions src/core/push.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
clearFailedContentRegistry,
clearHeldBackTemplateRegistry,
clearSavedTargets,
clearDeferredModelDisables,
clearOtherLocaleStagingRegistry,
getOtherLocaleStaging,
getPageCmsLink,
Expand Down Expand Up @@ -105,6 +106,8 @@ export class Push {
clearHeldBackTemplateRegistry();
// Entities saved by a previous run must not stand in for this run's pulled targets
clearSavedTargets();
// PROD-2914: models a previous run meant to disable after its pages
clearDeferredModelDisables();
clearOtherLocaleStagingRegistry();

// Preflight (PROD-2203): start each run with a clean report of planned actions
Expand Down
30 changes: 30 additions & 0 deletions src/core/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,11 @@ export interface State {
// was not recreated (no --overwrite). Used by page pusher to skip pages that depend on them.
heldBackTemplateRegistry: Map<string, string>;

// Deferred model disables (PROD-2914) - source IDs of models created this run whose source is
// disabled (`isVisible: false`). They're created enabled and disabled after the pages, so the
// run's own page saves can place them (on a new target every placement counts as new).
deferredModelDisables: Set<number>;

// Other-locale staging registry (PROD-2782) - pages that a save in one locale put in staging in
// another locale, keyed by that other locale, then by target page ID. Auto-publish republishes
// them; without auto-publish they are reported as warnings.
Expand Down Expand Up @@ -215,6 +220,9 @@ export const state: State = {
// Held-back template registry - templates whose mapped target is gone and was not recreated
heldBackTemplateRegistry: new Map(),

// Deferred model disables - models created this run, to disable after the pages (PROD-2914)
deferredModelDisables: new Set(),

// Other-locale staging registry - pages a save in one locale put in staging in another (PROD-2782)
otherLocaleStagingRegistry: new Map(),
otherLocaleStagingBlocked: new Set(),
Expand Down Expand Up @@ -522,6 +530,7 @@ export function resetState() {

// Run-scoped saved target entities
state.savedTargets = new Map();
state.deferredModelDisables = new Set();
}

/**
Expand Down Expand Up @@ -881,6 +890,27 @@ export function clearSavedTargets(): void {
state.savedTargets.clear();
}

/**
* PROD-2914: register a model created this run whose source is disabled (`isVisible: false`). The
* model pusher creates it enabled, and the component availability phase disables it after the
* pages, so the run's own page saves aren't rejected for placing a disabled component.
* @param sourceModelID - The source model ID
*/
export function registerDeferredModelDisable(sourceModelID: number): void {
if (sourceModelID == null) return;
state.deferredModelDisables.add(sourceModelID);
}

/** Source IDs of the models registered by registerDeferredModelDisable this run. */
export function getDeferredModelDisables(): number[] {
return Array.from(state.deferredModelDisables);
}

/** Clear the deferred model disables (called at start of each sync). */
export function clearDeferredModelDisables(): void {
state.deferredModelDisables.clear();
}

/**
* Register a source template that was held back because its mapped target template no longer
* exists and --overwrite was not set
Expand Down
100 changes: 100 additions & 0 deletions src/lib/downloaders/download-model-availability.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import * as fs from "fs";
import * as path from "path";
import * as mgmtApi from "@agility/management-sdk";
import { fileOperations } from "core/fileOperations";
import { getApiClient, getLoggerForGuid } from "core/state";

/**
* PROD-2914: component availability rules (who can add a component model, and where it can go) are
* pulled into their own folder, one file per model: `{guid}/model-availability/{modelID}.json`, the
* same layout as `models/{modelID}.json`. They live outside `models/` because everything in that
* folder is read back as a model.
*
* The folder's presence is meaningful: it exists (possibly empty) only when the instance answered,
* so "no file for a model" means "no rules". When the instance can't answer (an API without the
* feature yet), the folder is removed, and the push treats that side as unknown.
*
* ⚠️ An instance whose DATABASE doesn't have the feature yet does answer: with an empty list, the
* same as an instance with no rules. So an empty folder doesn't prove the instance stores rules;
* the push allows for that.
*/
export const MODEL_AVAILABILITY_FOLDER = "model-availability";

/**
* Status codes on a read that mean "this instance doesn't support availability rules yet": 404/405
* from an API without the route. (A read on a database without the feature answers "no rules", not
* 409; a 409 is tolerated anyway, as only the save is specified to return it.)
*/
export function isAvailabilityUnsupported(error: any): boolean {
const status = error?.statusCode ?? error?.response?.status;
return status === 404 || status === 405 || status === 409;
}

export async function downloadModelAvailability(guid: string): Promise<void> {
const fileOps = new fileOperations(guid);
const apiClient = getApiClient();
const logger = getLoggerForGuid(guid);
const folderPath = fileOps.getDataFolderPath(MODEL_AVAILABILITY_FOLDER);

const removeFolder = () => {
if (fs.existsSync(folderPath)) fs.rmSync(folderPath, { recursive: true, force: true });
};

// An older SDK build has no availability methods: same as an instance without the feature.
if (typeof apiClient?.modelMethods?.getAllModelAvailability !== "function") {
removeFolder();
return;
}

let rules: mgmtApi.ModelAvailability[];
try {
rules = (await apiClient.modelMethods.getAllModelAvailability(guid)) || [];
} catch (error: any) {
// Never leave stale rules behind: a push would otherwise send rules the instance no longer has.
removeFolder();
if (isAvailabilityUnsupported(error)) {
logger?.info(`Component availability rules aren't available on ${guid} yet; skipping them.`);
return;
}
logger?.error(`Error pulling component availability rules: ${error?.message || error}`);
throw error;
}

// An older API serves `model/availability` from its `model/{referenceName}` route: an instance
// with a model named "availability" answers with that model, not a list. That's an API without
// the feature, too.
if (!Array.isArray(rules)) {
removeFolder();
logger?.info(`Component availability rules aren't available on ${guid} yet; skipping them.`);
return;
}

const pulledIds = new Set<string>();
try {
fs.mkdirSync(folderPath, { recursive: true });

for (const rule of rules) {
if (rule?.modelID === undefined || rule?.modelID === null) continue;
const fileName = rule.modelID.toString();
pulledIds.add(fileName);
fileOps.exportFiles(MODEL_AVAILABILITY_FOLDER, fileName, rule);
}

// A model that has lost all its rules is no longer listed: drop its file.
for (const file of fs.readdirSync(folderPath)) {
if (!file.endsWith(".json")) continue;
if (!pulledIds.has(file.replace(".json", ""))) {
fs.unlinkSync(path.join(folderPath, file));
}
}
} catch (error: any) {
// A half-written folder would read as the instance's current rules: remove it all.
removeFolder();
logger?.error(`Error writing component availability rules: ${error?.message || error}`);
throw error;
}

if (pulledIds.size > 0) {
logger?.info(`Pulled component availability rules for ${pulledIds.size} model(s).`);
}
}
11 changes: 11 additions & 0 deletions src/lib/downloaders/download-operations-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { downloadAllGalleries } from "./download-galleries";
import { downloadAllAssets } from "./download-assets";
import { downloadAllModels } from "./download-models";
import { downloadModelAvailability } from "./download-model-availability";
import { downloadAllTemplates } from "./download-templates";
import { downloadAllContainers } from "./download-containers";
import { downloadAllSyncSDK } from "./download-sync-sdk";
Expand Down Expand Up @@ -56,6 +57,16 @@ export const DOWNLOAD_OPERATIONS: Record<string, OperationConfig> = {
},
elements: ["Models"],
},
// PROD-2914: component availability rules travel with the models. A separate operation so an
// instance without the feature (or a failed read) never fails the model download itself.
modelAvailability: {
name: "downloadModelAvailability",
description: "Download component availability rules",
handler: async (guid) => {
await downloadModelAvailability(guid);
},
elements: ["Models"],
},
templates: {
name: "downloadAllTemplates",
description: "Download page templates and layouts",
Expand Down
Loading
Loading