Skip to content

Commit 572ecfb

Browse files
fix(spec,service-automation,metadata-protocol): the v18 stored preview lists every row protocol 18 refuses and no conversion rewrites (#23315)
Fixes #23269 Fixes #23270 Fixes #23271 Clause-②: yes (widening) ## What this does `os migrate meta --stored` now lists every stored row that protocol 18 refuses and that no conversion rewrites. Before, the preview counted each such row as `canonical` ("already on protocol"), while the parse or the boot refused it after the upgrade. All three cards get the same fate, **refused and listed**, through the stored preview's existing TODO lines. Each line names the step-18 D3 entry that refuses the shape, the site, and the choice the owner has to make. Nothing is rewritten, on preview or on `--apply`, and `storedMigrationClean` does not move. | card | shape | fate | listed by | TODO id | |:--|:--|:--|:--|:--| | #23269 | a flow value slot carrying a `{…}` token, including a `get_record` / `update_record` / `delete_record` filter operand | refuse and list | flow, node, path | `flow-value-slot-template-dialect-refused` | | #23270 | `controlled_by_parent` with two masters (both required, or neither), no master, a required `lookup` only, an explicit `required: false` | refuse and list, each with its own fate sentence | object | `cbp-master-relation-tiers-retired` | | #23271 | a view whose `data` binds the retired view-level API source, in every stored view body spelling and in an object's `listViews` | refuse and list; request headers withheld | view | `view-data-api-provider-retired` | The one lossless shape in this family, a single `master_detail` with `required` omitted, is still converted by `cbp-master-detail-required-defaulted` and is not listed. ## Public surface: Clause ②, yes (widening) No door accepts or refuses anything it did not before. The published flow-canonicalization result gains one output member, declared in the contract and read through it: - `IAutomationService.canonicalizeStoredFlow(...).todos?` (`@objectstack/spec`, `contracts/automation-service.ts`): optional, the sibling of `notices` and `conflicts`, so an implementation that reports none still conforms. - `AutomationEngine.canonicalizeStoredFlow(...).todos` (`@objectstack/service-automation`): the engine always returns it. - `StoredFlowCanonicalization.todos?` (`@objectstack/metadata-protocol`): restates the contract's optional member, so `flowResult.todos ?? []` reads a member the contract declares. Also widened: the published `ConversionTodoNotice.conversionId` may now name a D3 entry as well as a conversion, and an `onTodo` sink now also receives the refused-shape TODOs. All three packages ship `minor` (`.changeset/23269-v18-stored-row-fates.md`). ## The #23269 choice, measured: (b) refuse and list The lane's ruling allowed (a) only if the chain already declares an operator-applied rewrite of stored rows that is separate from the load-path D2. It does not: - `migrateStoredMetadata` (the `--stored` pass) writes back the read path's own pass: `applyConversionsToStoredItem` for most types, and `AutomationEngine.canonicalizeStoredFlow` for a flow, which is the `registerFlow` seam itself. - `--from 17` (`applyMetaMigrations`) replays step conversions over authored source files, never over rows. - The decision-mode precedent (`flow-decision-mode-inclusive-explicit`) shows the same boundary. It replays over authored sources only and is refused at rehydration by `CONVERSIONS_NOT_REPLAYED_AT_REHYDRATION`. For stored rows it ships a report-only list, not a rewrite. So (b) applies, and the changeset names it. ## Zone 2 assumptions, each measured 1. **The report channel: partly disproved.** - The D3 entries carry no stored-row detector, and `--stored` never reads D3 entries. What it does read is the conversion TODO sink (`onTodo`, `CONVERSION_TODO_CODE`). For a flow row, `migrateStoredMetadata` hardcoded `todos: []` and ran only `canonicalizeStoredFlow`. - Why the cloud seat's preview named no refused flow: `FlowSchema.safeParse` accepts a `get_record` filter of `{ id: '{record.id}' }`, which I measured at `success: true` on this tree. The refusal is `flowNodeValueTemplateRefusals`, which only `registerFlow`'s `validateFlowExpressions` runs, after canonicalization. Objects and views had the same gap: the preview never parses a row it does not rewrite, so a refused row read as `canonical`. - A TODO-only D2 entry would have been wrong: `spec-changes.json` projects every D2 entry as "A lossless conversion applied at load". The detectors therefore live in their own in-package list (`packages/spec/src/conversions/refused-shapes.ts`). They are keyed by their D3 entry ids, they run at the end of `applyConversions` only when a caller passes `onTodo`, and they never rewrite. 2. **#23270's shapes: holds.** No shape converts losslessly. Two masters need a pick. A required `lookup` turned into a `master_detail` changes delete behaviour. An explicit `required: false` is a contradiction the builder refuses: writing `true` would change what the field accepts and override what the author wrote. No master leaves nothing to convert. 3. **#23271's move: holds.** No mechanical move exists. A page must exist to carry the `dataSource`, and deleting `data` makes the view query its object instead. The population is not provably empty: the save door accepted the arm before #23160, so I list instead of treating the population as empty. 4. **The predicate: holds, and is shared.** The detector reuses ObjectSchema's one-required-master predicate, extracted into the internal module `packages/spec/src/data/controlled-by-parent-master.ts`. It is not re-exported, so #23272 stays its own card. The refinement's messages are byte-identical. The view detector asks `ViewDataSchema.safeParse` itself, and the flow detector calls `flowNodeValueTemplateRefusals`, so each listing is exactly what its door refuses. 5. **The boot behaviour: holds.** It is out of scope, reported below, and the runtime is not changed. 6. **Hot files.** No generated file is committed. `registry.ts` is gitignored and regenerated at install, build and test. I merged `main` three times, never with a conflict. ## Where it lands, and why outside the claim's file surface The claim's surface is `packages/spec`. For flows, measurement put the producer elsewhere, and the dispatch's file-surface clause says to fix at the producer and name it. Changing `packages/cli/src/commands/migrate/meta*` (the dispatch's STOP path) was not needed: the CLI renders `formatStoredMigrationReport`, which lives in metadata-protocol. - `packages/services/service-automation/src/engine.ts`: `canonicalizeStoredFlow` passes `onTodo` and returns `todos` beside `notices` and `conflicts`. `registerFlow` ignores them, because its own refusal names the same sites. - `packages/metadata-protocol/src/stored-migration.ts`: `StoredFlowCanonicalization` gains an optional `todos`, so a caller-supplied canonicalizer that reports none still fits. - `packages/metadata-protocol/src/protocol.ts`: the flow branch reads `flowResult.todos ?? []`. The skip reason of a row that only has TODOs no longer says "The row keeps loading unchanged", which this change would have made false for a refused flow. It now says the row is read as stored and that each TODO says what that leaves it doing. Inside `packages/spec`: - `conversions/refused-shapes.ts` (new), `conversions/apply.ts` (the call), and doc-only edits to `conversions/types.ts` (a TODO's `conversionId` may name a D3 entry) and `conversions/stored.ts`; - `contracts/automation-service.ts`: `canonicalizeStoredFlow`'s return declares `todos?` (the public-surface section above); - `data/controlled-by-parent-master.ts` (new) and `data/object.zod.ts` (the refinement delegates to it); - one sentence on each of the three D3 entries, saying what `--stored` does with these rows. Docs: one row in `content/docs/deployment/cli.mdx`'s `--stored` decline table. Changeset: `.changeset/23269-v18-stored-row-fates.md` (`minor` for spec, service-automation and metadata-protocol). Both new spec modules (`refused-shapes.ts`, `controlled-by-parent-master.ts`) are internal: neither is re-exported, so no export name changes. The one contract member they need is declared above. ## Reach decision: all value slots, not only CRUD filters The flow detector lists every refusal of `flowNodeValueTemplateRefusals`, not only the CRUD filter operands. That is the D3 entry `flow-value-slot-template-dialect-refused` whole, and `registerFlow` refuses every one of those sites the same way. Limiting it to filters would have taken extra code that filters the one judge, and the preview would have stayed silent about flows the boot refuses for the same retirement. The four conditions for a bounded in-place fix hold: same defect class, mechanical, no other claim on these files, same gate family. ## Pins - **#23269:** - a braced operand on `get_record`, `update_record` and `delete_record` (field value, `$gt` comparand, `$in` element), and one inside a loop body: one TODO each, naming the flow, the node and the operand, with its CEL envelope; - CEL-envelope, query-placeholder and literal flows: untouched and not listed; - the engine returns the TODO, and `registerFlow` refuses the same site; - the preview lists the flow, `--apply` writes nothing, and the CEL form is `canonical` and registers. - "A converted row boots and runs" does not apply under (b): no row converts. The CEL-form row booting is pinned instead. - **#23270:** - one pin per shape: two required masters, two unrequired masters, explicit `required: false`, required lookup only, no master; - three conforming objects untouched; - the Studio shape still converts and is not listed; - another sharing model is not listed; - the preview lists four objects, counts the conforming one `canonical`, writes nothing on `--apply`, and leaves `storedMigrationClean` true. - **#23271:** - all five stored view spellings, and an object's `listViews`: listed; - no header value reaches `from`, `reason` or `message`; - `object`, `value` and `schema` providers: untouched. ## Verification ### Patch round, at `HEAD` `086d6f1ad` (merge base with `origin/main`: `404fc477e`) The contract-tier review (record 6152721854) failed this PR on the semver and Clause-② section only. This round makes its three edits, with no change to any door, detector or predicate: - the contract declares `todos?`; - the changeset is `minor` for all three packages, with `Clause-②: yes (widening)` naming the three members; - this body's `Clause-②` line matches. `main` was merged first: `331fa99bb`, no conflict. - Build: `turbo run build` over the closures of `@objectstack/service-automation^...` and `@objectstack/metadata-protocol^...`, plus the `@objectstack/client...` and `@objectstack/client-react...` closures: 35 of 35 tasks, exit 0. - `pnpm --filter @objectstack/spec check:generated`: exit 0, "All 14 generated artifacts are up to date", `check:api-surface` and `check:export-origins` among them. Nothing was regenerated, because the api-surface snapshot records export names, not interface members. - Reverse verification of the contract change: `AutomationEngine.canonicalizeStoredFlow`'s `todos` widened to `ConversionTodoNotice[] | number` through `scripts/ablation-replace.mjs`. `tsc --noEmit` in service-automation went red as predicted: `src/engine.ts(4364,5): error TS2416: Property 'canonicalizeStoredFlow' in type 'AutomationEngine' is not assignable to the same property in base type 'IAutomationService'`. A contract without `todos` would accept that member, so the red proves `tsc` read the rebuilt `dist` declarations. Restore proven: blob equal to `HEAD`, empty `git diff HEAD`. - `typecheck` for `@objectstack/spec`, `@objectstack/service-automation` and `@objectstack/metadata-protocol`: exit 0 each. - Pins, all exit 0: - spec: `refused-shapes`, `stored` and `cbp-master-detail-required-defaulted`, 61 tests; - service-automation: `canonicalize-stored-flow` and `engine`, 175 tests; - metadata-protocol: `protocol.stored-migration` and `protocol.flow-canonicalizer`, 60 tests. - `pnpm --filter @objectstack/spec test:repo`: measured in full, exit 0, 58 files, 1007 tests. - Gates: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 115 commands at `086d6f1ad`, with no stale-tree warning. All exited 0, the Clause-② gates (`check-changeset-no-major`, `check-adr-0087-registration`) and `check:api-surface` included. - `check:dual-build-cjs-loads` first answered PREREQUISITE NOT MET (exit 3), because 33 packages had no `dist` in the recreated worktree. Re-run after the battery, whose later gates built them, it gave exit 0: "108 published require entry point(s) across 67 package(s) load". - Reconcile: "✓ dispatch-gates --ran: 115 derived famil(ies) accounted for — 115 run, 0 NOT-MEASURED (a DERIVED zero — all 115 recorded an exit code and none of them is 3)." - `origin/main` has since gained `87e902dcb`, `4d4040ccb` and `37510aa0f`, none under the three changed packages. Not merged. ### First round, at `HEAD` `af10b608d` (merge base with `origin/main`: `9c7cfbf0a`) unless a line names another commit - Build: `turbo run build` over the closures of `@objectstack/service-automation^...` and `@objectstack/metadata-protocol^...`, plus the `@objectstack/client...` and `@objectstack/client-react...` closures: 35 of 35 tasks, exit 0. - `pnpm --filter @objectstack/spec test`: exit 0, 670 files, 20307 tests (1 todo). - `pnpm --filter @objectstack/metadata-protocol test`: exit 0, 235 passed and 3 skipped files, 28336 tests. - `pnpm --filter @objectstack/service-automation test`: exit 0, 185 files, 2451 tests, at `e43c67948`. The merge after it brought no change under that package; the pins re-ran on `af10b608d`: `canonicalize-stored-flow.test.ts`, 11 of 11. - `typecheck` for `@objectstack/spec`, `@objectstack/metadata-protocol` and `@objectstack/service-automation`: exit 0 each. - `pnpm --filter @objectstack/spec test:repo`: **NOT MEASURED** as a whole. I stopped my own run at 22 minutes of lock hold while another agent was queued (exit 143). Declared narrowing, at `e43c67948`: the 34 `src/` tree-scan pins plus `scripts/conversions-major18-merge.test.ts` ran, exit 0, 35 files, 482 tests. The other 23 `scripts/` tests exercise `packages/spec/scripts/*`, which this diff does not touch. CI runs the whole project. - Gates: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 115 commands at `af10b608d`, and every one exited 0. Reconcile: "✓ dispatch-gates --ran: 115 derived famil(ies) accounted for — 115 run, 0 NOT-MEASURED (a DERIVED zero — all 115 recorded an exit code and none of them is 3)." - An earlier battery caught one real finding, now fixed: `check:dispatcher-error-vocabulary` saw `OS_METADATA_CONVERSION_TODO` stamped in `refused-shapes.ts`, an unclassified site. That code is now stamped only in `apply.ts`, the classified one. - After the merge, `origin/main` moved four commits: `280806912`, the CLI organization-ownership ceremony; `4c54486dd`, a fix to the derivation script; `5c9bfb0d3`, plugin-security; `404fc477e`, plugin-auth. None touches `packages/spec`, `packages/metadata-protocol` or `packages/services/service-automation`, so I did not merge them then; the patch round merged them (`331fa99bb`). - Lint, a proven narrowing, not `pnpm lint`. `eslint --no-inline-config --format json` over the 16 changed source and docs files: 16 entries, 0 errors, 0 rule warnings. - `content/docs/deployment/cli.mdx` is outside the population: eslint itself answers "File ignored because no matching configuration was supplied". - Invariance: `eslint.config.mjs` never enables type-aware linting (no `parserOptions.project`, no typed rules; the note above `QUERY_OPTIONS_TEST_GLOBS`). So this diff cannot move an untouched file's verdict through type information. - Spec's built CJS entries, `dist/index.js` and `dist/browser/index.js`, both load and list a refused object and a refused view through `applyConversionsToStoredItem`. - Ablations, one-shot, through `scripts/ablation-replace.mjs`, run before the stamping move. Each restore was proven by blob equal to `HEAD` and an empty `git diff HEAD`: - A, the `reportRefusedStoredShapes` call removed from `applyConversions`: `refused-shapes.test.ts` 18 failed and 13 passed, predicted red. The 13 are the not-listed and registry pins. - B, `flowResult.todos ?? []` replaced by `[]` in `protocol.ts`: only the #23269 preview pin went red (1 of 46). - C, the engine's `todos.push` removed: only the braced-operand engine pin went red (1 of 11). ## Acceptance notes - **Real producers of the #23269 shape.** hotcrm at `0f84c9c` has 19 flow files (35 lines) whose `filter:` carries a dotted-path braced token. Cloud's AI build wrote it while the skill taught `filter: { id: '{record.id}' }`, per #23269. - **Out of scope here:** a page component whose `data` (`object-grid`, and the other component whose `data` is `ViewDataSchema`) binds the retired API source is refused by the same schema but is not listed. #23160's D3 entry scopes the retirement to views and object list views, and this listing follows that scope. Carrier: none. - **Out of scope here:** the other `registerFlow` refusals (text-slot `{{ }}` holes, unknown config keys, decision modes) are still not previewed. The general shape would be the stored pass running `registerFlow`'s whole validation. Carrier: none. --- _Generated by [Claude Code](https://claude.ai/code/session_01BRQ9Mp1yH7R1az6MaUM18H)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent cc14657 commit 572ecfb

18 files changed

Lines changed: 1097 additions & 61 deletions
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/service-automation': minor
4+
'@objectstack/metadata-protocol': minor
5+
---
6+
7+
fix: `os migrate meta --stored` lists every stored row that protocol 18 refuses and no conversion rewrites. Before, the preview counted each such row as already on protocol.
8+
9+
Clause-②: yes (widening: `IAutomationService.canonicalizeStoredFlow(...).todos?` in `@objectstack/spec`, `AutomationEngine.canonicalizeStoredFlow(...).todos` in `@objectstack/service-automation` and `StoredFlowCanonicalization.todos?` in `@objectstack/metadata-protocol`, each a new output member; nothing narrows, and no door accepts or refuses anything it did not before)
10+
11+
Three step-18 retirements refuse a stored shape that has no lossless rewrite. Upgrading a v17 database left those rows refused at load or at boot, and the stored preview read them as `canonical`. Each shape now has exactly one fate: **refused and listed**. The preview and `--apply` print one `TODO` line per site under its row. The line names the step-18 entry that refuses the shape, the site, and the choice the owner has to make. Nothing is rewritten, and the run's verdict does not change, because no run of the pass could clear these rows.
12+
13+
**#23269, a flow value slot carrying a `{…}` token, including a `get_record` / `update_record` / `delete_record` filter operand: refuse and list.** A whole-path token maps mechanically to `{ dialect: 'cel', source: '<path>' }`, but the rewrite is not lossless. A token the template resolved to nothing (an erased condition, so the node was refused) is a CEL fault on an absent root, and a flow variable named like a query placeholder used to shadow it. ADR-0087 D2 conversions run on the load path and must be lossless, so this one is not a D2. The chain also has no operator-applied rewrite of stored rows separate from the load path: `--stored` writes back the read path's own pass, and `--from 17` rewrites authored sources, not rows. So the flow is listed by flow, node and path (`flow-value-slot-template-dialect-refused`), and `registerFlow` goes on refusing it until the value is rewritten as its CEL envelope. A flow already in the CEL form, or using a query placeholder such as `{current_user_id}`, is not listed.
14+
15+
**#23270, a `controlled_by_parent` object without exactly one required `master_detail`: refuse and list, by object (`cbp-master-relation-tiers-retired`).** No shape here converts losslessly. The upgrade never picks between two masters, either both required or neither. An explicit `required: false` is a contradiction the builder refuses. Turning a required `lookup` into a `master_detail` changes what deleting the master does. An object with no master has nothing to convert. Leaving `controlled_by_parent` changes who can read the rows, so that is the owner's choice and never a silent rewrite. The listing reads the same predicate `ObjectSchema` refuses with, so the preview and the parse refuse one set. The one lossless shape, a single `master_detail` with `required` omitted, is still converted by `cbp-master-detail-required-defaulted` and is not listed.
16+
17+
**#23271, a view whose `data` binds the retired view-level API source: refuse and list, by view (`view-data-api-provider-retired`).** There is no mechanical move. The replacement is a page component's `dataSource`, so a page must exist to carry it. Deleting the view's `data` would make the view query its object instead. Views in every stored body spelling are covered, and so are an object's own `listViews`. The line shows the request URL and method and withholds every other request key, so a credential stored in a view's headers never reaches the report. A view on the `object`, `value` or `schema` provider is not listed.
18+
19+
How it is wired: `applyConversions` reports these shapes through its existing `onTodo` sink, after the conversions run. Each TODO's `conversionId` is the id of the D3 entry that refuses the shape, so the published `ConversionTodoNotice.conversionId` now names either a conversion or a D3 entry. The published flow-canonicalization result gains one member, a widening in three packages:
20+
21+
- `@objectstack/spec`: the contract `IAutomationService.canonicalizeStoredFlow(...)` declares an optional `todos?: ConversionTodoNotice[]` beside `notices` and `conflicts`. An implementation that reports none still conforms.
22+
- `@objectstack/service-automation`: `AutomationEngine.canonicalizeStoredFlow(...)` returns `todos`. `registerFlow` ignores them, because its own refusal names the same sites.
23+
- `@objectstack/metadata-protocol`: `StoredFlowCanonicalization` gains the matching optional `todos?`, and the stored pass lists a flow row's TODOs the way it lists any other row's.
24+
25+
The authoring funnel and the read seams pass no sink, so nothing changes for them.

‎content/docs/deployment/cli.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1473,6 +1473,7 @@ can produce, because both supply a live one.
14731473
| Rows that still fail the current schema after conversion | That is a genuine contract violation, not chain-owned history. The write path's rejection is correct; fix the row in Studio |
14741474
| A flow whose rename the conflict guard refused | The old node-type token is a live name something else owns here. Rewriting would clobber that owner, so the row fails loudly naming the token — never a silent skip |
14751475
| A site the conversion chain leaves as stored because no lossless rewrite exists — above all a page filter carrying `$and` / `$or` / `$not` | Flattening a combinator changes which rows the page selects, so it is never done. Each site is printed as a `TODO` line under its row — path, block, and what blocks the rewrite — whatever the row's outcome; a row with nothing but TODOs is reported `skipped`. It does not fail the run, since no run of this pass can clear it: rewrite each site by hand |
1476+
| A stored shape protocol 18 refuses and no conversion rewrites — a `controlled_by_parent` object without exactly one required `master_detail` (two masters, none, a required `lookup` only, an explicit `required: false`), a view bound to the retired view-level API data source, a flow value slot carrying a `{…}` token (a `get_record` / `update_record` / `delete_record` filter operand among them) | Each would need a choice only its owner can make — which master, which page, what an absent value should do — so it is never rewritten. Each site is printed as a `TODO` line under its row, naming the step-18 entry that refuses it, the object, view or flow node, and the owner's choices; a view's request headers are withheld. Until it is rewritten by hand, the parse or the boot refuses the row: an object registers but derives no master, a view registers under `[metadata_spec_invalid]`, and a flow is not registered |
14761477
14771478
**One thing it lists and never writes: decision nodes that changed meaning at
14781479
protocol 18.** A stored `decision` with no `config.conditions`, no `mode` and two

‎packages/metadata-protocol/src/protocol.stored-migration.test.ts‎

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { describe, expect, it } from 'vitest';
2626
// of this package's (file, verb) pairs sat in the gate's DEBT ledger until
2727
// #5619 sank the two predicates into a package both sides already depend on.
2828
import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate } from '@objectstack/metadata-core';
29+
import { applyConversionsToFlow, type ConversionTodoNotice } from '@objectstack/spec';
2930
import { applyMetaMigrations } from '@objectstack/spec/migrations';
3031
import { ObjectStackProtocolImplementation } from './protocol.js';
3132
import {
@@ -1083,3 +1084,142 @@ describe('migrateStoredMetadata — the decision review list: stored rows take f
10831084
expect(formatStoredMigrationReport(report).join('\n')).not.toMatch(/FIRST matching branch/);
10841085
});
10851086
});
1087+
1088+
describe('migrateStoredMetadata — the v18 stored shapes refused with no lossless rewrite are LISTED, never rewritten (#23269, #23270, #23271)', () => {
1089+
// Each of these rows reads as on protocol to every conversion — nothing
1090+
// rewrites it — while the parse or the boot refuses it after the upgrade.
1091+
// Before the refused-shape list, the preview counted each one `canonical`.
1092+
// The retired view-level provider, spelled through a constant (a tree-scoped
1093+
// absence pin in @objectstack/spec reads `provider: 'api'` literals).
1094+
const API = 'api' as const;
1095+
const cbpObject = (name: string, fields: Record<string, unknown>) => ({
1096+
type: 'object',
1097+
name,
1098+
metadata: { name, label: name, sharingModel: 'controlled_by_parent', fields: { title: { type: 'text', label: 'Title' }, ...fields } },
1099+
});
1100+
const md = (reference: string, extra: Record<string, unknown> = {}) => ({ type: 'master_detail', label: reference, reference, ...extra });
1101+
const crudFlow = (name: string, operand: unknown) => ({
1102+
type: 'flow',
1103+
name,
1104+
metadata: {
1105+
name,
1106+
label: name,
1107+
type: 'autolaunched',
1108+
status: 'active',
1109+
nodes: [{ id: 'get', type: 'get_record', label: 'Get', config: { objectName: 'lead', filter: { id: operand } } }],
1110+
edges: [],
1111+
},
1112+
});
1113+
/** Stands in for `AutomationEngine.canonicalizeStoredFlow`: the same chain, the same TODO sink. */
1114+
const canonicalizeFlow = (_name: string, body: unknown) => {
1115+
const todos: ConversionTodoNotice[] = [];
1116+
const storable = applyConversionsToFlow(body, { includeRetired: true, onTodo: (t) => todos.push(t) });
1117+
return { storable, notices: [], conflicts: [], todos };
1118+
};
1119+
1120+
it('#23270 — each refused controlled_by_parent shape is listed by object; a conforming object is counted canonical', async () => {
1121+
const { engine, tables } = makeStubEngine([
1122+
cbpObject('crm_two', { quote: md('crm_quote', { required: true }), order: md('crm_order', { required: true }) }),
1123+
cbpObject('crm_none', {}),
1124+
cbpObject('crm_lookup', { quote: { type: 'lookup', label: 'Quote', reference: 'crm_quote', required: true } }),
1125+
cbpObject('crm_false', { quote: md('crm_quote', { required: false }) }),
1126+
cbpObject('crm_ok', { quote: md('crm_quote', { required: true }) }),
1127+
]);
1128+
const before = JSON.stringify(metaRows(tables));
1129+
const protocol = new ObjectStackProtocolImplementation(engine);
1130+
1131+
const report = await protocol.migrateStoredMetadata();
1132+
1133+
expect(report.canonical).toBe(1);
1134+
expect(report.rows.map((r) => [r.name, r.outcome, r.todos.map((t) => [t.conversionId, t.path])])).toEqual([
1135+
['crm_two', 'skipped', [['cbp-master-relation-tiers-retired', 'objects[0].fields']]],
1136+
['crm_none', 'skipped', [['cbp-master-relation-tiers-retired', 'objects[0].sharingModel']]],
1137+
['crm_lookup', 'skipped', [['cbp-master-relation-tiers-retired', 'objects[0].sharingModel']]],
1138+
['crm_false', 'skipped', [['cbp-master-relation-tiers-retired', 'objects[0].sharingModel']]],
1139+
]);
1140+
const text = formatStoredMigrationReport(report).join('\n');
1141+
expect(text).toContain('object/crm_two [env-wide]');
1142+
expect(text).toContain('TODO cbp-master-relation-tiers-retired:');
1143+
expect(text).toMatch(/which of them is the master is the owner's choice: the upgrade never picks one/);
1144+
expect(text).toMatch(/☐ TODO: 4 site\(s\) in 4 row\(s\) are left as stored/);
1145+
// Listed, never written — on --apply too — and no verdict flips over it.
1146+
expect(storedMigrationClean(report)).toBe(true);
1147+
const applied = await protocol.migrateStoredMetadata({ apply: true });
1148+
expect(applied.rewritten).toBe(0);
1149+
expect(JSON.stringify(metaRows(tables))).toBe(before);
1150+
expect(historyRows(tables)).toHaveLength(0);
1151+
});
1152+
1153+
it('#23271 — a view bound to the retired API source is listed by view, without its headers; another provider is canonical', async () => {
1154+
const apiView = {
1155+
type: 'view',
1156+
name: 'partner_cases',
1157+
metadata: {
1158+
object: 'crm_case',
1159+
list: {
1160+
type: 'grid',
1161+
columns: ['name'],
1162+
data: { provider: API, read: { url: 'https://partner.example.com/cases', headers: { Authorization: 'Bearer s3cr3t' } } },
1163+
},
1164+
},
1165+
};
1166+
const objectView = {
1167+
type: 'view',
1168+
name: 'open_cases',
1169+
metadata: { object: 'crm_case', list: { type: 'grid', columns: ['name'], data: { provider: 'object', object: 'crm_case' } } },
1170+
};
1171+
const { engine } = makeStubEngine([apiView, objectView]);
1172+
const protocol = new ObjectStackProtocolImplementation(engine);
1173+
1174+
const report = await protocol.migrateStoredMetadata();
1175+
1176+
expect(report.canonical).toBe(1);
1177+
expect(report.rows).toHaveLength(1);
1178+
const row = report.rows[0]!;
1179+
expect(row).toMatchObject({ type: 'view', name: 'partner_cases', outcome: 'skipped' });
1180+
expect(row.todos.map((t) => [t.conversionId, t.path])).toEqual([['view-data-api-provider-retired', 'views[0].list.data']]);
1181+
const text = formatStoredMigrationReport(report).join('\n');
1182+
expect(text).toContain('view/partner_cases [env-wide]');
1183+
expect(text).toMatch(/No mechanical move: the replacement is a `dataSource` binding on a page component/);
1184+
expect(text).not.toContain('s3cr3t');
1185+
});
1186+
1187+
it('#23269 — a flow whose CRUD filter operand is a {…} token is listed by flow and node; the CEL form is canonical', async () => {
1188+
const { engine, tables } = makeStubEngine([
1189+
crudFlow('fd_refused_filter', '{record.id}'),
1190+
crudFlow('fd_cel_filter', { dialect: 'cel', source: 'record.id' }),
1191+
]);
1192+
const before = JSON.stringify(metaRows(tables));
1193+
const protocol = new ObjectStackProtocolImplementation(engine);
1194+
1195+
const report = await protocol.migrateStoredMetadata({ canonicalizeFlow });
1196+
1197+
expect(report.canonical).toBe(1);
1198+
expect(report.rows).toHaveLength(1);
1199+
const row = report.rows[0]!;
1200+
expect(row).toMatchObject({ type: 'flow', name: 'fd_refused_filter', outcome: 'skipped', notices: [] });
1201+
expect(row.todos.map((t) => [t.conversionId, t.path, t.from])).toEqual([
1202+
['flow-value-slot-template-dialect-refused', 'flows[0].nodes[0].config.filter.id', JSON.stringify('{record.id}')],
1203+
]);
1204+
const text = formatStoredMigrationReport(report).join('\n');
1205+
expect(text).toContain('flow/fd_refused_filter [env-wide]');
1206+
expect(text).toContain("Flow `fd_refused_filter`, node 'get' (get_record), get_record filter operand at config.filter.id");
1207+
expect(text).toContain('registerFlow refuses the flow: it is not registered at boot and never runs');
1208+
// Listed, never written.
1209+
const applied = await protocol.migrateStoredMetadata({ apply: true, canonicalizeFlow });
1210+
expect(applied.rewritten).toBe(0);
1211+
expect(JSON.stringify(metaRows(tables))).toBe(before);
1212+
});
1213+
1214+
it('a flow canonicalizer that reports no `todos` still fits the contract, and reads as none', async () => {
1215+
const { engine } = makeStubEngine([crudFlow('fd_refused_filter', '{record.id}')]);
1216+
const protocol = new ObjectStackProtocolImplementation(engine);
1217+
1218+
const report = await protocol.migrateStoredMetadata({
1219+
canonicalizeFlow: (_n, body) => ({ storable: body, notices: [], conflicts: [] }),
1220+
});
1221+
1222+
expect(report.canonical).toBe(1);
1223+
expect(report.rows).toEqual([]);
1224+
});
1225+
});

‎packages/metadata-protocol/src/protocol.ts‎

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22536,6 +22536,12 @@ export class ObjectStackProtocolImplementation implements
2253622536
* a pre-protocol shape it cannot rewrite without changing what it means
2253722537
* — a page filter carrying `$or`, which a flat rule list cannot spell —
2253822538
* leaves the site byte-identical and reports a TODO instead of a notice.
22539+
* The same list carries the shapes protocol 18 refuses that no
22540+
* conversion covers (#23269, #23270, #23271: a `controlled_by_parent`
22541+
* object without exactly one required `master_detail`, a view bound to
22542+
* the retired view-level API source, a flow value slot carrying a `{…}`
22543+
* token), each TODO naming the D3 entry that refuses it. A flow row's
22544+
* TODOs come back from the flow canonicalizer.
2253922545
* Every such site is listed under its row (`rows[].todos`) with the
2254022546
* block and the reason, whatever the row's outcome; a row whose ONLY
2254122547
* finding is TODOs has nothing to persist and is reported `skipped`,
@@ -22822,8 +22828,14 @@ export class ObjectStackProtocolImplementation implements
2282222828
// still changing the body. Both passes are copy-on-write, so
2282322829
// identity is the precise test there: `storable === body` exactly
2282422830
// when nothing was rewritten at all.
22831+
//
22832+
// [#23269] A flow's TODOs come back from the canonicalizer, which
22833+
// runs the same chain with the same sink: until they did, a stored
22834+
// flow carrying a `{…}` token at a value slot — a CRUD filter
22835+
// operand among them — parsed, rewrote nothing and was counted
22836+
// `canonical`, while `registerFlow` refused it at boot.
2282522837
const { item, notices, todos } = flowResult
22826-
? { item: flowResult.storable, notices: flowResult.notices, todos: [] as ConversionTodoNotice[] }
22838+
? { item: flowResult.storable, notices: flowResult.notices, todos: flowResult.todos ?? [] }
2282722839
: this.convertStoredItemDetailed(singular, body);
2282822840
const changed = flowResult ? item !== body : notices.length > 0;
2282922841
// [#17321] A site the chain left as stored — no lossless rewrite
@@ -22856,8 +22868,8 @@ export class ObjectStackProtocolImplementation implements
2285622868
outcome: 'skipped',
2285722869
reason: `the conversion chain rewrites nothing here: it left ${flattenedTodos.length} `
2285822870
+ 'site(s) of this row as stored, because no lossless rewrite exists for them — '
22859-
+ 'each TODO below names the site and why. The row keeps loading unchanged; '
22860-
+ 'rewrite each site by hand.',
22871+
+ 'each TODO below names the site and why. The row is read as stored, unchanged, '
22872+
+ 'and each TODO says what that leaves it doing; rewrite each site by hand.',
2286122873
});
2286222874
continue;
2286322875
}

‎packages/metadata-protocol/src/stored-migration.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,23 @@ export interface StoredFlowCanonicalization {
5858
* clobber that owner, and skipping quietly would hide it.
5959
*/
6060
conflicts: Array<{ conversionId: string; token: string; path: string; message: string }>;
61+
/**
62+
* Sites the chain left as stored because no lossless rewrite exists (#23269)
63+
* — above all a value slot carrying a `{…}` template token, which
64+
* `registerFlow` refuses although the parse accepts the flow. Listed under
65+
* the row exactly as a non-flow row's TODOs are ({@link StoredMigrationTodo}).
66+
* It restates the contract's own optional member,
67+
* `IAutomationService.canonicalizeStoredFlow(...).todos?` (`@objectstack/spec`):
68+
* a canonicalizer that reports none still conforms, and absent reads as none.
69+
*/
70+
todos?: Array<{
71+
conversionId: string;
72+
surface: string;
73+
from: string;
74+
path: string;
75+
reason: string;
76+
message: string;
77+
}>;
6178
}
6279

6380
/** What the pass did with (or would do with) one `sys_metadata` row. */

0 commit comments

Comments
 (0)