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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions .changeset/duplicate-package-flow-canonicalization.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
---
"@objectstack/metadata-protocol": patch
"@objectstack/cli": patch
---

fix(metadata-protocol): `duplicatePackage` stops minting pre-protocol flow rows (#4498)

`duplicatePackage` canonicalizes each source row before re-saving it, under a
stated guarantee: "duplication never mints new rows in a pre-protocol dialect."
It delivered that through `convertStoredItem`, which opens with
`if (singular === 'flow') return { item: data, notices: [] }` — so for flows the
guarantee was **not** delivered.

It did not fail loudly either. `FlowNodeSchema.config` is an open `z.record`, so
a pre-17 body (a `delete_record` carrying `config.filters`) sails through
`saveMetaItem`'s schema gate and lands verbatim in a brand-new row.

**Why this mattered more than an un-migrated row.** ADR-0087 justifies the whole
stored-metadata design on new writes always being canonical, *therefore* the
stored pass being "a strictly shrinking concern". `duplicatePackage` was a live
producer contradicting that for flows: an operator could run
`os migrate meta --stored --apply`, get a clean report, duplicate a package, and
be back to having pre-protocol rows — with the report still saying protocol N
until the next run.

**The capability was already reachable.** The reason for the flow skip is real —
flow-node conversions carry ADR-0078's open-namespace conflict guard, which needs
the automation engine's live executor registry to tell a rename from a clobber.
But the protocol is constructed with an accessor for the kernel's service table
(the same one `analytics` and `package` are read from), and the automation
service registers under `automation`. A new private `resolveFlowCanonicalizer`
reads `canonicalizeStoredFlow` (#4454) off it, so every caller running next to a
live engine gets flow coverage without threading anything.

- **`duplicatePackage`** canonicalizes flow rows through it. A refused rename
fails that item into the existing `failed[]` naming the token — copying the
un-renamed body would mint exactly the row this fixes. A flow that cannot
canonicalize fails the same way. With no engine reachable (a control-plane or
metadata-only host) the source body is copied as-is: no worse than the source
row already is, and failing an unrelated duplication over it would be its own
regression.
- **`migrateStoredMetadata`'s `canonicalizeFlow` becomes an override.** It now
defaults to the resolver. The CLI stopped passing one — it boots its inert
engine into the same kernel, so both routes reached the same instance, and two
routes to one capability is how they drift. The parameter stays for callers
with no registry and for testing the flow branch without an engine.
- **Resolution is lazy, per call.** Plugin init order does not guarantee
`automation` is in the table when the protocol is assembled (the CLI adds it
after ObjectQL by design), so caching `undefined` from a too-early read would
disable flow canonicalization for the life of the process.

Two smaller honesty fixes ride along: a source item that fails *conversion* (a
tombstoned key throws) is now reported as such instead of as `unparseable
metadata`, and `migrateStoredMetadata`'s "no engine" skip reason says no
automation service is reachable rather than blaming the caller for not supplying
one.

Reads are unchanged. `getMetaItems` / `getMetaItem` / `getMetaItemLayered` /
`loadMetaFromDb` still skip flows — they are reads, covered by `registerFlow`
canonicalizing at execution, and are not producing bad data. Duplication was the
one that writes.
47 changes: 47 additions & 0 deletions .changeset/meta-migrate-stored-route.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
---
"@objectstack/rest": minor
"@objectstack/runtime": minor
"@objectstack/client": minor
---

feat(rest,runtime,client): `POST /meta/_migrate-stored` — run the stored-metadata migration without a shell (#4327)

`os migrate meta --stored` (#4327) gave ADR-0087's stored-metadata chain a finish
line, but only for someone who can reach the deployment's database from a
terminal. A hosted operator cannot, so on a managed deployment the chain had no
finish line at all — just the per-read conversion, running forever, with no way
to assert what protocol the rows are on.

The same pass is now reachable over HTTP:

```ts
const preview = await client.meta.migrateStored(); // writes nothing
const result = await client.meta.migrateStored({ apply: true });
const flows = await client.meta.migrateStored({ types: ['flow'] });
```

It returns the same `StoredMigrationReport` the CLI renders, and takes the same
posture:

- **Preview by default.** `apply` must be literally `true`; an empty body, a
missing body, and `"apply": "yes"` all preview. Nothing is inferred.
- **Gated on `manage_metadata`.** Unlike the single-item `PUT /meta/:type/:name`
next door, this rewrites every eligible row in the deployment, so it demands
the ADR-0066 D1 authoring capability rather than just a session, and answers
`403` otherwise. The gate runs before the protocol is probed, so an
unauthorized caller cannot use `403`-vs-`501` to learn which kernels can be
migrated. `/meta`'s anonymous-deny umbrella still closes it to anonymous
callers first.
- **Attributed to the caller.** The `actor` recorded on the history and audit
rows names the user who fired it — that is the question those rows exist to
answer.

**Flows need no extra setup on this path.** The CLI has to boot an inert
automation engine to hold the executor registry ADR-0078's conflict guard needs;
a server already has a live one, and the protocol resolves it from the services
registry itself (#4498), so this route covers flow rows by simply running in the
process that owns them.

Registered on both the REST server and the runtime dispatcher's `/meta` domain,
ledgered in both route ledgers, and mounted before `/:type` so the
leading-underscore segment is never captured as a metadata type name.
4 changes: 4 additions & 0 deletions content/docs/api/client-sdk.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,10 @@ const bad = await client.meta.getDiagnostics({ severity: 'error' });
const refs = await client.meta.getReferences('object', 'account');
const trail = await client.meta.getAudit('object', 'account', { limit: 20 });
const tree = await client.meta.getBookTree('handbook');

// Operator: rewrite stored rows into today's canonical shape (ADR-0087).
// Preview unless `apply: true`; requires the `manage_metadata` capability.
const report = await client.meta.migrateStored({ apply: true });
```

### `client.data` — CRUD & Batch
Expand Down
25 changes: 25 additions & 0 deletions content/docs/deployment/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -846,6 +846,31 @@ rewrites an **author's source** and reads no database; `--stored` rewrites **one
deployment's rows** and reads no config. Same chain, opposite ends of the
contract — which is why the two modes are mutually exclusive.

**Without shell access, use the route.** This command needs to reach the
deployment's database directly, which a hosted operator cannot do. The same pass
is exposed over HTTP:

```http
POST /api/v1/meta/_migrate-stored
Content-Type: application/json

{ "apply": true, "types": ["flow"] }
```

or from the SDK:

```ts
const preview = await client.meta.migrateStored(); // writes nothing
const result = await client.meta.migrateStored({ apply: true });
```

It returns the same report the CLI renders, and takes the same posture:
**preview unless `apply` is literally `true`**, `types` optional. It requires the
`manage_metadata` capability — it rewrites every eligible row in the deployment,
not one item — and answers `403` otherwise. Flows need no extra setup on this
path: the server already holds a live automation engine, so the run resolves the
executor registry the conflict guard needs from the process it is running in.

### Scaffolding

| Command | Alias | Description |
Expand Down
45 changes: 45 additions & 0 deletions docs/adr/0087-metadata-protocol-upgrade-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -487,3 +487,48 @@ Closing it needed three decisions.
A refused rename — the guard firing because the old token is a live name owned
by something else — fails that row loudly with the token and its owner. Never a
silent skip, never a clobber; that is the whole reason the guard exists.

## Addendum (2026-08-01c) — "strictly shrinking" was false for flows (#4498)

The bullet above claims new rows are always canonical, *therefore* the stored
pass is a strictly shrinking concern. `duplicatePackage` was a live producer
contradicting it: it canonicalizes each source row before re-saving, but through
`convertStoredItem`, which returns `flow` bodies untouched. `FlowNodeSchema.config`
is an open `z.record`, so a pre-17 body sailed through `saveMetaItem`'s gate and
landed verbatim in a brand-new row. An operator could run the migration, get a
clean report, duplicate a package, and be back to pre-protocol rows — with the
report still saying protocol N until the next run.

- **The capability was already reachable; only the wiring was missing.** The
protocol is constructed with an accessor for the kernel's service table (the
same one `analytics` and `package` are read from), and the automation service
registers under `automation`. `resolveFlowCanonicalizer` reads
`canonicalizeStoredFlow` off it. So the fix is not new plumbing per call site
— it is one private resolver that every caller running next to a live engine
shares.
- **The explicit hook becomes an override, not a requirement.**
`migrateStoredMetadata`'s `canonicalizeFlow` defaults to the resolver, so the
CLI stopped passing one (it boots the inert engine into the same kernel, so
both routes reached the same instance — two routes to one capability is how
they drift). The parameter stays for callers with no registry and for testing
the flow branch without an engine.
- **Resolution is lazy, per call.** Plugin init order does not guarantee
`automation` is in the table when the protocol is assembled — the CLI adds it
after ObjectQL by design — so caching `undefined` from a too-early read would
disable flow canonicalization for the life of the process.
- **The failure posture matches #4454's.** A refused rename fails that item into
`duplicatePackage`'s existing `failed[]` naming the token, rather than copying
the un-renamed body: producing exactly the row this fix exists to prevent is
the one outcome worse than failing the copy. A flow that cannot canonicalize
at all fails the same way. With **no** engine reachable (a control-plane or
metadata-only host) the source body is copied as-is — no worse than the source
row already is, and failing an unrelated duplication over it would be its own
regression.
- **Reads were not changed.** `getMetaItems` / `getMetaItem` /
`getMetaItemLayered` / `loadMetaFromDb` still skip flows; they are reads,
covered by `registerFlow` canonicalizing at execution, and are not producing
bad data. Duplication was the one that *writes*. The resolver is the seam they
would adopt if that changes.

The premise is restored rather than restated: the stored pass shrinks because
every write path now canonicalizes, not because the sentence says so.
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* End-to-end acceptance for #4498's CLI half: `os migrate meta --stored` still
* covers `flow` rows after the command stopped threading `canonicalizeFlow`.
*
* #4454 wired flow coverage by resolving `automation` off the booted kernel in
* the command body and handing `canonicalizeStoredFlow` to
* `migrateStoredMetadata`. #4498 gave the protocol its own resolver — it is
* constructed with an accessor for the kernel's service table, which is the
* same table the inert engine registers into — so the command passes nothing
* and the redundant second route is gone.
*
* That is exactly the kind of removal a unit test cannot defend: every flag test
* still passes if the protocol silently fails to find the engine, and the only
* symptom is flow rows quietly reporting `skipped` again. So this boots the
* REAL stack the command boots (`bootSchemaStack` +
* `buildDataMigrationPlugins({ automation: true })`), seeds a pre-17 flow row,
* and asserts the rewrite lands in the database.
*/

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type { IObjectQLEngine } from '@objectstack/spec/contracts';
import { bootSchemaStack } from '../../utils/schema-migrate.js';
import { buildDataMigrationPlugins } from '../../utils/data-migration-plugins.js';

/**
* `SchemaStack.kernel` is untyped, so a type argument is a TS2347 — the slot's
* contract is stated on the RESULT instead. Narrowing, not erasing: `: any`
* here would switch off checking on every `ql.*` call below while looking
* identical to code that has it (the `slot-lookup` rule's whole point).
*/
const engineOf = (stack: { kernel: any }): IObjectQLEngine =>
stack.kernel.getService('objectql') as IObjectQLEngine;

/** Elevated so the seed write bypasses RLS on a system object. */
const SYSTEM = { context: { isSystem: true } };

const ARTIFACT = {
id: 'stored_flow_smoke',
name: 'Stored Flow Smoke',
objects: [{ name: 'sfs_lead', fields: { title: { type: 'text' } } }],
};

/**
* A pre-17 flow: `delete_record` carrying `config.filters`, which the
* `flow-node-crud-filter-alias` conversion (toMajor 11) renames to `filter`.
* Written straight into `sys_metadata`, bypassing today's schema gate — exactly
* like a row saved years ago under an older protocol.
*/
const LEGACY_FLOW = {
name: 'sfs_purge',
label: 'Purge Stale Leads',
type: 'autolaunched',
status: 'active',
nodes: [
{ id: 'n0', type: 'start', label: 'Start' },
{
id: 'n1',
type: 'delete_record',
label: 'Purge',
config: { objectName: 'sfs_lead', filters: { title: 'stale' } },
},
],
edges: [{ id: 'e1', source: 'n0', target: 'n1' }],
};

describe('os migrate meta --stored — the protocol resolves the engine itself (#4498)', () => {
let dir: string;
let dbFile: string;
const savedEnv: Record<string, string | undefined> = {};

beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'os-stored-flow-'));
mkdirSync(join(dir, 'dist'), { recursive: true });
mkdirSync(join(dir, 'data'), { recursive: true });
dbFile = join(dir, 'data', 'app.db');
writeFileSync(join(dir, 'dist', 'objectstack.json'), JSON.stringify(ARTIFACT));

savedEnv.OS_ARTIFACT_PATH = process.env.OS_ARTIFACT_PATH;
savedEnv.NODE_ENV = process.env.NODE_ENV;
process.env.OS_ARTIFACT_PATH = join(dir, 'dist', 'objectstack.json');
process.env.NODE_ENV = 'production';
});

afterEach(() => {
process.env.OS_ARTIFACT_PATH = savedEnv.OS_ARTIFACT_PATH;
process.env.NODE_ENV = savedEnv.NODE_ENV;
try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
});

it('rewrites a pre-17 flow row with NO canonicalizeFlow passed by the command', async () => {
const stack = await bootSchemaStack({
databaseUrl: `file:${dbFile}`,
projectRoot: dir,
extraPlugins: await buildDataMigrationPlugins({ automation: true }),
});
try {
const ql = engineOf(stack);
await ql.insert('sys_metadata', {
type: 'flow',
name: 'sfs_purge',
state: 'active',
metadata: JSON.stringify(LEGACY_FLOW),
}, SYSTEM);

const protocol: any = stack.kernel.getService('protocol');

// The command's exact call since #4498 — no `canonicalizeFlow`.
const report = await protocol.migrateStoredMetadata({
apply: true,
types: ['flow'],
actor: 'os migrate meta --stored',
});

// Before the resolver this row came back `skipped` with "no automation
// service is reachable". Asserted as the REASON rather than as a bare
// count, so a regression here says what went wrong instead of just
// "expected 1 to be 0".
expect(
report.rows
.filter((r: any) => r.outcome === 'skipped' || r.outcome === 'failed')
.map((r: any) => `${r.outcome}: ${r.reason}`),
).toEqual([]);
expect(report.skipped).toBe(0);
expect(report.failed).toBe(0);
expect(report.rewritten).toBe(1);

// …and the bytes on disk actually moved.
const [row] = await ql.find('sys_metadata', {
where: { type: 'flow', name: 'sfs_purge', state: 'active' },
}, SYSTEM);
const stored = typeof row.metadata === 'string' ? JSON.parse(row.metadata) : row.metadata;
const node = stored.nodes.find((n: any) => n.id === 'n1');
expect(node.config).toEqual({ objectName: 'sfs_lead', filter: { title: 'stale' } });
expect(node.config).not.toHaveProperty('filters');

// The write-back must NOT carry the schema's defaults (#4454): persisting
// a `version` / `runAs` the author never wrote would pin this row to
// today's value while untouched rows follow tomorrow's.
expect(stored).not.toHaveProperty('runAs');
expect(stored.edges[0]).not.toHaveProperty('isDefault');

// A second pass has nothing left to do — the finish line the whole
// feature exists to provide.
const rerun = await protocol.migrateStoredMetadata({ types: ['flow'] });
expect(rerun.scanned).toBe(1);
expect(rerun.canonical).toBe(1);
expect(rerun.pending).toBe(0);
} finally {
await stack.shutdown();
}
}, 120_000);

it('without the automation plugin the row is skipped with the reason, never counted done', async () => {
// The honest negative: the coverage comes from the engine being present,
// not from the report defaulting to optimistic.
const stack = await bootSchemaStack({
databaseUrl: `file:${dbFile}`,
projectRoot: dir,
extraPlugins: await buildDataMigrationPlugins(),
});
try {
const ql = engineOf(stack);
await ql.insert('sys_metadata', {
type: 'flow',
name: 'sfs_purge',
state: 'active',
metadata: JSON.stringify(LEGACY_FLOW),
}, SYSTEM);

const protocol: any = stack.kernel.getService('protocol');
const report = await protocol.migrateStoredMetadata({ apply: true, types: ['flow'] });

expect(report.rewritten).toBe(0);
expect(report.skipped).toBe(1);
expect(report.rows[0].reason).toMatch(/no automation service is reachable/);
} finally {
await stack.shutdown();
}
}, 120_000);
});
Loading
Loading