diff --git a/.changeset/30884-package-upgrade-plan.md b/.changeset/30884-package-upgrade-plan.md new file mode 100644 index 00000000000..923a790baf5 --- /dev/null +++ b/.changeset/30884-package-upgrade-plan.md @@ -0,0 +1,19 @@ +--- +'@objectstack/metadata-core': patch +'@objectstack/objectql': patch +'@objectstack/cloud-connection': patch +'@objectstack/cli': patch +--- + +feat: the package upgrade plan — one comparison of an installed package version with the incoming one, a dry run of the install door, and `os package install --dry-run` + +Clause-②: yes (widening) — new exports from `@objectstack/metadata-core` (`comparePackageVersions` and its types, `typeChangeCategory`, `isEmptyPackageUpgradePlan`, `upgradePlanSeedDatasets`, `seedModeOverwritesMatchedRows`), a new `planUpgrade` method on objectql's `manifest` service, a new `dryRun` key on the `POST /api/v1/marketplace/install-local` body, a new `--dry-run` flag on `os package install`, and new `os diff` entries (declared and suspected renames, seed datasets). Nothing a request did before answers differently. +Metadata-protocol: none + +**One comparison.** `comparePackageVersions(installed, incoming)` (`@objectstack/metadata-core`) lists what an upgrade from one version of a package to the next changes: objects and fields added and removed; type changes, each with the drift category `schema-drift.ts` uses (`safe`, `needs_confirm`, `destructive`); renames a field declares with `renamedFrom`; suspected renames, where one object loses a stored field and gains one with nothing linking them; and seed datasets added, removed and changed, each with its mode. A declared rename whose type changes is also listed as a type change. It is pure and decides nothing. + +**What the environment knows.** The `manifest` service's `planUpgrade(installed, incoming)` (`@objectstack/objectql`) answers the comparison with two readings of the installed data: the rows holding a value in each column the upgrade removes, renames or retypes (and the rows of each object it removes), and, for each `upsert` or `update` dataset both versions declare, the rows a customer edited that the seed replay would write over. A reading that fails is `null`, never `0`. + +**A dry run.** `POST /api/v1/marketplace/install-local` with `dryRun: true` answers `200` with the plan of the installed version (its ledger entry) against the one sent, and installs and writes nothing. A dry run of an `unmanaged` install or of a customization pair is refused `400 INVALID_REQUEST`, as is a `dryRun` that is not a boolean. `os package install … --dry-run` sends it and prints the plan. A runtime that predates the key ignores it and installs; the command then says so and exits 1. + +**`os diff`** reads its object and field changes from the same comparison, so the CLI and an install cannot disagree. Its output for every shape it reported before is unchanged. New: a field renamed with `renamedFrom` prints as one rename instead of a removal and an addition (`renamedFrom` is new in this release), a suspected rename prints beside its removal and addition, and seed datasets print under `data`. diff --git a/packages/cli/src/commands/diff.ts b/packages/cli/src/commands/diff.ts index 6e0cc3a8d56..bbe219fef65 100644 --- a/packages/cli/src/commands/diff.ts +++ b/packages/cli/src/commands/diff.ts @@ -2,6 +2,7 @@ import { Args, Command, Flags } from '@oclif/core'; import chalk from 'chalk'; +import { comparePackageVersions, seedModeOverwritesMatchedRows } from '@objectstack/metadata-core'; import { loadConfig } from '../utils/config.js'; import { authoringRuleUnionStack } from '../utils/stack-collections.js'; import { @@ -20,7 +21,7 @@ import { // ─── Types ────────────────────────────────────────────────────────── -interface DiffEntry { +export interface DiffEntry { type: 'added' | 'removed' | 'modified'; category: string; name: string; @@ -39,24 +40,10 @@ function getNames(items: any[] | undefined): Map { return map; } -function getFieldNames(obj: any): string[] { - if (!obj?.fields || typeof obj.fields !== 'object') return []; - return Object.keys(obj.fields); -} - -function getFieldType(obj: any, fieldName: string): string | undefined { - return obj?.fields?.[fieldName]?.type; -} - -function isFieldRequired(obj: any, fieldName: string): boolean { - return obj?.fields?.[fieldName]?.required === true; -} - function diffNamedArrays( beforeItems: any[] | undefined, afterItems: any[] | undefined, category: string, - detectFieldChanges: boolean, ): DiffEntry[] { const entries: DiffEntry[] = []; const beforeMap = getNames(beforeItems); @@ -86,76 +73,136 @@ function diffNamedArrays( } } - // Modified items — field-level diff for objects - if (detectFieldChanges) { - for (const [name, beforeObj] of beforeMap) { - const afterObj = afterMap.get(name); - if (!afterObj) continue; - - const beforeFields = getFieldNames(beforeObj); - const afterFields = getFieldNames(afterObj); - const beforeSet = new Set(beforeFields); - const afterSet = new Set(afterFields); - - // Removed fields - for (const f of beforeFields) { - if (!afterSet.has(f)) { - entries.push({ - type: 'removed', - category: `${category}.${name}.fields`, - name: f, - breaking: true, - detail: 'field removed', - }); - } - } + return entries; +} - // Added fields - for (const f of afterFields) { - if (!beforeSet.has(f)) { - const breaking = isFieldRequired(afterObj, f); - entries.push({ - type: 'added', - category: `${category}.${name}.fields`, - name: f, - breaking, - detail: breaking ? 'required field added' : 'optional field added', - }); - } - } +function seedModeNote(mode: string): string { + if (seedModeOverwritesMatchedRows(mode)) return '; a replay writes its columns over the rows it matches'; + if (mode === 'replace') return '; a replay deletes every row of the object and inserts these'; + return ''; +} - // Type changes - for (const f of beforeFields) { - if (!afterSet.has(f)) continue; - const oldType = getFieldType(beforeObj, f); - const newType = getFieldType(afterObj, f); - if (oldType && newType && oldType !== newType) { - entries.push({ - type: 'modified', - category: `${category}.${name}.fields`, - name: f, - breaking: true, - detail: `type changed: ${oldType} → ${newType}`, - }); - } - } +/** + * Objects, fields and seed datasets, read from the package upgrade plan + * (`comparePackageVersions`, `@objectstack/metadata-core`) — the comparison + * the install door's dry run and the `manifest` service's `planUpgrade` read, + * so this command and an install cannot disagree about one pair of versions. + * + * Printed per object as before: removed fields, added fields, then (new with + * the plan) declared renames, type changes, then suspected renames, then the + * object's label change. A suspected rename's two keys stay listed as removed + * and added, as the plan lists them: nothing declares otherwise. + */ +function diffObjectsAndSeeds(beforeConfig: any, afterConfig: any): DiffEntry[] { + const plan = comparePackageVersions(beforeConfig, afterConfig); + const entries: DiffEntry[] = []; - // Label / ownership changes on the object itself - if (beforeObj.label !== afterObj.label) { - entries.push({ - type: 'modified', - category, - name, - breaking: false, - detail: `label changed: "${beforeObj.label ?? '(none)'}" → "${afterObj.label ?? '(none)'}"`, - }); - } + for (const name of plan.objects.removed) entries.push({ type: 'removed', category: 'objects', name, breaking: true }); + for (const name of plan.objects.added) entries.push({ type: 'added', category: 'objects', name, breaking: false }); + + const afterObjects = getNames(afterConfig.objects); + for (const [name, beforeObj] of getNames(beforeConfig.objects)) { + const afterObj = afterObjects.get(name); + if (!afterObj) continue; + const category = `objects.${name}.fields`; + const mine = (list: T[]): T[] => list.filter((item) => item.object === name); + + for (const f of mine(plan.fields.removed)) { + entries.push({ type: 'removed', category, name: f.field, breaking: true, detail: 'field removed' }); + } + for (const f of mine(plan.fields.added)) { + entries.push({ + type: 'added', + category, + name: f.field, + breaking: f.required, + detail: f.required ? 'required field added' : 'optional field added', + }); + } + for (const r of mine(plan.declaredRenames)) { + entries.push({ type: 'modified', category, name: r.to, breaking: true, detail: `renamed from ${r.from}, declared by renamedFrom` }); + } + for (const t of mine(plan.typeChanges)) { + entries.push({ type: 'modified', category, name: t.field, breaking: true, detail: `type changed: ${t.from} → ${t.to}` }); + } + for (const s of mine(plan.suspectedRenames)) { + entries.push({ + type: 'modified', + category, + name: s.field, + breaking: true, + detail: `suspected rename to ${s.candidates.map((c) => c.field).join(' or ')}: no renamedFrom declares it, ` + + 'so the new key installs as a new empty column', + }); + } + + // Label / ownership changes on the object itself + if (beforeObj.label !== afterObj.label) { + entries.push({ + type: 'modified', + category: 'objects', + name, + breaking: false, + detail: `label changed: "${beforeObj.label ?? '(none)'}" → "${afterObj.label ?? '(none)'}"`, + }); } } + for (const d of plan.seeds.removed) { + entries.push({ type: 'removed', category: 'data', name: d.key, breaking: false, detail: `seed dataset removed, mode ${d.mode}` }); + } + for (const d of plan.seeds.added) { + entries.push({ + type: 'added', + category: 'data', + name: d.key, + breaking: false, + detail: `seed dataset added: ${d.records} record(s); mode ${d.mode}${seedModeNote(d.mode)}`, + }); + } + for (const d of plan.seeds.changed) { + const mode = d.fromMode === d.mode ? d.mode : `${d.fromMode} → ${d.mode}`; + entries.push({ + type: 'modified', + category: 'data', + name: d.key, + breaking: false, + detail: `seed dataset changed: ${d.changed.join(', ')}; mode ${mode}${seedModeNote(d.mode)}`, + }); + } + return entries; } +/** + * Every change between two folded stacks, in the order the command prints + * them: objects with their field-level changes first, then each name-level + * category, then seed datasets. + */ +export function diffStacks(beforeConfig: any, afterConfig: any): DiffEntry[] { + const plan = diffObjectsAndSeeds(beforeConfig, afterConfig); + const allDiffs: DiffEntry[] = plan.filter((entry) => entry.category !== 'data'); + + // Views, Flows, Agents, Apps (name-level diff) + const simpleCats: Array<{ key: string; label: string }> = [ + { key: 'views', label: 'views' }, + { key: 'flows', label: 'flows' }, + { key: 'agents', label: 'agents' }, + { key: 'apps', label: 'apps' }, + { key: 'dashboards', label: 'dashboards' }, + { key: 'actions', label: 'actions' }, + { key: 'workflows', label: 'workflows' }, + { key: 'apis', label: 'apis' }, + { key: 'positions', label: 'positions' }, + ]; + + for (const cat of simpleCats) { + allDiffs.push(...diffNamedArrays(beforeConfig[cat.key], afterConfig[cat.key], cat.label)); + } + allDiffs.push(...plan.filter((entry) => entry.category === 'data')); + return allDiffs; +} + // ─── Command ──────────────────────────────────────────────────────── export default class Diff extends Command { @@ -229,29 +276,7 @@ export default class Diff extends Command { } // ── Diff all categories ── - const allDiffs: DiffEntry[] = []; - - // Objects (with field-level diff) - allDiffs.push(...diffNamedArrays(beforeConfig.objects, afterConfig.objects, 'objects', true)); - - // Views, Flows, Agents, Apps (name-level diff) - const simpleCats: Array<{ key: string; label: string }> = [ - { key: 'views', label: 'views' }, - { key: 'flows', label: 'flows' }, - { key: 'agents', label: 'agents' }, - { key: 'apps', label: 'apps' }, - { key: 'dashboards', label: 'dashboards' }, - { key: 'actions', label: 'actions' }, - { key: 'workflows', label: 'workflows' }, - { key: 'apis', label: 'apis' }, - { key: 'positions', label: 'positions' }, - ]; - - for (const cat of simpleCats) { - allDiffs.push( - ...diffNamedArrays(beforeConfig[cat.key], afterConfig[cat.key], cat.label, false), - ); - } + const allDiffs = diffStacks(beforeConfig, afterConfig); // ── Filter ── const diffs = flags['breaking-only'] diff --git a/packages/cli/src/commands/package/install.ts b/packages/cli/src/commands/package/install.ts index 7dd59d3734f..8d62991ed2c 100644 --- a/packages/cli/src/commands/package/install.ts +++ b/packages/cli/src/commands/package/install.ts @@ -41,6 +41,14 @@ * the pair install, and registered it as read-only code) is printed as the * failure it is, with the way back. * + * `--dry-run` (epic cloud#3652 Q3, #30884) installs nothing: the runtime + * answers the package upgrade plan — the version it has installed against + * this one: objects, fields, type changes, declared (`renamedFrom`) and + * suspected renames, seed datasets — with the rows each touched column holds + * and the rows a customer edited that the seed replay would overwrite, and + * the command prints it. A runtime that predates the dry run ignores the key + * and installs; the command reads the answer, and says so when that happened. + * * This complements `os package publish` (which uploads to the CLOUD): the * pairing is publish-to-cloud / install-into-runtime. The target runtime * authenticates the call itself — pass a token it issued (-t/--token, or @@ -76,6 +84,86 @@ import { printHeader, printKV, printSuccess, printWarning, printError, printStep * withheld, or a runtime that predates the key) prints nothing, and nothing * is guessed from the artifact the command sent. */ +/** One row count, or the honest "could not be read". */ +function rowsText(rows: unknown, noun: string): string { + if (rows === null) return 'its rows could not be read'; + if (typeof rows !== 'number') return ''; + return `${rows} row(s) ${noun}`; +} + +/** + * [epic cloud#3652 Q3, #30884] The lines a dry run's answer prints: the + * package upgrade plan (`data.plan`, `comparePackageVersions`) with the + * environment's readings beside each finding (`data.environment`). Printed + * from the answer only; a list the answer does not carry prints nothing. + */ +export function renderUpgradePlan(data: any): string[] { + const plan = data?.plan ?? {}; + const env = data?.environment ?? {}; + const lines: string[] = []; + const columnRows = new Map(); + for (const c of Array.isArray(env.columns) ? env.columns : []) columnRows.set(`${c.object}.${c.field}`, c.rows); + const objectRows = new Map(); + for (const o of Array.isArray(env.removedObjects) ? env.removedObjects : []) objectRows.set(o.object, o.rows); + const list = (value: unknown): any[] => (Array.isArray(value) ? value : []); + const held = (object: string, field: string) => { + const text = rowsText(columnRows.get(`${object}.${field}`), 'hold a value'); + return text ? ` — ${text}` : ''; + }; + + const section = (title: string, items: string[]) => { + if (items.length === 0) return; + lines.push(` ${title}`); + for (const item of items) lines.push(` ${item}`); + }; + section('Objects', [ + ...list(plan.objects?.added).map((name: string) => `+ ${name}`), + ...list(plan.objects?.removed).map((name: string) => { + const text = rowsText(objectRows.get(name), 'in it'); + return `- ${name}${text ? ` — ${text}; the table is not dropped` : ''}`; + }), + ]); + section('Fields', [ + ...list(plan.fields?.added).map((f: any) => `+ ${f.object}.${f.field}${f.type ? ` (${f.type})` : ''}${f.required ? ' required' : ''}`), + ...list(plan.fields?.removed).map((f: any) => `- ${f.object}.${f.field}${f.type ? ` (${f.type})` : ''}${held(f.object, f.field)}; the column is not dropped`), + ]); + section('Renames declared by renamedFrom', list(plan.declaredRenames).map((r: any) => + `${r.object}.${r.from} → ${r.to}${held(r.object, r.from)}`)); + section('Suspected renames (no renamedFrom links them)', list(plan.suspectedRenames).map((r: any) => + `${r.object}.${r.field} → ${list(r.candidates).map((c: any) => c.field).join(' or ')}?${held(r.object, r.field)}; ` + + 'the new key would install as a new empty column. Declare renamedFrom on the new field and rebuild if it is a rename')); + section('Type changes', list(plan.typeChanges).map((t: any) => + `${t.object}.${t.field}: ${t.from} → ${t.to} (${t.category})${t.renamedFrom ? `, renamed from ${t.renamedFrom}` : ''}` + + `${held(t.object, t.renamedFrom ?? t.field)}`)); + + const overwrites = new Map(); + for (const o of list(env.seeds)) overwrites.set(o.key, o); + const seedLine = (d: any, sign: string) => { + const o = overwrites.get(d.key); + let line = `${sign} ${d.key} (mode ${d.fromMode && d.fromMode !== d.mode ? `${d.fromMode} → ${d.mode}` : d.mode}, ${d.records} record(s))`; + if (Array.isArray(d.changed)) line += `: ${d.changed.join(', ')} changed`; + if (o) { + line += o.editedRows === null + ? ' — rows a customer edited could not be read' + : ` — ${o.editedRows} row(s) a customer edited would be overwritten${o.editedKeys?.length ? ` (${o.editedKeys.join(', ')})` : ''}`; + } + return line; + }; + const changedKeys = new Set(list(plan.seeds?.changed).map((d: any) => d.key)); + section('Seed datasets', [ + ...list(plan.seeds?.added).map((d: any) => seedLine(d, '+')), + ...list(plan.seeds?.removed).map((d: any) => `- ${d.key} (mode ${d.mode}); its rows are not removed`), + ...list(plan.seeds?.changed).map((d: any) => seedLine(d, '~')), + // An unchanged overwriting dataset still writes over the rows a customer edited. + ...list(env.seeds).filter((o: any) => !changedKeys.has(o.key) && o.editedRows !== 0) + .map((o: any) => `= ${o.key} (mode ${o.mode}, unchanged)${o.editedRows === null + ? ' — rows a customer edited could not be read' + : ` — ${o.editedRows} row(s) a customer edited would be overwritten${o.editedKeys?.length ? ` (${o.editedKeys.join(', ')})` : ''}`}`), + ]); + if (lines.length === 0) lines.push(' No change to objects, fields or seed datasets.'); + return lines; +} + function printWithheldMaps(withheldMaps: unknown): void { const maps: Array<{ type?: unknown; name?: unknown; path?: unknown }> = Array.isArray(withheldMaps) ? withheldMaps : []; @@ -100,6 +188,7 @@ export default class PackageInstall extends Command { '$ os package install ./dist/objectstack.json --install-mode unmanaged # copy into the environment as its own metadata', '$ OS_RUNTIME_EMAIL=admin@local.test OS_RUNTIME_PASSWORD=… os package install com.acme.crm', '$ os package install ./dist/objectstack.json --url https://app.example.com --token osk_…', + '$ os package install ./dist/objectstack.json --dry-run # print the upgrade plan, install nothing', ]; static override args = { @@ -176,6 +265,14 @@ export default class PackageInstall extends Command { "Confirm the access findings of this upgrade with the finding-set token the runtime's refusal printed " + '(ACCESS_UPGRADE_CONFIRMATION_REQUIRED). It confirms exactly those findings; a changed set is refused again.', }), + // [epic cloud#3652 Q3, #30884] The install-local body's `dryRun`. + 'dry-run': Flags.boolean({ + description: + 'Install nothing: print the upgrade plan of the installed version against this one (objects, fields, type ' + + 'changes, declared and suspected renames, seed datasets), with the rows each touched column holds and the ' + + 'rows a customer edited that the seed replay would overwrite', + default: false, + }), }; async run(): Promise { @@ -274,6 +371,7 @@ export default class PackageInstall extends Command { if (flags['confirm-access-upgrade'] !== undefined) body.confirmAccessUpgrade = flags['confirm-access-upgrade']; const installMode = flags['install-mode'] as InstallMode | undefined; if (installMode !== undefined) body.installMode = installMode; + if (flags['dry-run']) body.dryRun = true; const res = await this.request(`${runtime}/api/v1/marketplace/install-local`, { method: 'POST', headers: { @@ -344,6 +442,30 @@ export default class PackageInstall extends Command { const data = res.body?.data ?? res.body ?? {}; + // [#30884] The dry run's answer: the plan, and nothing installed. + if (data.dryRun === true) { + console.log(''); + printSuccess('Dry run — nothing was installed or written'); + printKV(' Package', String(data.manifestId ?? label)); + printKV(' Version', `${String(data.version ?? flags.version)}${typeof data.installedVersion === 'string' ? ` (installed: ${data.installedVersion})` : ' (not installed)'}`); + printKV(' Runtime', runtime); + console.log(''); + for (const line of renderUpgradePlan(data)) console.log(line); + console.log(''); + return; + } + // Asked for a dry run, answered without it: the runtime predates the + // key, read no `dryRun`, and INSTALLED. Said out loud, never printed as + // a plan — the opposite of what was asked happened. + if (flags['dry-run']) { + printError( + `The runtime at ${runtime} does not support a dry run: it ignored \`--dry-run\` and INSTALLED ${label}. ` + + 'Upgrade the runtime to preview an upgrade before it is installed.', + ); + this.exit(1); + return; + } + // [ADR-0146 D3, #30106] A base's paired customization package answers // its own shape too: the writable pair its items were written into, // never a kernel registration or a cached manifest — so, as for an diff --git a/packages/cli/test/diff-upgrade-plan.test.ts b/packages/cli/test/diff-upgrade-plan.test.ts new file mode 100644 index 00000000000..5a59479d104 --- /dev/null +++ b/packages/cli/test/diff-upgrade-plan.test.ts @@ -0,0 +1,156 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `os diff` reads its object and field changes from the package upgrade plan + * (`comparePackageVersions`, `@objectstack/metadata-core`), the comparison the + * install door and the `manifest` service's `planUpgrade` read too (epic + * cloud#3652 Q3). + * + * The first block pins today's output, byte for byte (`JSON.stringify`, so the + * key order of each entry is pinned as well — `--json` prints these objects), + * over every shape the command reported before it moved onto the plan. It was + * written against the command's own comparison and stayed green across the + * move. The second block pins what the plan adds: renames, suspected renames + * and seed datasets. + */ + +import { describe, expect, it } from 'vitest'; +import type { DriftCategory } from '@objectstack/driver-sql'; +import type { UpgradeDriftCategory } from '@objectstack/metadata-core'; +import { diffStacks } from '../src/commands/diff.js'; + +// The plan restates `schema-drift.ts`'s three drift words, since +// `@objectstack/metadata-core` cannot depend on a driver; this package depends +// on both, so the two unions are held equal here. A word added to or dropped +// from either side fails `tsc` on this line. +type Equal = (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; +const driftWordsAgree: Equal = true; + +const BEFORE = { + objects: [ + { name: 'acme_deal', label: 'Deal', fields: { title: { type: 'text' }, amount: { type: 'number' }, stage: { type: 'text' } } }, + { name: 'acme_lead', label: 'Lead', fields: { legacy: { type: 'text' }, name: { type: 'text' } } }, + { name: 'acme_old', label: 'Old', fields: { x: { type: 'text' } } }, + { name: 'acme_dup', fields: { a: { type: 'text' } } }, + { name: 'acme_dup', fields: { b: { type: 'text' } } }, + ], + views: [{ name: 'deal_list' }, { name: 'old_view' }], + flows: [{ name: 'f1' }], + positions: [{ name: 'p1' }], +}; + +const AFTER = { + objects: [ + { + name: 'acme_deal', + label: 'Deals', + fields: { + title: { type: 'text' }, + amount: { type: 'currency' }, + stage: { type: 'text' }, + code: { type: 'text', required: true }, + note: { type: 'textarea' }, + }, + }, + { name: 'acme_lead', label: 'Lead', fields: { name: { type: 'text' } } }, + { name: 'acme_new', fields: {} }, + { name: 'acme_dup', fields: { b: { type: 'text' } } }, + ], + views: [{ name: 'deal_list' }, { name: 'new_view' }], + flows: [], + apps: [{ name: 'a1' }], + positions: [{ name: 'p1' }], +}; + +describe('os diff — the output it printed before the plan, unchanged', () => { + it('every shape, in order, with each entry\'s key order', () => { + expect(driftWordsAgree).toBe(true); + const expected = [ + { type: 'removed', category: 'objects', name: 'acme_old', breaking: true }, + { type: 'added', category: 'objects', name: 'acme_new', breaking: false }, + { type: 'added', category: 'objects.acme_deal.fields', name: 'code', breaking: true, detail: 'required field added' }, + { type: 'added', category: 'objects.acme_deal.fields', name: 'note', breaking: false, detail: 'optional field added' }, + { type: 'modified', category: 'objects.acme_deal.fields', name: 'amount', breaking: true, detail: 'type changed: number → currency' }, + { type: 'modified', category: 'objects', name: 'acme_deal', breaking: false, detail: 'label changed: "Deal" → "Deals"' }, + { type: 'removed', category: 'objects.acme_lead.fields', name: 'legacy', breaking: true, detail: 'field removed' }, + { type: 'removed', category: 'views', name: 'old_view', breaking: true }, + { type: 'added', category: 'views', name: 'new_view', breaking: false }, + { type: 'removed', category: 'flows', name: 'f1', breaking: true }, + { type: 'added', category: 'apps', name: 'a1', breaking: false }, + ]; + expect(JSON.stringify(diffStacks(BEFORE, AFTER))).toBe(JSON.stringify(expected)); + }); + + it('an identical pair reports nothing', () => { + expect(diffStacks(BEFORE, structuredClone(BEFORE))).toEqual([]); + }); + + it('a stack with no objects on either side reports only its name-level categories', () => { + expect(diffStacks({ views: [{ name: 'v' }] }, {})).toEqual([ + { type: 'removed', category: 'views', name: 'v', breaking: true }, + ]); + }); +}); + +describe('os diff — what the upgrade plan adds', () => { + const lead = (fields: Record, data?: unknown[]) => ({ + objects: [{ name: 'acme_lead', label: 'Lead', fields }], + ...(data ? { data } : {}), + }); + + it('a field removed and one added with no renamedFrom: both listed, and a suspected rename on the removed key', () => { + expect(diffStacks(lead({ company: { type: 'text' } }), lead({ account_name: { type: 'text' } }))).toEqual([ + { type: 'removed', category: 'objects.acme_lead.fields', name: 'company', breaking: true, detail: 'field removed' }, + { type: 'added', category: 'objects.acme_lead.fields', name: 'account_name', breaking: false, detail: 'optional field added' }, + { + type: 'modified', category: 'objects.acme_lead.fields', name: 'company', breaking: true, + detail: 'suspected rename to account_name: no renamedFrom declares it, so the new key installs as a new empty column', + }, + ]); + }); + + it('the same pair with renamedFrom: one rename, no removed, added or suspected entry', () => { + expect(diffStacks( + lead({ company: { type: 'text' } }), + lead({ account_name: { type: 'text', renamedFrom: ['company'] } }), + )).toEqual([ + { type: 'modified', category: 'objects.acme_lead.fields', name: 'account_name', breaking: true, detail: 'renamed from company, declared by renamedFrom' }, + ]); + }); + + it('a declared rename that changes type prints the type change too', () => { + expect(diffStacks( + lead({ amount: { type: 'text' } }), + lead({ amount_value: { type: 'number', renamedFrom: ['amount'] } }), + ).map((e) => e.detail)).toEqual(['renamed from amount, declared by renamedFrom', 'type changed: text → number']); + }); + + it('seed datasets, after every other category, each with its mode', () => { + const before = lead({}, [ + { object: 'acme_stage', mode: 'upsert', records: [{ name: 'new' }] }, + { object: 'acme_region', records: [{ name: 'emea' }] }, + { object: 'acme_old', mode: 'insert', records: [{ name: 'x' }] }, + ]); + const after = { + ...lead({}, [ + { object: 'acme_stage', mode: 'upsert', records: [{ name: 'new' }, { name: 'won' }] }, + { object: 'acme_region', records: [{ name: 'apac' }] }, + { object: 'acme_tier', mode: 'replace', records: [{ name: 'gold' }] }, + ]), + views: [{ name: 'lead_list' }], + }; + expect(diffStacks(before, after)).toEqual([ + { type: 'added', category: 'views', name: 'lead_list', breaking: false }, + { type: 'removed', category: 'data', name: 'acme_old', breaking: false, detail: 'seed dataset removed, mode insert' }, + { + type: 'added', category: 'data', name: 'acme_tier', breaking: false, + detail: 'seed dataset added: 1 record(s); mode replace; a replay deletes every row of the object and inserts these', + }, + { + type: 'modified', category: 'data', name: 'acme_stage', breaking: false, + detail: 'seed dataset changed: records; mode upsert; a replay writes its columns over the rows it matches', + }, + { type: 'modified', category: 'data', name: 'acme_region', breaking: false, detail: 'seed dataset changed: records; mode ignore' }, + ]); + }); +}); diff --git a/packages/cli/test/package-install-dry-run.test.ts b/packages/cli/test/package-install-dry-run.test.ts new file mode 100644 index 00000000000..0800e8b6491 --- /dev/null +++ b/packages/cli/test/package-install-dry-run.test.ts @@ -0,0 +1,143 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [epic cloud#3652 Q3, #30884] `os package install --dry-run` — the + * install-local body's `dryRun`, and how the command prints the runtime's + * upgrade plan. + * + * Pinned: + * - the flag reaches the body as `dryRun: true`; without it the body carries + * no such key; + * - the answer prints as a dry run (nothing installed), each finding with + * the environment's reading beside it: rows in a touched column, rows a + * customer edited that the seed replay would overwrite, a reading that + * failed as "could not be read"; + * - a runtime that ignored the key and installed is printed as the failure + * it is, exit 1. + */ + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import PackageInstall, { renderUpgradePlan } from '../src/commands/package/install.js'; + +type Answer = { ok: boolean; status: number; body: unknown }; + +function stubRuntime(answer: Answer): { sent: Array> } { + const sent: Array> = []; + vi.stubGlobal('fetch', vi.fn(async (_url: string, init?: { body?: string }) => { + if (init?.body) sent.push(JSON.parse(init.body)); + return { + ok: answer.ok, + status: answer.status, + statusText: answer.ok ? 'OK' : 'Refused', + headers: { get: () => null }, + json: async () => answer.body, + } as any; + })); + return { sent }; +} + +async function runInstall(args: string[]): Promise<{ out: string; exit: number | undefined }> { + const lines: string[] = []; + const capture = (...a: unknown[]) => { lines.push(a.map(String).join(' ')); }; + vi.spyOn(console, 'log').mockImplementation(capture); + vi.spyOn(console, 'error').mockImplementation(capture); + vi.spyOn(process.stderr, 'write').mockImplementation(((chunk: unknown) => { lines.push(String(chunk)); return true; }) as any); + let exit: number | undefined; + try { + await PackageInstall.run(['com.acme.crm', '--url', 'http://runtime.test', ...args]); + } catch (err: any) { + exit = err?.oclif?.exit ?? err?.code ?? 1; + } + return { out: lines.join('\n'), exit }; +} + +const PLAN_ANSWER = { + dryRun: true, + manifestId: 'com.acme.crm', + version: '2.0.0', + installedVersion: '1.0.0', + plan: { + objects: { added: [], removed: ['acme_old'] }, + fields: { + added: [{ object: 'acme_lead', field: 'account_name', type: 'text', required: false, stored: true }], + removed: [{ object: 'acme_lead', field: 'company', type: 'text', required: false, stored: true }], + }, + typeChanges: [{ object: 'acme_deal', field: 'amount_value', from: 'text', to: 'number', category: 'needs_confirm', renamedFrom: 'amount' }], + declaredRenames: [{ object: 'acme_deal', from: 'amount', to: 'amount_value', fromType: 'text', toType: 'number' }], + suspectedRenames: [{ object: 'acme_lead', field: 'company', type: 'text', candidates: [{ field: 'account_name', type: 'text' }] }], + seeds: { + added: [], + removed: [], + changed: [{ key: 'acme_stage', object: 'acme_stage', externalId: 'name', mode: 'upsert', records: 3, fromMode: 'upsert', fromRecords: 3, changed: ['records'] }], + }, + }, + environment: { + columns: [ + { object: 'acme_lead', field: 'company', reasons: ['removed', 'suspected_rename'], rows: 42 }, + { object: 'acme_deal', field: 'amount', reasons: ['renamed', 'type_change'], rows: null }, + ], + removedObjects: [{ object: 'acme_old', rows: 7 }], + seeds: [ + { key: 'acme_stage', object: 'acme_stage', mode: 'upsert', changed: true, matchedRows: 3, editedRows: 1, editedKeys: ['won'] }, + { key: 'acme_tier', object: 'acme_tier', mode: 'upsert', changed: false, matchedRows: 2, editedRows: 2, editedKeys: ['gold', 'silver'] }, + ], + }, +}; + +describe('os package install --dry-run (#30884)', () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('sends `dryRun: true`, and no such key without the flag', async () => { + const { sent } = stubRuntime({ ok: true, status: 200, body: { success: true, data: PLAN_ANSWER } }); + await runInstall(['--dry-run']); + expect(sent[0]).toMatchObject({ packageId: 'com.acme.crm', dryRun: true }); + + vi.unstubAllGlobals(); + const second = stubRuntime({ ok: true, status: 200, body: { success: true, data: { manifestId: 'com.acme.crm', version: '2.0.0', hotLoaded: true } } }); + await runInstall([]); + expect(second.sent[0]).not.toHaveProperty('dryRun'); + }); + + it('prints the plan with each reading beside its finding, and says nothing was installed', async () => { + stubRuntime({ ok: true, status: 200, body: { success: true, data: PLAN_ANSWER } }); + const { out, exit } = await runInstall(['--dry-run']); + expect(exit).toBeUndefined(); + expect(out).toContain('Dry run — nothing was installed or written'); + expect(out).toContain('2.0.0 (installed: 1.0.0)'); + expect(out).not.toContain('Package installed'); + }); + + it('the plan lines', () => { + expect(renderUpgradePlan(PLAN_ANSWER)).toEqual([ + ' Objects', + ' - acme_old — 7 row(s) in it; the table is not dropped', + ' Fields', + ' + acme_lead.account_name (text)', + ' - acme_lead.company (text) — 42 row(s) hold a value; the column is not dropped', + ' Renames declared by renamedFrom', + ' acme_deal.amount → amount_value — its rows could not be read', + ' Suspected renames (no renamedFrom links them)', + ' acme_lead.company → account_name? — 42 row(s) hold a value; the new key would install as a new empty column. ' + + 'Declare renamedFrom on the new field and rebuild if it is a rename', + ' Type changes', + ' acme_deal.amount_value: text → number (needs_confirm), renamed from amount — its rows could not be read', + ' Seed datasets', + ' ~ acme_stage (mode upsert, 3 record(s)): records changed — 1 row(s) a customer edited would be overwritten (won)', + ' = acme_tier (mode upsert, unchanged) — 2 row(s) a customer edited would be overwritten (gold, silver)', + ]); + }); + + it('an empty plan says so', () => { + expect(renderUpgradePlan({ plan: {}, environment: {} })).toEqual([' No change to objects, fields or seed datasets.']); + }); + + it('a runtime that ignored --dry-run and installed is a failure, exit 1', async () => { + stubRuntime({ ok: true, status: 200, body: { success: true, data: { manifestId: 'com.acme.crm', version: '2.0.0', hotLoaded: true } } }); + const { out, exit } = await runInstall(['--dry-run']); + expect(exit).toBe(1); + expect(out).toContain('does not support a dry run: it ignored `--dry-run` and INSTALLED com.acme.crm@latest'); + }); +}); diff --git a/packages/cloud-connection/src/marketplace-install-local-plugin.ts b/packages/cloud-connection/src/marketplace-install-local-plugin.ts index f5d54196187..975487202cf 100644 --- a/packages/cloud-connection/src/marketplace-install-local-plugin.ts +++ b/packages/cloud-connection/src/marketplace-install-local-plugin.ts @@ -95,6 +95,18 @@ * answers `503 SERVICE_UNAVAILABLE`. Under a wall only the operator * reaches this step (ADR-0131 D12 items 7–8). * + * [epic cloud#3652 Q3, #30884] `dryRun: true` answers `200` with the + * package upgrade plan — the installed version (its ledger entry) + * against this one: objects, fields, type changes, declared and + * suspected renames, seed datasets — and the `manifest` service's + * readings of this environment (rows in each touched column, rows a + * customer edited that the seed replay overwrites), after every + * refusal that says the package cannot install at all and before + * any confirmation ceremony or write. Nothing is installed or + * written. A dry run of an `unmanaged` install or of a + * customization pair answers `400 INVALID_REQUEST`, as does a + * `dryRun` that is not a boolean. + * * GET /api/v1/marketplace/install-local * → lists currently installed marketplace packages. Requires an * authenticated principal (anonymous → 401); `installedBy` and @@ -1800,6 +1812,35 @@ export class MarketplaceInstallLocalPlugin implements Plugin { } const installMode: InstallMode = requestedMode.data; + // 0'. [epic cloud#3652 Q3, #30884] ⭐ `dryRun: true` answers the + // package upgrade plan and writes nothing (step 2a′ below). Read + // here with the mode, before anything is fetched: a key this door + // cannot read is refused, never read as "install for real", and a + // dry run of an `unmanaged` install (a copy, which follows no + // upgrade, and whose branch below writes) is refused rather than + // run. + if (body?.dryRun !== undefined && typeof body.dryRun !== 'boolean') { + return c.json({ + success: false, + error: { + code: 'INVALID_REQUEST', + message: `\`dryRun\` must be true or false, received ${JSON.stringify(body.dryRun)}. Nothing was installed or written.`, + }, + }, 400); + } + const dryRun = body?.dryRun === true; + if (dryRun && installMode === 'unmanaged') { + return c.json({ + success: false, + error: { + code: 'INVALID_REQUEST', + message: 'A dry run previews a managed install or upgrade; an `unmanaged` install is a copy that follows no ' + + 'upgrade, so there is no plan to preview. Omit `dryRun`, or omit `installMode`. Nothing was installed ' + + 'or written.', + }, + }, 400); + } + // ── Offline path: an inline manifest was supplied (file import). ── // Bypass the cloud-fetch entirely; no OS_CLOUD_URL required. const inlineManifest = body?.manifest && typeof body.manifest === 'object' ? body.manifest : null; @@ -2229,6 +2270,19 @@ export class MarketplaceInstallLocalPlugin implements Plugin { }, UNRUNNABLE_REFUSAL_STATUS); } if (pairBaseId !== null) { + // [#30884] The pair's branch registers and writes on its own path; + // the upgrade plan previews a package's own data schema, not a + // pair's, so a dry run of it is refused rather than run. + if (dryRun) { + return c.json({ + success: false, + error: { + code: 'INVALID_REQUEST', + message: `Package '${manifestId}' is a customization pair over '${pairBaseId}': a dry run previews ` + + 'a package install or upgrade, not a pair. Omit `dryRun`. Nothing was installed or written.', + }, + }, 400); + } return this.installCustomizationPair(c, ctx, { manifest, manifestId, @@ -2325,6 +2379,15 @@ export class MarketplaceInstallLocalPlugin implements Plugin { }, 409); } + // 2a′. [epic cloud#3652 Q3, #30884] ⭐ THE DRY RUN answers here, + // after every refusal that says this package cannot install at + // all (payload, id, wall, handshake, dependencies, mode, code it + // cannot run, namespace, conflict, datasource holder) and before + // the first confirmation ceremony (2b) and the first write (3): + // the plan is what an installer reads before deciding, so it is + // answered whether or not a ceremony would stop the install. + if (dryRun) return this.answerUpgradeDryRun(c, ctx, { manifest, manifestId, version }); + // 2b. [ADR-0120 D5e] `isolated`-posture gate on installation-wide // uniques. Runs BEFORE hot-register and before anything is written // to the ledger: a stopped install must leave the runtime exactly as @@ -4835,6 +4898,69 @@ export class MarketplaceInstallLocalPlugin implements Plugin { * * An engine without that door (not ObjectQL) is handed the body as before. */ + /** + * [epic cloud#3652 Q3, #30884] The dry run (`dryRun: true`, step 2a′): the + * package upgrade plan of the installed version against this one, with + * what only this environment knows, and nothing installed or written. + * + * Both versions are compared as the registry reads them — each converted + * forward ({@link convertPackageBodyForward}), the installed one from its + * ledger entry — by the `manifest` service's `planUpgrade` + * (`@objectstack/objectql`): the comparison is + * `comparePackageVersions` (`@objectstack/metadata-core`), the one + * `os diff` reads, and the readings are rows in each column the upgrade + * touches and rows a customer edited that the seed replay overwrites. + * + * A runtime whose `manifest` service answers no `planUpgrade` answers 503: + * a preview that could not run is never answered as an empty one. A + * ledger entry that will not parse is said so, and the plan is then + * against no installed version. + */ + private answerUpgradeDryRun = async ( + c: any, + ctx: PluginContext, + args: { manifest: any; manifestId: string; version: string }, + ): Promise => { + const { manifest, manifestId, version } = args; + let service: { planUpgrade?: (installed: unknown, incoming: unknown) => Promise } | undefined; + try { service = ctx.getService('manifest') as typeof service; } catch { /* no manifest service */ } + if (typeof service?.planUpgrade !== 'function') { + return c.json({ + success: false, + error: { + code: 'SERVICE_UNAVAILABLE', + message: `A dry run of '${manifestId}' cannot be answered: this runtime's \`manifest\` service does not ` + + 'answer `planUpgrade`, which the plan is read from. Nothing was installed or written.', + }, + }, 503); + } + const ledgerRead = this.ledger.read(manifestId); + const installedEntry = ledgerRead.entry; + const installed = installedEntry?.manifest ? this.convertPackageBodyForward(ctx, installedEntry.manifest) : undefined; + const reading = await service.planUpgrade(installed, this.convertPackageBodyForward(ctx, manifest)) as { + plan: unknown; + environment: unknown; + }; + return c.json({ + success: true, + data: { + dryRun: true, + manifestId, + version, + installedVersion: installedEntry?.version ?? null, + ...(ledgerRead.failure ? { installedRecordUnreadable: true } : {}), + plan: reading.plan, + environment: reading.environment, + message: installedEntry + ? `Dry run: the plan of upgrading '${manifestId}' from ${installedEntry.version} to ${version}. Nothing was installed or written.` + : ledgerRead.failure + ? `Dry run: the record of the installed '${manifestId}' could not be read, so the plan lists everything ` + + `${version} adds, against no installed version. Nothing was installed or written.` + : `Dry run: '${manifestId}' is not installed here, so the plan lists everything ${version} adds. Nothing was installed or written.`, + }, + }, 200); + }; + private convertPackageBodyForward = (ctx: PluginContext, manifest: unknown): any => { let ql: { convertPackageBodyForward?: (body: unknown) => unknown } | undefined; try { ql = ctx.getService('objectql') as typeof ql; } catch { /* no data engine */ } diff --git a/packages/cloud-connection/src/marketplace-install-local-upgrade-plan.test.ts b/packages/cloud-connection/src/marketplace-install-local-upgrade-plan.test.ts new file mode 100644 index 00000000000..3946df319d3 --- /dev/null +++ b/packages/cloud-connection/src/marketplace-install-local-upgrade-plan.test.ts @@ -0,0 +1,206 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [epic cloud#3652 Q3, #30884] The marketplace door's dry run: `dryRun: true` + * answers the package upgrade plan of the installed version (its ledger + * entry) against the incoming one, read from the `manifest` service's + * `planUpgrade`, and writes nothing — no registration, no schema sync, no + * ledger change. + * + * `planUpgrade` here is the comparison the real service runs + * (`comparePackageVersions`, each side folded by `resolveStackCollection`) + * with an empty environment reading: the readings themselves are pinned on + * the real engine in `@objectstack/objectql`'s + * `package-upgrade-environment.test.ts`. What this file pins is the door: what + * it hands the service, what it answers, and that it writes nothing. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +// [#21321] An install binds the package's handlers through +// `@objectstack/runtime` (a lazy `import()` inside the plugin). Its first load +// is paid here, at module top — never inside a clocked `it`. +import '@objectstack/runtime'; +import { resolveStackCollection } from '@objectstack/core'; +import { comparePackageVersions } from '@objectstack/metadata-core'; +import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js'; +import { installerAuthService, withInstallerGrants } from './install-local-principal.fixtures.js'; +import { LocalManifestSource } from './local-manifest-source.js'; + +type Handler = (c: any) => Promise; +type Row = Record; + +const VENDOR = 'com.acme.leads'; +const INSTALLER = 'usr_installer'; + +function vendorBody(version: string, fields: Row, data: Row[] = []): Row { + return { + id: VENDOR, + namespace: 'acme', + version, + objects: [{ name: 'acme_lead', label: 'Lead', fields }], + ...(data.length > 0 ? { data } : {}), + }; +} + +const V1 = vendorBody('1.0.0', { company: { type: 'text' } }, [{ object: 'acme_lead', mode: 'upsert', records: [{ name: 'a', company: 'Acme' }] }]); +const V2 = vendorBody('2.0.0', { account_name: { type: 'text' } }, [{ object: 'acme_lead', mode: 'upsert', records: [{ name: 'a', account_name: 'Acme' }] }]); + +function makeRawApp() { + const routes = new Map(); + return { + routes, + get: (p: string, h: Handler) => routes.set(`GET ${p}`, h), + post: (p: string, h: Handler) => routes.set(`POST ${p}`, h), + delete: (p: string, h: Handler) => routes.set(`DELETE ${p}`, h), + }; +} + +function makeC(body: any) { + const json = vi.fn((payload: any, status?: number) => ({ payload, status: status ?? 200 })); + return { + req: { + url: 'http://localhost:3000/api/v1/marketplace/install-local', + raw: new Request('http://localhost:3000/x'), + json: async () => body, + param: () => undefined, + }, + json, + }; +} + +let dir: string; +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'mil-upgrade-plan-')); +}); +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + vi.restoreAllMocks(); +}); + +const fold = (artifact: unknown) => ({ objects: resolveStackCollection(artifact, 'objects'), data: resolveStackCollection(artifact, 'data') }); + +async function mount(options: { installed?: Row; planUpgrade?: false } = {}) { + const register = vi.fn(); + const syncSchemas = vi.fn(async () => undefined); + const planUpgrade = vi.fn(async (installed: unknown, incoming: unknown) => ({ + plan: comparePackageVersions(fold(installed), fold(incoming)), + environment: { columns: [], removedObjects: [], seeds: [] }, + })); + const rawApp = makeRawApp(); + const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }; + const services: Record = { + manifest: options.planUpgrade === false ? { register } : { register, planUpgrade }, + auth: installerAuthService(INSTALLER), + objectql: withInstallerGrants({ syncSchemas }, INSTALLER), + }; + const hooks = new Map(); + const ctx = { + hook: (e: string, h: any) => hooks.set(e, h), + getService: (name: string) => { + if (name === 'http-server') return { getRawApp: () => rawApp }; + if (!(name in services)) throw new Error(`no ${name}`); + return services[name]; + }, + logger, + }; + const plugin = new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir: dir }); + await plugin.start(ctx as any); + await hooks.get('kernel:ready')?.(); + const install = rawApp.routes.get('POST /api/v1/marketplace/install-local')!; + if (options.installed) { + const first = await install(makeC({ manifest: options.installed })); + expect(first.status).toBe(200); + } + register.mockClear(); + syncSchemas.mockClear(); + return { install, register, syncSchemas, planUpgrade }; +} + +/** Every ledger file and its bytes. */ +const ledgerFiles = (): Record => + Object.fromEntries(readdirSync(dir).sort().map((f) => [f, readFileSync(join(dir, f), 'utf8')])); + +describe('dryRun: true answers the upgrade plan and writes nothing', () => { + it('a re-install: the installed ledger body against the incoming one; registry, schema and ledger untouched', async () => { + const door = await mount({ installed: V1 }); + const before = ledgerFiles(); + + const res = await door.install(makeC({ manifest: V2, dryRun: true })); + + expect(res.status).toBe(200); + expect(res.payload.data).toMatchObject({ dryRun: true, manifestId: VENDOR, version: '2.0.0', installedVersion: '1.0.0' }); + expect(res.payload.data.plan.suspectedRenames).toEqual([ + { object: 'acme_lead', field: 'company', type: 'text', candidates: [{ field: 'account_name', type: 'text' }] }, + ]); + expect(res.payload.data.plan.seeds.changed).toEqual([expect.objectContaining({ key: 'acme_lead', mode: 'upsert', changed: ['records'] })]); + expect(res.payload.data.environment).toEqual({ columns: [], removedObjects: [], seeds: [] }); + expect(res.payload.data.message).toMatch(/from 1\.0\.0 to 2\.0\.0\. Nothing was installed or written\./); + + // The service was handed the installed version from the ledger and the incoming one. + const [installed, incoming] = door.planUpgrade.mock.calls[0]; + expect(resolveStackCollection(installed, 'objects')).toEqual(V1.objects); + expect(resolveStackCollection(incoming, 'objects')).toEqual(V2.objects); + + // Nothing written: no registration, no schema sync, the ledger byte for byte. + expect(door.register).not.toHaveBeenCalled(); + expect(door.syncSchemas).not.toHaveBeenCalled(); + expect(ledgerFiles()).toEqual(before); + expect(new LocalManifestSource(dir).read(VENDOR).entry?.version).toBe('1.0.0'); + }); + + it('a package not installed here: the plan lists what it adds, and no ledger entry appears', async () => { + const door = await mount(); + const res = await door.install(makeC({ manifest: V1, dryRun: true })); + expect(res.status).toBe(200); + expect(res.payload.data).toMatchObject({ dryRun: true, installedVersion: null }); + expect(res.payload.data.plan.objects.added).toEqual(['acme_lead']); + expect(res.payload.data.message).toMatch(/is not installed here/); + expect(door.register).not.toHaveBeenCalled(); + expect(door.syncSchemas).not.toHaveBeenCalled(); + expect(ledgerFiles()).toEqual({}); + }); + + it('CONTROL: the same request without dryRun installs', async () => { + const door = await mount({ installed: V1 }); + const res = await door.install(makeC({ manifest: V2 })); + expect(res.status).toBe(200); + expect(door.register).toHaveBeenCalledTimes(1); + expect(door.planUpgrade).not.toHaveBeenCalled(); + expect(new LocalManifestSource(dir).read(VENDOR).entry?.version).toBe('2.0.0'); + }); + + it('a runtime whose manifest service answers no planUpgrade answers 503, never an empty plan', async () => { + const door = await mount({ installed: V1, planUpgrade: false }); + const before = ledgerFiles(); + const res = await door.install(makeC({ manifest: V2, dryRun: true })); + expect(res.status).toBe(503); + expect(res.payload.error.code).toBe('SERVICE_UNAVAILABLE'); + expect(res.payload.error.message).toMatch(/planUpgrade/); + expect(door.register).not.toHaveBeenCalled(); + expect(ledgerFiles()).toEqual(before); + }); +}); + +describe('a dryRun this door cannot honour is refused, never run as an install', () => { + it('a dryRun that is not a boolean', async () => { + const door = await mount(); + const res = await door.install(makeC({ manifest: V1, dryRun: 'yes' })); + expect(res.status).toBe(400); + expect(res.payload.error).toMatchObject({ code: 'INVALID_REQUEST' }); + expect(res.payload.error.message).toMatch(/`dryRun` must be true or false/); + expect(door.register).not.toHaveBeenCalled(); + expect(ledgerFiles()).toEqual({}); + }); + + it('a dry run of an unmanaged install', async () => { + const door = await mount(); + const res = await door.install(makeC({ manifest: V1, dryRun: true, installMode: 'unmanaged' })); + expect(res.status).toBe(400); + expect(res.payload.error.message).toMatch(/an `unmanaged` install is a copy that follows no upgrade/); + expect(door.register).not.toHaveBeenCalled(); + expect(ledgerFiles()).toEqual({}); + }); +}); diff --git a/packages/metadata-core/src/index.ts b/packages/metadata-core/src/index.ts index 4e7c2a0b0cf..112ca09f20d 100644 --- a/packages/metadata-core/src/index.ts +++ b/packages/metadata-core/src/index.ts @@ -156,3 +156,9 @@ export * from './extension-field-patch-targets.js'; // authoring gate the draft verdict and the publish doors run, so a flow the // engine refuses to register no longer reads valid and publishes. export * from './flow-cycle.js'; + +// [epic cloud#3652 Q3] The package upgrade plan — one comparison of an installed +// package version with the incoming one, read by `os diff`, the marketplace +// install door's dry run and the `manifest` service's `planUpgrade`, so the +// preview an installer reads and the comparison a door decides on are one. +export * from './package-upgrade-plan.js'; diff --git a/packages/metadata-core/src/package-upgrade-plan.test.ts b/packages/metadata-core/src/package-upgrade-plan.test.ts new file mode 100644 index 00000000000..0b6cba66c55 --- /dev/null +++ b/packages/metadata-core/src/package-upgrade-plan.test.ts @@ -0,0 +1,201 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The package upgrade plan's comparison (epic cloud#3652 Q3): what it lists + * for each shape of change between an installed version and the incoming one. + */ + +import { describe, expect, it } from 'vitest'; +import { SeedMode } from '@objectstack/spec/data'; +import { + comparePackageVersions, + isEmptyPackageUpgradePlan, + seedModeOverwritesMatchedRows, + typeChangeCategory, + upgradePlanSeedDatasets, +} from './package-upgrade-plan.js'; + +type Fields = Record>; + +const side = (fields: Fields, extra: Record = {}) => ({ + objects: [{ name: 'acme_deal', label: 'Deal', fields }], + ...extra, +}); + +describe('fields and objects', () => { + it('an identical pair lists nothing', () => { + const body = side({ title: { type: 'text' } }, { data: [{ object: 'acme_deal', records: [{ name: 'a' }] }] }); + const plan = comparePackageVersions(body, structuredClone(body)); + expect(isEmptyPackageUpgradePlan(plan)).toBe(true); + }); + + it('objects added and removed are listed by name', () => { + const plan = comparePackageVersions( + { objects: [{ name: 'acme_old', fields: {} }, { name: 'acme_kept', fields: {} }] }, + { objects: [{ name: 'acme_kept', fields: {} }, { name: 'acme_new', fields: {} }] }, + ); + expect(plan.objects).toEqual({ added: ['acme_new'], removed: ['acme_old'] }); + }); + + it('a field added carries whether it is required; a field removed whether it is stored', () => { + const plan = comparePackageVersions( + side({ title: { type: 'text' }, total: { type: 'formula' } }), + side({ title: { type: 'text' }, code: { type: 'text', required: true } }), + ); + expect(plan.fields.added).toEqual([{ object: 'acme_deal', field: 'code', type: 'text', required: true, stored: true }]); + expect(plan.fields.removed).toEqual([{ object: 'acme_deal', field: 'total', type: 'formula', required: false, stored: false }]); + // A computed field held no column: its removal is no suspected rename. + expect(plan.suspectedRenames).toEqual([]); + }); +}); + +describe('renames', () => { + it('a field removed and one added of the same type in one object, no renamedFrom → one suspected rename', () => { + const plan = comparePackageVersions(side({ company: { type: 'text' } }), side({ account_name: { type: 'text' } })); + expect(plan.suspectedRenames).toEqual([ + { object: 'acme_deal', field: 'company', type: 'text', candidates: [{ field: 'account_name', type: 'text' }] }, + ]); + expect(plan.declaredRenames).toEqual([]); + // Nothing declares a rename, so both keys are still listed as removed and added. + expect(plan.fields.removed.map((f) => f.field)).toEqual(['company']); + expect(plan.fields.added.map((f) => f.field)).toEqual(['account_name']); + }); + + it("the same pair with renamedFrom: ['company'] on the new field → one declared rename and no suspected rename", () => { + const plan = comparePackageVersions( + side({ company: { type: 'text' } }), + side({ account_name: { type: 'text', renamedFrom: ['company'] } }), + ); + expect(plan.declaredRenames).toEqual([ + { object: 'acme_deal', from: 'company', to: 'account_name', fromType: 'text', toType: 'text' }, + ]); + expect(plan.suspectedRenames).toEqual([]); + expect(plan.fields.added).toEqual([]); + expect(plan.fields.removed).toEqual([]); + expect(plan.typeChanges).toEqual([]); + }); + + it('a declared rename whose type changes is ALSO a type change, judged on its own', () => { + const plan = comparePackageVersions( + side({ amount: { type: 'text' } }), + side({ amount_value: { type: 'number', renamedFrom: ['amount'] } }), + ); + expect(plan.declaredRenames).toEqual([ + { object: 'acme_deal', from: 'amount', to: 'amount_value', fromType: 'text', toType: 'number' }, + ]); + // `needs_confirm`, not `destructive`: the declared rename does not decide + // whether the type change is reported. + expect(plan.typeChanges).toEqual([ + { object: 'acme_deal', field: 'amount_value', from: 'text', to: 'number', category: 'needs_confirm', renamedFrom: 'amount' }, + ]); + }); + + it('a renamedFrom naming a key the installed version does not have declares nothing', () => { + const plan = comparePackageVersions( + side({ company: { type: 'text' } }), + side({ account_name: { type: 'text', renamedFrom: ['firm'] } }), + ); + expect(plan.declaredRenames).toEqual([]); + expect(plan.suspectedRenames).toHaveLength(1); + }); + + it('a renamedFrom naming a key the new version still declares is no rename', () => { + const plan = comparePackageVersions( + side({ company: { type: 'text' } }), + side({ company: { type: 'text' }, account_name: { type: 'text', renamedFrom: ['company'] } }), + ); + expect(plan.declaredRenames).toEqual([]); + expect(plan.fields.added.map((f) => f.field)).toEqual(['account_name']); + }); + + it('one removed field and two added name both as candidates; a linked pair is not a candidate', () => { + const plan = comparePackageVersions( + side({ company: { type: 'text' }, region: { type: 'select' } }), + side({ account_name: { type: 'text' }, area: { type: 'select', renamedFrom: ['region'] }, note: { type: 'textarea' } }), + ); + expect(plan.declaredRenames.map((r) => `${r.from}→${r.to}`)).toEqual(['region→area']); + expect(plan.suspectedRenames).toEqual([ + { + object: 'acme_deal', field: 'company', type: 'text', + candidates: [{ field: 'account_name', type: 'text' }, { field: 'note', type: 'textarea' }], + }, + ]); + }); +}); + +describe('type changes', () => { + it('a type change carries its drift category', () => { + const plan = comparePackageVersions( + side({ stage: { type: 'text' }, score: { type: 'formula' }, total: { type: 'number' } }), + side({ stage: { type: 'select' }, score: { type: 'number' }, total: { type: 'formula' } }), + ); + expect(plan.typeChanges).toEqual([ + { object: 'acme_deal', field: 'stage', from: 'text', to: 'select', category: 'needs_confirm' }, + { object: 'acme_deal', field: 'score', from: 'formula', to: 'number', category: 'safe' }, + { object: 'acme_deal', field: 'total', from: 'number', to: 'formula', category: 'destructive' }, + ]); + }); + + it('the category reads the storage fact, one rule per case', () => { + expect(typeChangeCategory({ type: 'formula' }, { type: 'text' })).toBe('safe'); + expect(typeChangeCategory({ type: 'text' }, { type: 'formula' })).toBe('destructive'); + expect(typeChangeCategory({ type: 'text' }, { type: 'number' })).toBe('needs_confirm'); + }); +}); + +describe('seed datasets', () => { + const records = (n: number) => Array.from({ length: n }, (_, i) => ({ name: `r${i}`, label: `Row ${i}` })); + + it('an upsert dataset change and an ignore dataset change are both listed with their modes', () => { + const plan = comparePackageVersions( + side({}, { data: [ + { object: 'acme_deal', mode: 'upsert', records: records(2) }, + { object: 'acme_stage', mode: 'ignore', records: records(1) }, + ] }), + side({}, { data: [ + { object: 'acme_deal', mode: 'upsert', records: records(3) }, + { object: 'acme_stage', mode: 'ignore', records: [{ name: 'r0', label: 'Renamed' }] }, + ] }), + ); + expect(plan.seeds.changed).toEqual([ + { key: 'acme_deal', object: 'acme_deal', externalId: 'name', mode: 'upsert', records: 3, fromMode: 'upsert', fromRecords: 2, changed: ['records'] }, + { key: 'acme_stage', object: 'acme_stage', externalId: 'name', mode: 'ignore', records: 1, fromMode: 'ignore', fromRecords: 1, changed: ['records'] }, + ]); + }); + + it('a dataset that declares no mode is listed under the spec default', () => { + const plan = comparePackageVersions(side({}), side({}, { data: [{ object: 'acme_deal', records: records(1) }] })); + expect(plan.seeds.added).toEqual([{ key: 'acme_deal', object: 'acme_deal', externalId: 'name', mode: 'ignore', records: 1 }]); + }); + + it('a removed dataset and a mode change are listed', () => { + const plan = comparePackageVersions( + side({}, { data: [{ object: 'acme_deal', mode: 'ignore', records: records(1) }, { object: 'acme_old', records: records(1) }] }), + side({}, { data: [{ object: 'acme_deal', mode: 'upsert', records: records(1) }] }), + ); + expect(plan.seeds.removed.map((d) => d.key)).toEqual(['acme_old']); + expect(plan.seeds.changed).toEqual([expect.objectContaining({ key: 'acme_deal', mode: 'upsert', fromMode: 'ignore', changed: ['mode'] })]); + }); + + it('the modes whose replay writes over a row it matches, of every mode the spec declares', () => { + expect(SeedMode.options.filter(seedModeOverwritesMatchedRows)).toEqual(['update', 'upsert']); + }); + + it('a dataset is keyed by its object, its non-default match key and locales, and its repeat', () => { + const keys = [...upgradePlanSeedDatasets({ data: [ + { object: 'acme_deal', records: [] }, + { object: 'acme_deal', externalId: 'code', records: [] }, + { object: 'acme_deal', locale: ['zh-CN', 'en'], records: [] }, + { object: 'acme_deal', records: [] }, + ] }).keys()]; + expect(keys).toEqual(['acme_deal', 'acme_deal by code', 'acme_deal [en,zh-CN]', 'acme_deal #2']); + }); + + it('record key order is not a change', () => { + const plan = comparePackageVersions( + side({}, { data: [{ object: 'acme_deal', records: [{ name: 'a', label: 'A' }] }] }), + side({}, { data: [{ object: 'acme_deal', records: [{ label: 'A', name: 'a' }] }] }), + ); + expect(plan.seeds.changed).toEqual([]); + }); +}); diff --git a/packages/metadata-core/src/package-upgrade-plan.ts b/packages/metadata-core/src/package-upgrade-plan.ts new file mode 100644 index 00000000000..3236121f216 --- /dev/null +++ b/packages/metadata-core/src/package-upgrade-plan.ts @@ -0,0 +1,407 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The package upgrade plan: ONE comparison of the version of a package that is + * installed with the version about to replace it (epic cloud#3652, Phase 2 + * "change preview before install"; ruling Q3 「默认拒绝,需显式映射(推荐)」 and + * D3 「写进清单 renamedFrom」). + * + * ## Why one function + * + * Every reader of "what will this upgrade change" calls this: `os diff` + * (two config files), the install-local door's dry run and its upgrade + * verdict, and the `manifest` service's `planUpgrade`, which a host such as a + * hosted install door calls. Two comparisons would disagree about the same + * pair of versions, and the one an installer saw would not be the one that + * decided. + * + * It is pure: two package bodies in, findings out. It reads no registry, no + * database and no ledger. What only the running environment knows (rows in a + * column, rows a customer edited) is the `manifest` service's half. + * + * ## What it reads + * + * Each side is ONE flat package body, as far as this function is concerned: + * its `objects` (each an object with a `name` and a `fields` map) and its + * `data` (seed datasets). A multi-package artifact is folded by the caller + * with the platform's one collection reader (`resolveStackCollection` in + * `@objectstack/core`; `os diff` folds through the CLI's union stack), so this + * module holds no second reading of `packages[]`. + * + * ## What it reports + * + * - objects added and removed, by name; + * - fields added and removed, by key, within an object both versions declare; + * - type changes: a field both versions declare whose `type` differs, with a + * drift category ({@link typeChangeCategory}); + * - declared renames: a field of the new version whose `renamedFrom` names a + * field of the installed version that the new version no longer declares. + * The pair is a rename, so neither key is listed as added or removed. When + * the two types differ, the rename is ALSO a type change, judged on its own: + * a declared rename never stands in for a type-change confirmation + * (the card's scope note, objectstack#30884 comment 6685166393); + * - suspected renames: within one object, a stored field removed while a + * stored field is added, with no `renamedFrom` linking them. One finding per + * removed field, naming every unlinked added field as a candidate. Both keys + * stay listed as removed and added: nothing declares otherwise; + * - seed datasets added, removed and changed, each with its mode. + * + * ⛔ It decides nothing. Which findings stop an install is the door's call. + */ + +import { SeedSchema, isVirtualSearchField } from '@objectstack/spec/data'; +import { canonicalize } from './canonicalize.js'; + +/** + * The drift categories of `@objectstack/driver-sql`'s `schema-drift.ts` + * (`DriftCategory`), the same three words: this package cannot depend on a + * driver, so the union is restated here and held equal to the driver's by a + * type-level pin in the CLI's tests, which depend on both. + * + * - `safe` loosening that cannot lose data and cannot fail; + * - `needs_confirm` a change a human should look at, that destroys nothing; + * - `destructive` a drop or tightening that can lose data or fail. + */ +export type UpgradeDriftCategory = 'safe' | 'needs_confirm' | 'destructive'; + +/** One side of the comparison: a flat package body's two collections. */ +export interface PackageUpgradeSide { + /** The body's object definitions (`{ name, fields }`). */ + objects?: readonly unknown[]; + /** The body's seed datasets (`SeedSchema`). */ + data?: readonly unknown[]; +} + +/** A field one side declares and the other does not. */ +export interface UpgradePlanField { + object: string; + field: string; + type?: string; + /** `required: true` on the declaration. */ + required: boolean; + /** The field has a stored column (not a computed-on-read type). */ + stored: boolean; +} + +/** A field whose declared type differs between the two versions. */ +export interface UpgradePlanTypeChange { + object: string; + /** The field's key in the NEW version. */ + field: string; + from: string; + to: string; + category: UpgradeDriftCategory; + /** Set when the change rides on a declared rename: the installed key. */ + renamedFrom?: string; +} + +/** A rename the new version declares with `renamedFrom`. */ +export interface UpgradePlanDeclaredRename { + object: string; + /** The installed key. */ + from: string; + /** The new key. */ + to: string; + fromType?: string; + toType?: string; +} + +/** A stored field removed while stored fields are added, with nothing linking them. */ +export interface UpgradePlanSuspectedRename { + object: string; + /** The removed (installed) key. */ + field: string; + type?: string; + /** Every added stored field of the same object no `renamedFrom` links. */ + candidates: Array<{ field: string; type?: string }>; +} + +/** A seed dataset, as the plan names it. */ +export interface UpgradePlanSeedDataset { + /** `object`, plus the match key and the locale scope when they are not the defaults. */ + key: string; + object: string; + externalId: string | string[]; + /** The mode, the spec default (`ignore`) when the dataset declares none. */ + mode: string; + records: number; +} + +/** A dataset both versions declare under one key, with what differs. */ +export interface UpgradePlanSeedChange extends UpgradePlanSeedDataset { + /** The installed version's mode. */ + fromMode: string; + /** The installed version's record count. */ + fromRecords: number; + /** What differs: the records, the mode, the column policies, the environments. */ + changed: Array<'records' | 'mode' | 'columns' | 'env'>; +} + +export interface PackageUpgradePlan { + objects: { added: string[]; removed: string[] }; + fields: { added: UpgradePlanField[]; removed: UpgradePlanField[] }; + typeChanges: UpgradePlanTypeChange[]; + declaredRenames: UpgradePlanDeclaredRename[]; + suspectedRenames: UpgradePlanSuspectedRename[]; + seeds: { added: UpgradePlanSeedDataset[]; removed: UpgradePlanSeedDataset[]; changed: UpgradePlanSeedChange[] }; +} + +type Bag = Record; + +function isBag(value: unknown): value is Bag { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** Objects by name, the LAST declaration of a name winning (`os diff`'s reading). */ +function objectsByName(side: PackageUpgradeSide | undefined): Map { + const out = new Map(); + for (const item of side?.objects ?? []) { + if (isBag(item) && typeof item.name === 'string' && item.name !== '') out.set(item.name, item); + } + return out; +} + +function fieldsOf(object: Bag | undefined): Map { + const out = new Map(); + const fields = object?.fields; + if (!isBag(fields)) return out; + for (const [key, def] of Object.entries(fields)) out.set(key, isBag(def) ? def : {}); + return out; +} + +function typeOf(field: Bag | undefined): string | undefined { + return typeof field?.type === 'string' && field.type !== '' ? field.type : undefined; +} + +/** Has the field a stored column? The spec's storage predicate, never a type list kept here. */ +function isStored(field: Bag | undefined): boolean { + return !isVirtualSearchField({ type: typeOf(field) } as Parameters[0]); +} + +function describeField(object: string, field: string, def: Bag | undefined): UpgradePlanField { + const type = typeOf(def); + return { object, field, ...(type !== undefined ? { type } : {}), required: def?.required === true, stored: isStored(def) }; +} + +/** + * The drift category of a declared type change, in `schema-drift.ts`'s + * vocabulary and by its reading of the same physical facts. A package upgrade + * syncs the schema ADDITIVELY: it adds missing columns and never alters or + * drops one, so a retyped field keeps its existing column. + * + * - The installed field had no stored column (computed on read): there is + * nothing stored to lose, and the new column is one the additive sync adds. + * `safe`. + * - The new field has no stored column and the installed one had: the column + * stops being declared, which the drift detector reports as an orphan whose + * operation is a column drop. `destructive`. + * - Both stored: the column keeps its old type under a declaration of another + * type. The detector reports a base-type divergence as report-only, applied + * by no reconciler (`manual_column_type_change`). `needs_confirm`. + */ +export function typeChangeCategory(from: { type?: unknown }, to: { type?: unknown }): UpgradeDriftCategory { + const fromStored = isStored(from as Bag); + const toStored = isStored(to as Bag); + if (!fromStored) return 'safe'; + if (!toStored) return 'destructive'; + return 'needs_confirm'; +} + +// ── seeds ─────────────────────────────────────────────────────────────── + +const DEFAULT_SEED_MODE: string = SeedSchema.shape.mode.parse(undefined); +const DEFAULT_SEED_EXTERNAL_ID: string | string[] = SeedSchema.shape.externalId.parse(undefined); + +/** + * Does a replay of a dataset in this mode write its columns over a row it + * MATCHED by `externalId`? `upsert` and `update` do (a column declaring + * `applyOn: 'create'` excepted); `insert` and `ignore` never touch a matched + * row, and `replace` deletes every row before it inserts, so it matches none. + * The rows a customer edited that an upgrade's replay would overwrite are + * counted for these modes only. + */ +export function seedModeOverwritesMatchedRows(mode: string): boolean { + return mode === 'upsert' || mode === 'update'; +} + +/** A dataset as the plan reads it, with the declaration it was read from. */ +export interface UpgradePlanSeedDatasetRead extends UpgradePlanSeedDataset { + raw: Record; +} + +function stableText(value: unknown): string { + try { + return canonicalize(value); + } catch { + // A value canonical JSON refuses (NaN, Infinity) still compares by its + // plain serialisation rather than throwing the whole plan away. + return JSON.stringify(value) ?? 'undefined'; + } +} + +/** + * One side's seed datasets under the keys the plan names them by: `object`, + * plus its match key and its locales when they are not the defaults, and + * ` #n` on a second dataset under one key. Exported so a reader of the + * environment (`planUpgrade`) pairs datasets exactly as the plan does. + */ +export function upgradePlanSeedDatasets(side: PackageUpgradeSide | undefined): Map { + const out = new Map(); + for (const item of side?.data ?? []) { + if (!isBag(item) || typeof item.object !== 'string' || item.object === '') continue; + const externalId = typeof item.externalId === 'string' || Array.isArray(item.externalId) + ? (item.externalId as string | string[]) + : DEFAULT_SEED_EXTERNAL_ID; + const mode = typeof item.mode === 'string' ? item.mode : DEFAULT_SEED_MODE; + const locale = Array.isArray(item.locale) ? [...item.locale].map(String).sort().join(',') : ''; + let key = item.object; + if (stableText(externalId) !== stableText(DEFAULT_SEED_EXTERNAL_ID)) key += ` by ${Array.isArray(externalId) ? externalId.join('+') : externalId}`; + if (locale !== '') key += ` [${locale}]`; + // Two datasets under one key in one version are told apart by position. + let unique = key; + for (let n = 2; out.has(unique); n++) unique = `${key} #${n}`; + const records = Array.isArray(item.records) ? item.records.length : 0; + out.set(unique, { key: unique, object: item.object, externalId, mode, records, raw: item }); + } + return out; +} + +function publicDataset(d: UpgradePlanSeedDatasetRead): UpgradePlanSeedDataset { + return { key: d.key, object: d.object, externalId: d.externalId, mode: d.mode, records: d.records }; +} + +function compareSeeds(installed: PackageUpgradeSide | undefined, incoming: PackageUpgradeSide | undefined): PackageUpgradePlan['seeds'] { + const before = upgradePlanSeedDatasets(installed); + const after = upgradePlanSeedDatasets(incoming); + const seeds: PackageUpgradePlan['seeds'] = { added: [], removed: [], changed: [] }; + for (const [key, d] of before) if (!after.has(key)) seeds.removed.push(publicDataset(d)); + for (const [key, d] of after) { + const prior = before.get(key); + if (!prior) { + seeds.added.push(publicDataset(d)); + continue; + } + const changed: UpgradePlanSeedChange['changed'] = []; + if (stableText(prior.raw.records ?? []) !== stableText(d.raw.records ?? [])) changed.push('records'); + if (prior.mode !== d.mode) changed.push('mode'); + if (stableText(prior.raw.columns ?? {}) !== stableText(d.raw.columns ?? {})) changed.push('columns'); + if (stableText(prior.raw.env ?? null) !== stableText(d.raw.env ?? null)) changed.push('env'); + if (changed.length > 0) { + seeds.changed.push({ ...publicDataset(d), fromMode: prior.mode, fromRecords: prior.records, changed }); + } + } + return seeds; +} + +// ── the comparison ────────────────────────────────────────────────────── + +/** + * Compare the installed version of a package with the incoming one. Lists + * are ordered as the versions declare: removed objects, fields and type + * changes in the installed version's order, added ones in the incoming + * version's order, objects one after another. + */ +export function comparePackageVersions(installed: PackageUpgradeSide | undefined, incoming: PackageUpgradeSide | undefined): PackageUpgradePlan { + const before = objectsByName(installed); + const after = objectsByName(incoming); + const plan: PackageUpgradePlan = { + objects: { added: [], removed: [] }, + fields: { added: [], removed: [] }, + typeChanges: [], + declaredRenames: [], + suspectedRenames: [], + seeds: compareSeeds(installed, incoming), + }; + + for (const name of before.keys()) if (!after.has(name)) plan.objects.removed.push(name); + for (const name of after.keys()) if (!before.has(name)) plan.objects.added.push(name); + + for (const [object, beforeObject] of before) { + const afterObject = after.get(object); + if (!afterObject) continue; + const beforeFields = fieldsOf(beforeObject); + const afterFields = fieldsOf(afterObject); + + // Declared renames: a NEW key whose `renamedFrom` names an installed key + // the new version no longer declares. The first such entry is the rename + // (the spec refuses an old key two fields claim, and a field renamed + // twice names at most one key this installed version has). + const renamedTo = new Map(); // installed key → new key + for (const [key, def] of afterFields) { + if (beforeFields.has(key) || !Array.isArray(def.renamedFrom)) continue; + for (const old of def.renamedFrom) { + if (typeof old !== 'string' || !beforeFields.has(old) || afterFields.has(old) || renamedTo.has(old)) continue; + renamedTo.set(old, key); + break; + } + } + const renameTargets = new Set(renamedTo.values()); + + const removed: string[] = []; + for (const key of beforeFields.keys()) { + if (afterFields.has(key) || renamedTo.has(key)) continue; + removed.push(key); + plan.fields.removed.push(describeField(object, key, beforeFields.get(key))); + } + const added: string[] = []; + for (const key of afterFields.keys()) { + if (beforeFields.has(key) || renameTargets.has(key)) continue; + added.push(key); + plan.fields.added.push(describeField(object, key, afterFields.get(key))); + } + + for (const [key, beforeDef] of beforeFields) { + const target = afterFields.has(key) ? key : renamedTo.get(key); + if (target === undefined) continue; + const afterDef = afterFields.get(target); + const from = typeOf(beforeDef); + const to = typeOf(afterDef); + if (target !== key) { + plan.declaredRenames.push({ + object, from: key, to: target, + ...(from !== undefined ? { fromType: from } : {}), + ...(to !== undefined ? { toType: to } : {}), + }); + } + if (from !== undefined && to !== undefined && from !== to) { + plan.typeChanges.push({ + object, field: target, from, to, + category: typeChangeCategory(beforeDef, afterDef ?? {}), + ...(target !== key ? { renamedFrom: key } : {}), + }); + } + } + + const storedAdded = added.filter((key) => isStored(afterFields.get(key))); + if (storedAdded.length > 0) { + for (const key of removed) { + const def = beforeFields.get(key); + if (!isStored(def)) continue; + const type = typeOf(def); + plan.suspectedRenames.push({ + object, field: key, ...(type !== undefined ? { type } : {}), + candidates: storedAdded.map((candidate) => { + const candidateType = typeOf(afterFields.get(candidate)); + return { field: candidate, ...(candidateType !== undefined ? { type: candidateType } : {}) }; + }), + }); + } + } + } + + return plan; +} + +/** Does the plan list nothing at all? */ +export function isEmptyPackageUpgradePlan(plan: PackageUpgradePlan): boolean { + return plan.objects.added.length === 0 + && plan.objects.removed.length === 0 + && plan.fields.added.length === 0 + && plan.fields.removed.length === 0 + && plan.typeChanges.length === 0 + && plan.declaredRenames.length === 0 + && plan.suspectedRenames.length === 0 + && plan.seeds.added.length === 0 + && plan.seeds.removed.length === 0 + && plan.seeds.changed.length === 0; +} diff --git a/packages/objectql/src/package-upgrade-environment.test.ts b/packages/objectql/src/package-upgrade-environment.test.ts new file mode 100644 index 00000000000..a96ab7886da --- /dev/null +++ b/packages/objectql/src/package-upgrade-environment.test.ts @@ -0,0 +1,205 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The environment half of the package upgrade plan (epic cloud#3652 Q3, + * #30884), read through the real `ObjectQL` engine over an in-memory driver: + * rows in each column the upgrade touches, and rows a customer edited that the + * seed replay overwrites. + */ + +import { describe, expect, it, vi } from 'vitest'; +import { ObjectKernel } from '@objectstack/core'; +import { ObjectQL } from './engine.js'; +import { ObjectQLPlugin } from './plugin.js'; +import { planPackageUpgrade } from './package-upgrade-environment.js'; + +type Row = Record; + +/** In-memory driver: equality, `$in` and `$ne` WHERE; `find` honours `limit`. */ +function makeDriver(rowsByObject: Record) { + const matches = (row: Row, where: any): boolean => { + if (!where || typeof where !== 'object') return true; + for (const [k, v] of Object.entries(where)) { + if (k === '$and' && Array.isArray(v)) { + if (!v.every((w: any) => matches(row, w))) return false; + continue; + } + if (k.startsWith('$')) continue; + const value = row[k] === undefined ? null : row[k]; + if (v && typeof v === 'object' && Array.isArray((v as any).$in)) { + if (!(v as any).$in.includes(value)) return false; + continue; + } + if (v && typeof v === 'object' && '$ne' in (v as any)) { + if (value === (v as any).$ne) return false; + continue; + } + if (value !== (v === undefined ? null : v)) return false; + } + return true; + }; + const rowsOf = (object: string) => (rowsByObject[object] ?? []).map((row) => ({ ...row })); + return { + name: 'memory', version: '0.0.0', supports: {} as any, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; }, + async find(object: string, ast: any) { + const rows = rowsOf(object).filter((row) => matches(row, ast?.where)); + return typeof ast?.limit === 'number' ? rows.slice(0, ast.limit) : rows; + }, + async findOne(object: string, ast: any) { return rowsOf(object).find((row) => matches(row, ast?.where)) ?? null; }, + async count(object: string, ast: any) { return rowsOf(object).filter((row) => matches(row, ast?.where)).length; }, + async create(_o: string, d: any) { return d; }, + async update(_o: string, _id: string, d: any) { return d; }, + async delete() { return true; }, + async bulkCreate(_o: string, r: any[]) { return r; }, + async bulkUpdate() { return []; }, async bulkDelete() {}, + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, + async commit() {}, async rollback() {}, + }; +} + +async function makeEngine(objects: Array<{ name: string; fields: Record }>, rows: Record) { + const engine = new ObjectQL(); + engine.registerDriver(makeDriver(rows) as any, true); + await engine.init(); + for (const object of objects) (engine.registry as any).registerObject(object); + return engine; +} + +const STAGE_FIELDS = { name: { type: 'text' }, label: { type: 'text' }, probability: { type: 'number' }, color: { type: 'text' } }; +const STAGE = { name: 'acme_stage', fields: STAGE_FIELDS }; + +const stages = (labels: Record) => + Object.entries(labels).map(([name, label]) => ({ name, label, probability: 10, color: 'grey' })); + +describe('rows a customer edited that the seed replay overwrites', () => { + it('a seeded upsert dataset with one row edited reports that row', async () => { + const installed = { objects: [STAGE], data: [{ object: 'acme_stage', mode: 'upsert', records: stages({ new: 'New', won: 'Won', lost: 'Lost' }) }] }; + // The new version relabels `lost`, and changes nothing a customer touched. + const incoming = { objects: [STAGE], data: [{ object: 'acme_stage', mode: 'upsert', records: stages({ new: 'New', won: 'Won', lost: 'Closed lost' }) }] }; + const engine = await makeEngine([STAGE], { + acme_stage: [ + { id: 's1', name: 'new', label: 'New', probability: 10, color: 'grey' }, + // The customer's edit, which the replay writes `Won` back over. + { id: 's2', name: 'won', label: 'Won (signed)', probability: 10, color: 'grey' }, + // As seeded: the replay's new label is an upgrade, not an erased edit. + { id: 's3', name: 'lost', label: 'Lost', probability: 10, color: 'grey' }, + ], + }); + + const { plan, environment } = await planPackageUpgrade(engine, installed, incoming, { getObject: (n) => engine.registry.getObject(n) }); + + expect(plan.seeds.changed.map((d) => d.key)).toEqual(['acme_stage']); + expect(environment.seeds).toEqual([ + { key: 'acme_stage', object: 'acme_stage', mode: 'upsert', changed: true, matchedRows: 3, editedRows: 1, editedKeys: ['won'] }, + ]); + }); + + it('an unchanged upsert dataset overwrites the same edits, and says it is unchanged', async () => { + const body = { objects: [STAGE], data: [{ object: 'acme_stage', mode: 'upsert', records: stages({ won: 'Won' }) }] }; + const engine = await makeEngine([STAGE], { acme_stage: [{ id: 's2', name: 'won', label: 'Signed', probability: 10, color: 'grey' }] }); + const { environment } = await planPackageUpgrade(engine, body, structuredClone(body)); + expect(environment.seeds).toEqual([expect.objectContaining({ changed: false, matchedRows: 1, editedRows: 1 })]); + }); + + it("an ignore dataset never writes over a matched row, so it is not read; nor is a column declared applyOn: 'create'", async () => { + const engine = await makeEngine([STAGE], { + acme_stage: [{ id: 's2', name: 'won', label: 'Won', probability: 90, color: 'grey' }], + acme_tier: [{ id: 't1', name: 'gold', label: 'Platinum' }], + }); + const { environment } = await planPackageUpgrade( + engine, + { data: [ + { object: 'acme_stage', mode: 'upsert', columns: { probability: { applyOn: 'create' } }, records: stages({ won: 'Won' }) }, + { object: 'acme_tier', records: [{ name: 'gold', label: 'Gold' }] }, + ] }, + { data: [ + { object: 'acme_stage', mode: 'upsert', columns: { probability: { applyOn: 'create' } }, records: stages({ won: 'Won' }) }, + { object: 'acme_tier', records: [{ name: 'gold', label: 'Gold tier' }] }, + ] }, + { getObject: (n) => engine.registry.getObject(n) }, + ); + expect(environment.seeds).toEqual([ + { key: 'acme_stage', object: 'acme_stage', mode: 'upsert', changed: false, matchedRows: 1, editedRows: 0, editedKeys: [] }, + ]); + }); + + it('a reading that fails is null, never zero, and is told', async () => { + const warn = vi.fn(); + const body = { objects: [STAGE], data: [{ object: 'acme_stage', mode: 'upsert', records: stages({ won: 'Won' }) }] }; + const engine = await makeEngine([STAGE], {}); + vi.spyOn(engine, 'find').mockRejectedValue(new Error('connection lost reading acme_stage')); + const { environment } = await planPackageUpgrade(engine, body, structuredClone(body), { warn }); + expect(environment.seeds).toEqual([expect.objectContaining({ matchedRows: null, editedRows: null })]); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('answered null, never zero'), expect.objectContaining({ object: 'acme_stage' })); + }); +}); + +describe('rows in each column the upgrade touches', () => { + const lead = (fields: Record) => ({ name: 'acme_lead', fields }); + + it('removed, suspected-rename, renamed and retyped columns, each with its rows; a removed object with its rows', async () => { + const before = lead({ + company: { type: 'text' }, legacy: { type: 'text' }, amount: { type: 'text' }, total: { type: 'formula' }, region: { type: 'select' }, + }); + const after = lead({ + account_name: { type: 'text' }, amount_value: { type: 'number', renamedFrom: ['amount'] }, total: { type: 'number' }, area: { type: 'select', renamedFrom: ['region'] }, + }); + // The registry holds the installed object's stored columns (the computed + // `total` and the option-less `region` would only add boot-log noise here). + const registered = lead({ company: { type: 'text' }, legacy: { type: 'text' }, amount: { type: 'text' }, region: { type: 'text' } }); + const engine = await makeEngine([registered, { name: 'acme_old', fields: { x: { type: 'text' } } }], { + acme_lead: [ + { id: 'l1', company: 'Acme', legacy: null, amount: '10', region: 'emea' }, + { id: 'l2', company: 'Globex', legacy: 'x', amount: null, region: null }, + { id: 'l3', company: null, legacy: null, amount: '7', region: 'apac' }, + ], + acme_old: [{ id: 'o1', x: 'a' }, { id: 'o2', x: 'b' }], + }); + + const { environment } = await planPackageUpgrade( + engine, + { objects: [before, { name: 'acme_old', fields: { x: { type: 'text' } } }] }, + { objects: [after] }, + ); + + expect(environment.columns).toEqual([ + { object: 'acme_lead', field: 'company', reasons: ['removed', 'suspected_rename'], rows: 2 }, + { object: 'acme_lead', field: 'legacy', reasons: ['removed', 'suspected_rename'], rows: 1 }, + { object: 'acme_lead', field: 'amount', reasons: ['renamed', 'type_change'], rows: 2 }, + { object: 'acme_lead', field: 'region', reasons: ['renamed'], rows: 2 }, + ]); + // `total` was computed (formula → number is `safe`): no stored column to count. + expect(environment.removedObjects).toEqual([{ object: 'acme_old', rows: 2 }]); + }); + + it('an identical pair reads nothing', async () => { + const engine = await makeEngine([lead({ company: { type: 'text' } })], { acme_lead: [{ id: 'l1', company: 'Acme' }] }); + const body = { objects: [lead({ company: { type: 'text' } })] }; + expect((await planPackageUpgrade(engine, body, structuredClone(body))).environment).toEqual({ columns: [], removedObjects: [], seeds: [] }); + }); +}); + +describe("the `manifest` service's planUpgrade", () => { + it('folds a multi-package artifact with the platform reader and reads through the engine', async () => { + const kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false }); + await kernel.use(new ObjectQLPlugin()); + await kernel.bootstrap(); + try { + const ql = kernel.getService('objectql'); + ql.registerDriver(makeDriver({ acme_lead: [{ id: 'l1', company: 'Acme' }, { id: 'l2', company: null }] }) as any, true); + (ql.registry as any).registerObject({ name: 'acme_lead', fields: { company: { type: 'text' } } }); + const artifact = (fields: Record) => ({ + packages: [{ manifest: { id: 'com.acme.crm', name: 'crm', version: '1.0.0', type: 'app', objects: [{ name: 'acme_lead', fields }] } }], + }); + + const manifest = kernel.getService<{ planUpgrade(a: unknown, b: unknown): ReturnType }>('manifest'); + const { plan, environment } = await manifest.planUpgrade(artifact({ company: { type: 'text' } }), artifact({ account_name: { type: 'text' } })); + + expect(plan.suspectedRenames.map((s) => `${s.field}→${s.candidates.map((c) => c.field).join('|')}`)).toEqual(['company→account_name']); + expect(environment.columns).toEqual([{ object: 'acme_lead', field: 'company', reasons: ['removed', 'suspected_rename'], rows: 1 }]); + } finally { + await kernel.shutdown(); + } + }); +}); diff --git a/packages/objectql/src/package-upgrade-environment.ts b/packages/objectql/src/package-upgrade-environment.ts new file mode 100644 index 00000000000..f03342c07ce --- /dev/null +++ b/packages/objectql/src/package-upgrade-environment.ts @@ -0,0 +1,390 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The environment half of the package upgrade plan (epic cloud#3652 Q3, + * objectstack#30884): what only the running environment knows about an + * upgrade from the installed version of a package to an incoming one. + * + * The comparison itself is `comparePackageVersions` + * (`@objectstack/metadata-core`), pure and shared with `os diff`. This module + * adds two readings of the data the installed version left behind: + * + * - **Rows in a touched column.** For every stored column the upgrade + * removes, renames (declared or suspected) or retypes, the rows that hold a + * value in it, and for every object it removes, the rows it holds. Upgrade + * schema sync is additive (`schema-drift.ts`): none of these columns is + * dropped or carried, so these are the rows a rename leaves behind and a + * type change keeps under a declaration of another type. + * - **Rows a customer edited that the seed replay overwrites.** An upgrade + * replays every dataset of the incoming version. A dataset whose mode writes + * over a row it matches (`seedModeOverwritesMatchedRows`: `upsert`, + * `update`) puts the incoming values back over every row it matches. A row + * whose current value differs from the value the INSTALLED version seeded, + * in a column the replay writes with another value, is a customer's edit + * the replay erases. Counted for every such dataset the installed version + * also declares, changed or not: an unchanged `upsert` dataset overwrites + * the same edits. + * + * Read-only: every read is a `count` or a `find` with the system context, across + * every organization (a package upgrade replaces the package for all of them). + * A reading that fails is answered `null` and never taken for zero. + * + * The comparison of a seed value with a stored one follows the replay's own + * (`SeedLoaderService.isNoOpReplay`, `@objectstack/metadata-protocol`): a + * temporal column in its stored form and a seed blank in the form the write + * door stores it, through the same `@objectstack/core` rules; a credential, a + * file, a reference (resolved from a natural key on write) and an expression + * (evaluated on each load) are never compared, so they count no row. + */ + +import { + blankAsNullColumns, + blankStorageForm, + resolveStackCollection, + temporalComparandKind, + temporalStorageForm, + type BlankAsNullReach, + type TemporalComparandKind, +} from '@objectstack/core'; +import { + comparePackageVersions, + seedModeOverwritesMatchedRows, + upgradePlanSeedDatasets, + type PackageUpgradePlan, + type PackageUpgradeSide, +} from '@objectstack/metadata-core'; +import { FILE_REFERENCE_TYPES, isMaskedOnReadFieldType, isVirtualSearchField } from '@objectstack/spec/data'; + +/** Why a column is read: what the upgrade does to it. */ +export type UpgradeColumnReason = 'removed' | 'renamed' | 'suspected_rename' | 'type_change'; + +/** Rows holding a value in one installed column the upgrade touches. */ +export interface UpgradeColumnRows { + object: string; + /** The INSTALLED key: the column the rows are in. */ + field: string; + reasons: UpgradeColumnReason[]; + /** Rows whose value is not null; `null` when the reading failed. */ + rows: number | null; +} + +/** Rows of an object the upgrade removes. */ +export interface UpgradeObjectRows { + object: string; + /** `null` when the reading failed. */ + rows: number | null; +} + +/** One overwriting dataset the installed version also declares. */ +export interface UpgradeSeedOverwrite { + key: string; + object: string; + /** The incoming mode (the one the replay runs). */ + mode: string; + /** Does the plan list the dataset as changed? */ + changed: boolean; + /** Rows the replay matches by `externalId`; `null` when the reading failed. */ + matchedRows: number | null; + /** Of those, rows a customer edited that the replay overwrites; `null` when the reading failed. */ + editedRows: number | null; + /** The natural keys of the first edited rows ({@link EDITED_KEYS_SHOWN} at most). */ + editedKeys: string[]; +} + +export interface PackageUpgradeEnvironment { + columns: UpgradeColumnRows[]; + removedObjects: UpgradeObjectRows[]; + seeds: UpgradeSeedOverwrite[]; +} + +/** The comparison and the environment's readings of it, as `planUpgrade` answers. */ +export interface PackageUpgradePlanReading { + plan: PackageUpgradePlan; + environment: PackageUpgradeEnvironment; +} + +/** The engine reads this module takes. `ObjectQL` satisfies it. */ +export interface UpgradeEnvironmentEngine { + count(object: string, query?: any, options?: any): Promise; + find(object: string, query?: any, options?: any): Promise; +} + +export interface PlanPackageUpgradeOptions { + /** The object definition the environment serves now (the installed version's). */ + getObject?: (name: string) => unknown; + /** Told once per failed reading. */ + warn?: (message: string, meta?: Record) => void; +} + +/** How many edited rows an answer names by key. */ +export const EDITED_KEYS_SHOWN = 20; + +const SYSTEM_READ = { context: { isSystem: true } } as const; +/** Columns a seed names by a natural key that the write resolves to an id. */ +const REFERENCE_TYPES: ReadonlySet = new Set(['lookup', 'master_detail', 'user']); + +type Bag = Record; +const isBag = (value: unknown): value is Bag => typeof value === 'object' && value !== null && !Array.isArray(value); + +/** One side as the comparison reads it: a multi-package artifact folded by the platform's one reader. */ +function sideOf(artifact: unknown): PackageUpgradeSide { + return { objects: resolveStackCollection(artifact, 'objects'), data: resolveStackCollection(artifact, 'data') }; +} + +/** + * Compare the installed version of a package with the incoming one and read + * the environment for what the comparison touches. Both artifacts are taken + * as the registry reads them (a caller holding a published artifact converts + * it forward first, as it does to register it). + */ +export async function planPackageUpgrade( + engine: UpgradeEnvironmentEngine, + installed: unknown, + incoming: unknown, + options: PlanPackageUpgradeOptions = {}, +): Promise { + const installedSide = sideOf(installed); + const incomingSide = sideOf(incoming); + const plan = comparePackageVersions(installedSide, incomingSide); + const environment: PackageUpgradeEnvironment = { + columns: await readTouchedColumns(engine, plan, options), + removedObjects: [], + seeds: await readSeedOverwrites(engine, plan, installedSide, incomingSide, options), + }; + for (const object of plan.objects.removed) { + environment.removedObjects.push({ object, rows: await reading(options, { object }, () => engine.count(object, {}, SYSTEM_READ)) }); + } + return { plan, environment }; +} + +async function reading(options: PlanPackageUpgradeOptions, meta: Record, read: () => Promise): Promise { + try { + return await read(); + } catch (error) { + options.warn?.('[planUpgrade] a reading of the installed data failed; it is answered null, never zero', { + ...meta, + error: error instanceof Error ? error.message : String(error), + }); + return null; + } +} + +async function readTouchedColumns( + engine: UpgradeEnvironmentEngine, + plan: PackageUpgradePlan, + options: PlanPackageUpgradeOptions, +): Promise { + const touched = new Map(); + const touch = (object: string, field: string, reason: UpgradeColumnReason) => { + const id = `${object}\u0000${field}`; + const entry = touched.get(id) ?? { object, field, reasons: [], rows: null }; + if (!entry.reasons.includes(reason)) entry.reasons.push(reason); + touched.set(id, entry); + }; + // A removed computed field held no column: nothing to count. + for (const f of plan.fields.removed) if (f.stored) touch(f.object, f.field, 'removed'); + for (const r of plan.declaredRenames) touch(r.object, r.from, 'renamed'); + for (const s of plan.suspectedRenames) touch(s.object, s.field, 'suspected_rename'); + for (const t of plan.typeChanges) { + // Retyped from a computed field: the installed version stored nothing. + if (t.category === 'safe') continue; + touch(t.object, t.renamedFrom ?? t.field, 'type_change'); + } + const out = [...touched.values()]; + for (const entry of out) { + entry.rows = await reading(options, { object: entry.object, field: entry.field }, () => + engine.count(entry.object, { where: { [entry.field]: { $ne: null } } }, SYSTEM_READ)); + } + return out; +} + +// ── seeds ─────────────────────────────────────────────────────────────── + +interface Dataset { + key: string; + object: string; + externalId: string | string[]; + mode: string; + records: Bag[]; + createOnly: Set; +} + +/** One side's datasets, paired by the plan's own keys (`upgradePlanSeedDatasets`). */ +function datasetsByKey(side: PackageUpgradeSide): Map { + const out = new Map(); + for (const [key, read] of upgradePlanSeedDatasets(side)) { + const keyFields = new Set(Array.isArray(read.externalId) ? read.externalId : [read.externalId]); + // `applyOn: 'create'` takes a column out of every write to a matched row + // (the loader's `createOnlySeedColumns`); a match-key column stays in. + const createOnly = new Set(); + if (isBag(read.raw.columns)) { + for (const [column, policy] of Object.entries(read.raw.columns)) { + if (isBag(policy) && policy.applyOn === 'create' && !keyFields.has(column)) createOnly.add(column); + } + } + out.set(key, { + key, + object: read.object, + externalId: read.externalId, + mode: read.mode, + records: Array.isArray(read.raw.records) ? read.raw.records.filter(isBag) : [], + createOnly, + }); + } + return out; +} + +/** The natural key of a record or row, `''` when a part is missing (the loader's `externalIdKey`). */ +function naturalKey(record: Bag, externalId: string | string[]): string { + const parts: string[] = []; + for (const field of Array.isArray(externalId) ? externalId : [externalId]) { + const value = record[field]; + if (value === undefined || value === null || value === '') return ''; + parts.push(String(value)); + } + return parts.join('\u0000'); +} + +/** A seed value evaluated on each load (`cel\`…\``), whose value no stored one is compared with. */ +function isExpressionValue(value: unknown): boolean { + return isBag(value) && typeof value.dialect === 'string'; +} + +interface Comparands { + temporal: Map; + blankAsNull: ReadonlyMap; + /** Columns never compared: computed, credential, file, reference. */ + skipped: Set; +} + +function comparandsOf(definition: unknown): Comparands { + const object = isBag(definition) ? definition : undefined; + const fields = isBag(object?.fields) ? object!.fields as Record : {}; + const temporal = new Map(); + const skipped = new Set(); + for (const [name, def] of Object.entries(fields)) { + const type = def?.type; + const kind = temporalComparandKind(type); + if (kind) temporal.set(name, kind); + if (typeof type !== 'string') continue; + if ( + isVirtualSearchField({ type } as Parameters[0]) + || isMaskedOnReadFieldType(type, object?.managedBy) + || FILE_REFERENCE_TYPES.has(type) + || REFERENCE_TYPES.has(type) + ) skipped.add(name); + } + return { temporal, blankAsNull: blankAsNullColumns(object as Parameters[0]), skipped }; +} + +/** Would the replay's comparison call these the same stored value? (the loader's `seedValueEquals`) */ +function sameStoredValue(seed: unknown, stored: unknown, kind: TemporalComparandKind | undefined): boolean { + if (seed === stored) return true; + if (seed == null || stored == null) return seed == null && stored == null; + if (kind) { + const form = temporalStorageForm(seed, kind); + return typeof form === 'string' && form === temporalStorageForm(stored, kind); + } + if (typeof seed === 'boolean' || typeof stored === 'boolean') { + const toNum = (v: unknown) => (typeof v === 'boolean' ? Number(v) : Number(String(v))); + return toNum(seed) === toNum(stored); + } + if (seed instanceof Date || stored instanceof Date) { + const toTime = (v: unknown) => (v instanceof Date ? v.getTime() : typeof v === 'number' ? v : Date.parse(String(v))); + const a = toTime(seed); + const b = toTime(stored); + return Number.isFinite(a) && Number.isFinite(b) && a === b; + } + if (typeof seed === 'object' || typeof stored === 'object') { + try { + return JSON.stringify(seed) === JSON.stringify(stored); + } catch { + return false; + } + } + return String(seed) === String(stored); +} + +/** + * Is `row` a customer's edit the replay of `incoming` erases? Some column the + * replay writes holds a value other than the one `installed` seeded, and the + * replay writes another value still. + */ +function isOverwrittenEdit(row: Bag, installed: Bag, incoming: Bag, dataset: Dataset, comparands: Comparands): boolean { + const keyFields = new Set(Array.isArray(dataset.externalId) ? dataset.externalId : [dataset.externalId]); + for (const [column, value] of Object.entries(incoming)) { + if (column === 'id' || keyFields.has(column) || dataset.createOnly.has(column) || comparands.skipped.has(column)) continue; + if (!(column in installed)) continue; + if (isExpressionValue(value) || isExpressionValue(installed[column])) continue; + const kind = comparands.temporal.get(column); + const reach = comparands.blankAsNull.get(column); + const stored = row[column]; + const seededAsInstalled = sameStoredValue(blankStorageForm(installed[column], reach), stored, kind); + const replayKeeps = sameStoredValue(blankStorageForm(value, reach), stored, kind); + if (!seededAsInstalled && !replayKeeps) return true; + } + return false; +} + +async function readSeedOverwrites( + engine: UpgradeEnvironmentEngine, + plan: PackageUpgradePlan, + installedSide: PackageUpgradeSide, + incomingSide: PackageUpgradeSide, + options: PlanPackageUpgradeOptions, +): Promise { + const before = datasetsByKey(installedSide); + const after = datasetsByKey(incomingSide); + const changedKeys = new Set(plan.seeds.changed.map((d) => d.key)); + const out: UpgradeSeedOverwrite[] = []; + for (const [key, dataset] of after) { + const prior = before.get(key); + if (!prior || !seedModeOverwritesMatchedRows(dataset.mode)) continue; + const entry: UpgradeSeedOverwrite = { + key, object: dataset.object, mode: dataset.mode, changed: changedKeys.has(key), matchedRows: null, editedRows: null, editedKeys: [], + }; + out.push(entry); + + const installedByKey = new Map(); + for (const record of prior.records) { + const k = naturalKey(record, prior.externalId); + if (k !== '') installedByKey.set(k, record); + } + const incomingByKey = new Map(); + for (const record of dataset.records) { + const k = naturalKey(record, dataset.externalId); + if (k !== '') incomingByKey.set(k, record); + } + if (incomingByKey.size === 0) { + entry.matchedRows = 0; + entry.editedRows = 0; + continue; + } + const first = Array.isArray(dataset.externalId) ? dataset.externalId[0] : dataset.externalId; + const firstValues = [...new Set(dataset.records.map((r) => r[first]).filter((v) => v !== undefined && v !== null && v !== ''))]; + const rows = await reading(options, { object: dataset.object, dataset: key }, () => + engine.find(dataset.object, { where: { [first]: { $in: firstValues } } }, SYSTEM_READ)); + if (rows === null) continue; + + const comparands = comparandsOf(options.getObject?.(dataset.object)); + let matched = 0; + let edited = 0; + for (const row of rows) { + if (!isBag(row)) continue; + const k = naturalKey(row, dataset.externalId); + const incomingRecord = incomingByKey.get(k); + if (!incomingRecord) continue; + matched++; + const installedRecord = installedByKey.get(k); + // A row the installed version did not seed has no seeded value to have been edited from. + if (!installedRecord) continue; + if (isOverwrittenEdit(row, installedRecord, incomingRecord, dataset, comparands)) { + edited++; + if (entry.editedKeys.length < EDITED_KEYS_SHOWN) entry.editedKeys.push(k.split('\u0000').join('+')); + } + } + entry.matchedRows = matched; + entry.editedRows = edited; + } + return out; +} diff --git a/packages/objectql/src/plugin.ts b/packages/objectql/src/plugin.ts index a5e31f6caf9..d4133a9efc5 100644 --- a/packages/objectql/src/plugin.ts +++ b/packages/objectql/src/plugin.ts @@ -69,6 +69,7 @@ import { SecurityCatalogNameConflictError, findEnvironmentHeldSecurityCatalogNam // boot's seal and its one switch (`bare-name-one-holder.ts`, "The cold boot"). import { sealBareNameHoldersAtColdBoot } from './registry.js'; import { BARE_NAME_COLD_BOOT_MODE } from './bare-name-one-holder.js'; +import { planPackageUpgrade } from './package-upgrade-environment.js'; // [ADR-0029 D3] The registry's own refusal of a second owner — read, never // re-derived, when a residual object's name is already owned. import { ObjectOwnershipConflictError, collectManifestBoundActions, collectManifestObjectNames, describeBoundActionCollision, describeUnboundActionTargets, FIND_BOUND_ACTION_COLLISIONS, type BoundActionCollision } from './registry.js'; @@ -1030,6 +1031,16 @@ export class ObjectQLPlugin implements Plugin { // {@link restoreExtendedObjectCopies}. restoreExtendedObjectCopies: (packageId: string): Promise => this.restoreExtendedObjectCopies(ctx, packageId), + // [epic cloud#3652 Q3, #30884] The package upgrade plan with what only this + // environment knows: rows in each column the upgrade touches and rows a + // customer edited that the seed replay overwrites. Read-only; the install + // door's dry run and a host's install door call it. See + // {@link planPackageUpgrade}. + planUpgrade: (installed: unknown, incoming: unknown) => + planPackageUpgrade(ql, installed, incoming, { + getObject: (name) => ql.registry.getObject(name), + warn: (message, meta) => ctx.logger.warn(message, meta), + }), }); ctx.logger.info('ObjectQL engine registered', {