/** * Three-way merge baseline — the "last-applied" leg of recipe ↔ tenant ↔ * baseline diffing. * * Without a baseline, scai's planner is a TWO-way diff: it sees the recipe * (desired) vs the tenant (live) and treats any divergence as recipe-wins * drift. That silently clobbers author edits — a field the author edited * in the Sitecore UI after the last push reads as "tenant disagrees with * recipe", and the planner schedules an `update` that overwrites it. * * With a baseline, the planner classifies per-field: * * R == B && C == B → no-op (idempotent) * R != B && C == B → recipe change, tenant unchanged → safe `update` * R == B && C != B → AUTHOR EDIT detected (cms-edit) — surface, don't clobber * R != B && C != B → CONFLICT — both sides moved. Default `error`. * * Baseline storage is per-(env, recipe) on the operator's filesystem: * * /.scai/baseline//.baseline.json * * Same slug rules as `defaultIrPath` in io.ts (`@` → `_v`). The file is * a flat list of per-field hash entries (one per SetField mutation the * last push wrote). Hashes (SHA-256 of `renderRefValue(value)`) keep the * baseline small and avoid storing tenant content verbatim — sufficient * to detect "tenant matches last-applied" vs "tenant has drifted", * insufficient for true three-way value merge (that's UI territory). * * Compared with the Tier 3 design — file-based baseline IS the MVP. The * remote orchestrator-backed baseline (shareable across operators / CI) * is the follow-on. */ import { z } from "zod"; import { type ParsedLayout } from "../layout/parse.js"; /** * One field's last-applied state — what the previous successful push * wrote to (itemRefKey, fieldId, language?, version?). The hash is * SHA-256 hex of the wire-form string (`renderRefValue(value)`). * * `fieldName` is carried alongside `fieldId` because recipe-created * fields' fieldIds are IR-internal refKeys (not the server-assigned * GUIDs); the planner matches by name when present, fieldId otherwise. */ export declare const BaselineFieldEntrySchema: z.ZodObject<{ itemRefKey: z.ZodString; fieldId: z.ZodString; fieldName: z.ZodOptional; language: z.ZodOptional; version: z.ZodOptional; valueHash: z.ZodString; }, z.core.$strip>; export type BaselineFieldEntry = z.infer; /** * One recipe's baseline document. Stored at * `/.scai/baseline//.baseline.json` after * a successful apply. */ export declare const BaselineSchema: z.ZodObject<{ schemaVersion: z.ZodLiteral<"1">; recipeHandle: z.ZodString; envName: z.ZodString; capturedAt: z.ZodString; fields: z.ZodArray; language: z.ZodOptional; version: z.ZodOptional; valueHash: z.ZodString; }, z.core.$strip>>; }, z.core.$strip>; export type Baseline = z.infer; /** * Stable hash of a rendered field value — SHA-256 hex. Inputs are the * exact wire-form strings the executor writes / the planner reads, so * hashing on either side compares apples to apples. * * Whitespace and case are NOT normalised — Sitecore returns the same * bytes on read that it wrote, and a normaliser here would mis-classify * legitimate whitespace edits as "no change". The one exception is layout * XML, which Sitecore canonicalises server-side; the planner handles that * via `layoutXmlEquivalent` separately and does NOT use this hash for * layout fields. */ export declare const hashFieldValue: (renderedValue: string) => string; /** * Sitecore field GUIDs whose values are layout XML — push-emitted * canonical, tenant-returned SXA delta. These need * `canonicaliseLayoutXml` before hashing. */ export declare const isLayoutFieldId: (fieldId: string) => boolean; /** * Canonicalise a layout XML string for stable hashing. Push emits * canonical XML; the tenant returns SXA delta XML (with `` * directives) — the two wire forms differ byte-for-byte even for the * same logical layout, so raw `renderRefValue` hashing would diverge * on every push → re-read cycle. * * Approach: parse both forms via `parseLayoutXml` (it handles canonical * + delta) into a `ParsedLayout`, then serialise to a deterministic * JSON string with sorted placeholder keys and sorted per-placement * param keys. Two semantically-equal layouts hash identical regardless * of wire form, GUID case, or device-element ordering — the same * invariant `layoutXmlEquivalent` relies on for planner drift detection. * * Returns the original XML unchanged when parse fails (defensive — a * malformed layout shouldn't crash the hash path; fallback to raw * string compare which at least round-trips identically against itself). */ export declare const canonicaliseLayoutXml: (xml: string, preParsed?: ParsedLayout) => string; /** * True when the rendered value is a pipe-separated list of GUIDs (one or * more) — the wire shape of `__Masters` (insert options), `__Base * template`, droplinks/multilists, etc. */ export declare const isGuidListValue: (value: string) => boolean; /** * Canonicalise a GUID-list value for comparison/hashing: each segment * to braced-uppercase-dashed form. ORDER IS PRESERVED — multilist order * is author-meaningful in Sitecore (insert-option display order, * treelist ordering), so only the byte representation is normalised * (brace form, case, stray whitespace), not the semantics. This is the * GUID-list analogue of `canonicaliseLayoutXml`: scai writes via * `toCurly` ({UPPER}), but values that round-trip through the tenant or * arrive from author edits can differ in case/braces for the same * logical list — a raw string compare then reports phantom drift. */ export declare const canonicaliseGuidList: (value: string) => string; export declare const hashFieldValueForBaseline: (fieldId: string, renderedValue: string, /** * Optional pre-parsed layout — when the caller already parsed the XML * (e.g. `computeFieldDrift` parses once for `layoutXmlEquivalent` * AND for hashing), pass the parsed value to skip re-parsing here. * Halves layout parse cost on the planner's hot path. */ preParsedLayout?: ParsedLayout) => string; /** * Indexed view over a baseline — `(itemRefKey, fieldKey) → valueHash` — * for O(1) lookup during planning. Built once per recipe at plan-time; * the planner calls `lookup` per SetField op. */ export interface BaselineIndex { /** * Look up the last-applied value hash for one (item, field) cell. * Returns `undefined` when: * - the baseline doesn't carry an entry for this item (recipe-created * items that weren't in the previous push — new fields, new items), * - the baseline file is absent (first push to this env), * - or `null` baseline was passed (operator opted out via flag). */ lookup: (itemRefKey: string, fieldId: string, fieldName: string | undefined, language: string | undefined, version: number | undefined) => string | undefined; /** Underlying baseline document (`null` when none was loaded). */ baseline: Baseline | null; } export declare const indexBaseline: (baseline: Baseline | null) => BaselineIndex; /** * Pluggable backing store for per-(env, recipe) baseline snapshots — * the content-recipe-specific surface (2-arg `(envName, recipeHandle)`). * * Relationship with `@/sync`'s {@link import("../../sync").BaselineStorage}: * the sync interface is the multi-kind 3-arg surface used by every * non-content-recipe kind (brand, brief, campaign, story, …). Content * recipes pre-date the multi-kind shape and keep their 2-arg surface * for callsite ergonomics — pull/push.ts wire baselineStorage through * dozens of call sites, and the kind is always "content-recipe". * * Operators who want a single backing store across every kind plug a * sync `BaselineStorage` into the recipe pipeline via * {@link adaptSyncBaselineStorage} below. Internally that pins kind to * `"content-recipe"`; downstream every method signature collapses to * the 2-arg form callers already use. * * The default {@link FileBaselineStorage} writes to * `/.scai/baseline//.baseline.json`. A * remote impl plugs in via the adapter; no recipe-side callsite changes. * * Contract: * - `load` returns `null` for "no baseline yet" (first push to this * (env, recipe) pair). Throws only on integrity errors — malformed * data, unreachable backing store. Silently treating malformed data * as "no baseline" would re-introduce the silent-clobber failure * mode the baseline exists to prevent. * - `write` replaces the baseline wholesale (no merge). Caller passes * the full field list captured from the push that just succeeded. * - `locator` returns a human-readable string for diagnostics * ("file:/path/to/baseline.json", "orchestrator://env/recipe"). Used * in log messages + error hints; never parsed. */ export interface BaselineStorage { load(envName: string, recipeHandle: string): Promise; write(envName: string, recipeHandle: string, baseline: Baseline): Promise; /** Human-readable locator (for diagnostics; never parsed). */ locator(envName: string, recipeHandle: string): string; } /** * Kind discriminator used when content-recipe baselines flow through a * multi-kind sync storage backend (HTTP, in-memory, etc.). Stable * because it's serialised into orchestrator-side URLs / column values. */ export declare const CONTENT_RECIPE_BASELINE_KIND = "content-recipe"; /** * Default storage: a directory under `/.scai/baseline/`. * Per-(env, recipe) layout: `/.baseline.json`. * Mirrors the rest of scai's per-recipe artifact layout * (`.scai/.ir.json`, `.scai/.plan.json`). */ export declare class FileBaselineStorage implements BaselineStorage { readonly configDir: string; constructor(configDir: string); /** * Defensive: assert that the composed path resolves inside the * `/.scai/baseline/` container. Belt-and-braces against a * tenant-controlled `recipeHandle` (the recipe-handle map in pull's * merge mode is built from tenant projections; `handleOf` validates * the marker against HANDLE_PATTERN, but a regression in either * could otherwise let a malicious handle resolve outside the baseline * tree). */ private assertWithinBaselineDir; locator(envName: string, recipeHandle: string): string; load(envName: string, recipeHandle: string): Promise; write(envName: string, recipeHandle: string, baseline: Baseline): Promise; } /** * Resolve the on-disk path for a recipe's baseline file. Convenience * shorthand for `new FileBaselineStorage(configDir).locator(env, handle)` — * kept exported because earlier callers + tests reach for the path * directly to e.g. delete the file as a baseline reset. */ export declare const baselineFilePath: (configDir: string, envName: string, recipeHandle: string) => string; /** * Load a recipe's baseline via the default file-backed storage. * Equivalent to `new FileBaselineStorage(configDir).load(env, handle)`; * kept as a thin function for callsite ergonomics + because the existing * code paths reach for `loadBaseline` directly. Future storage backends * accept a custom `BaselineStorage` instance through the planner + * push-task options instead. */ export declare const loadBaseline: (configDir: string, envName: string, recipeHandle: string) => Promise; /** * Write a recipe's baseline via the default file-backed storage. The * baseline replaces any existing one wholesale — see `BaselineStorage` * JSDoc. `capturedAt` is the caller's job so the value is deterministic * for tests; production stamps `new Date().toISOString()`. */ export declare const writeBaseline: (configDir: string, envName: string, recipeHandle: string, fields: readonly BaselineFieldEntry[], capturedAt: string) => Promise; /** * Wrap a multi-kind sync `BaselineStorage` (e.g. `HttpBaselineStorage`) * so it satisfies the content-recipe 2-arg `BaselineStorage` surface. * `kind` is pinned to `CONTENT_RECIPE_BASELINE_KIND`; the recipe `Baseline` * shape becomes the `payload` of a multi-kind envelope on write, and * is unwrapped from the envelope on load. * * Use this to let one orchestrator-side backing store serve content * recipes alongside brand / brief / campaign / story baselines — * push/pull don't change shape; only the storage they're wired with does. */ export declare const adaptSyncBaselineStorage: (sync: import("../../sync").BaselineStorage) => BaselineStorage;