From cc05affb7e963345b2f7435f58eb6631381a5fc2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 18:47:16 +0000 Subject: [PATCH 1/4] chore(service-automation): depend on @objectstack/metadata-protocol for the stored-metadata family serve Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude --- packages/services/service-automation/package.json | 1 + pnpm-lock.yaml | 3 +++ 2 files changed, 4 insertions(+) diff --git a/packages/services/service-automation/package.json b/packages/services/service-automation/package.json index 5eea5f1c9d6..0dc7e5f701f 100644 --- a/packages/services/service-automation/package.json +++ b/packages/services/service-automation/package.json @@ -28,6 +28,7 @@ "@objectstack/core": "workspace:*", "@objectstack/formula": "workspace:*", "@objectstack/metadata-core": "workspace:*", + "@objectstack/metadata-protocol": "workspace:*", "@objectstack/platform-objects": "workspace:*", "@objectstack/spec": "workspace:*", "@objectstack/types": "workspace:*" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 249978f37c3..fb0fc439dbf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2409,6 +2409,9 @@ importers: '@objectstack/metadata-core': specifier: workspace:* version: link:../../metadata-core + '@objectstack/metadata-protocol': + specifier: workspace:* + version: link:../../metadata-protocol '@objectstack/platform-objects': specifier: workspace:* version: link:../../platform-objects From 9e02c8cb4cfd092ac934ccd5f556b9e9b53cf31c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 18:57:41 +0000 Subject: [PATCH 2/4] fix(service-automation): a flow's get_record node serves the stored-metadata family the way the data door does A get_record read of the stored-metadata tables now projects the stored body and keys the content hash through the data door's own functions from @objectstack/metadata-protocol, under the data engine's crypto provider or the process-scoped ephemeral key, so the run output and any record the flow writes from it carry the door's form under either run identity. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude --- .../21519-flow-read-node-family-serve.md | 13 + .../src/builtin/crud-nodes.ts | 82 +++++- ...stored-metadata-family.integration.test.ts | 260 ++++++++++++++++++ 3 files changed, 353 insertions(+), 2 deletions(-) create mode 100644 .changeset/21519-flow-read-node-family-serve.md create mode 100644 packages/services/service-automation/src/builtin/get-record-stored-metadata-family.integration.test.ts diff --git a/.changeset/21519-flow-read-node-family-serve.md b/.changeset/21519-flow-read-node-family-serve.md new file mode 100644 index 00000000000..c77a6c8fb40 --- /dev/null +++ b/.changeset/21519-flow-read-node-family-serve.md @@ -0,0 +1,13 @@ +--- +'@objectstack/service-automation': patch +--- + +fix(service-automation): a flow's `get_record` node that reads the stored-metadata tables is served what the generic data door serves (#21519) + +Clause-②: no + +The two stored-metadata tables (the current metadata bodies and their version history) hold each body as stored, credential material included, and a content hash computed over it. The generic data door serves such a row with the body as its type's read projection, with the stored credential material withheld, and the hash in keyed form. A flow's `get_record` node read the same rows and served them as stored, under either run identity (`runAs: 'system'` and `runAs: 'user'`). What it read went into the run's declared output, which the flow's caller is handed back, and into any record the flow wrote from it. + +**What changes.** When the node reads either table, its answer now takes the data door's form, for one row (`findOne`, no `limit`) and for a row list (`find`, `limit` above 1). The body is projected, and the content hash is keyed under the same key the data door uses. That key is the crypto provider's, read from the data engine when the node runs, or the process-scoped ephemeral key when no provider is registered. So the hash a flow is served equals the hash the data door serves for the same row. A record the flow writes from what it read can therefore carry only the projected body and the keyed hash. A `fields` projection that names the body column without the type column reads the type beside it and drops it again, as on the data door. + +**What does not change.** Every other object is read exactly as before. The node's other config keys (`filter`, `limit`, `outputVariable`) and the write nodes behave as before. The node consumes the data door's own functions from `@objectstack/metadata-protocol` (`storedMetadataBodyProjection`, `redactStoredMetadataRows`, `serveStoredMetadataHashColumnRows`, `ephemeralStoredHashDigest`) and keeps no copy of them. `@objectstack/service-automation` now depends on `@objectstack/metadata-protocol`. A composition that runs flows on `ObjectQLPlugin`, as `os dev` and `os serve` do, already loaded that package. diff --git a/packages/services/service-automation/src/builtin/crud-nodes.ts b/packages/services/service-automation/src/builtin/crud-nodes.ts index b83fb4e30d4..ba0f9d99b47 100644 --- a/packages/services/service-automation/src/builtin/crud-nodes.ts +++ b/packages/services/service-automation/src/builtin/crud-nodes.ts @@ -18,6 +18,14 @@ import type { import type { AutomationContext, IDataEngine } from '@objectstack/spec/contracts'; import type { DroppedFieldsEvent } from '@objectstack/spec/data'; import { StandardErrorCode } from '@objectstack/spec/api'; +import { isStoredMetadataBodyObject } from '@objectstack/spec/kernel'; +import { + ephemeralStoredHashDigest, + redactStoredMetadataRows, + serveStoredMetadataHashColumnRows, + storedMetadataBodyProjection, + type StoredHashDigest, +} from '@objectstack/metadata-protocol'; import type { AutomationEngine } from '../engine.js'; import { interpolate, interpolateFilter, type VariableMap } from './template.js'; import { refuseNode } from '../guard-refusal.js'; @@ -196,6 +204,72 @@ function resolveFieldValues( return out; } +/** + * [#21519] The keyed digest a stored content hash is served under: the data + * engine's registered crypto provider's (`getKeyedDigest`, read at the moment + * of use, because a host registers the provider after the kernel starts), else + * `@objectstack/metadata-protocol`'s process-scoped ephemeral key. The same two + * sources, in the same order, the generic data door reads, so the hash this + * node serves is the hash the door serves for the same row: one key, never a + * second one. + */ +function storedHashDigestOf(data: IDataEngine): StoredHashDigest { + const accessor = (data as { getKeyedDigest?: () => StoredHashDigest | undefined }).getKeyedDigest; + const provider = typeof accessor === 'function' ? accessor.call(data) : undefined; + return provider ?? ephemeralStoredHashDigest; +} + +/** + * [#21519] Run one `get_record` read and serve its answer the way the generic + * data door serves the same rows. + * + * The stored-metadata-body family (`sys_metadata` / `sys_metadata_history`, + * judged by the family's own predicate, `isStoredMetadataBodyObject`) holds a + * stored metadata body, credential material included, and a content hash over + * that whole body. Every door that serves either serves the body as its type's + * read projection and the hash in keyed form. A flow's read node is such a + * door: what it reads becomes the run's output, a flow caller is handed that + * back, and any record the flow writes from it is a copy. Both run identities + * are served the same way: `runAs: 'system'` reads elevated, so the engine + * cannot tell this read from the platform's own internal readers, which need + * the stored form. The rule is therefore applied here, at the node. + * + * Built only from the door's own functions (`@objectstack/metadata-protocol`), + * never a copy: the projection (`storedMetadataBodyProjection`, which adds the + * `type` column a body-only projection needs to choose its redactor; it is + * taken back off the served rows), the redactor (`redactStoredMetadataRows`) + * and the keyed serve (`serveStoredMetadataHashColumnRows`, under + * {@link storedHashDigestOf}). Any other object is read and returned as is. + */ +async function serveFamilyRead( + data: IDataEngine, + objectName: string, + fields: string[] | undefined, + read: (fields: string[] | undefined) => Promise, +): Promise { + if (!isStoredMetadataBodyObject(objectName)) return read(fields); + const projection = storedMetadataBodyProjection(objectName, fields); + const answer = await read(projection.fields as string[] | undefined); + const opts = { dropType: projection.addedType }; + const digest = storedHashDigestOf(data); + if (Array.isArray(answer)) { + return (await serveStoredMetadataHashColumnRows( + objectName, + redactStoredMetadataRows(objectName, answer, opts), + digest, + )) as A; + } + if (answer !== null && typeof answer === 'object') { + const [served] = await serveStoredMetadataHashColumnRows( + objectName, + redactStoredMetadataRows(objectName, [answer], opts), + digest, + ); + return served as A; + } + return answer; +} + /** * CRUD built-in nodes — `get_record` / `create_record` / `update_record` / * `delete_record`, wired to the runtime data layer (ObjectQL / IDataEngine). @@ -288,9 +362,12 @@ export function registerCrudNodes(engine: AutomationEngine, ctx: PluginContext): // #1888 — honor flow.runAs: read under the run's effective identity // (system → RLS-bypassing; user → the triggering user). const dataCtx = resolveRunDataContext(context); + // [#21519] A read of the stored-metadata family is served the + // data door's way under either identity: body projected, hash keyed. try { if (limit && limit > 1) { - const records = await data.find(objectName, { where: filter, fields, limit, context: dataCtx }); + const records = await serveFamilyRead(data, objectName, fields, (projection) => + data.find(objectName, { where: filter, fields: projection, limit, context: dataCtx })); if (outputVariable) variables.set(outputVariable, records); // #4354 — the `selected` half of the broken-sweep signal: // this is the count that made #4347 diagnosable at all @@ -301,7 +378,8 @@ export function registerCrudNodes(engine: AutomationEngine, ctx: PluginContext): metrics: { selected: Array.isArray(records) ? records.length : 0 }, }; } - const record = await data.findOne(objectName, { where: filter, fields, context: dataCtx }); + const record = await serveFamilyRead(data, objectName, fields, (projection) => + data.findOne(objectName, { where: filter, fields: projection, context: dataCtx })); if (outputVariable) variables.set(outputVariable, record); return { success: true, diff --git a/packages/services/service-automation/src/builtin/get-record-stored-metadata-family.integration.test.ts b/packages/services/service-automation/src/builtin/get-record-stored-metadata-family.integration.test.ts new file mode 100644 index 00000000000..c24f4d9e626 --- /dev/null +++ b/packages/services/service-automation/src/builtin/get-record-stored-metadata-family.integration.test.ts @@ -0,0 +1,260 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#21519] A flow's `get_record` node is a reader of the stored-metadata-body + * family (`sys_metadata` / `sys_metadata_history`: the `metadata` body column + * and the stored content-hash columns), and it serves that family the way the + * generic data door does: the body as its type's read projection, with stored + * credential material withheld, and the content hash in keyed form, under the + * door's own key. + * + * Each case reads the SAME stored row the control reads through the data door + * (`ObjectStackProtocolImplementation.findData`, the `/data` route's handler), + * and each answer is judged against that control rather than against a fixed + * shape: + * - no stored credential and no stored hash anywhere in what the node served; + * - the row's non-credential configuration present (the projection of THIS + * row, not a withheld one); + * - the served hash equal to the one the door serves for the same row. + * + * Two exits are pinned, under BOTH run identities (`runAs: 'system'`, which + * reads elevated, and `runAs: 'user'`, which reads as the triggering user): + * - the run's declared OUTPUT (the node's `outputVariable`, declared + * `isOutput` on the flow), which a flow caller is handed back; + * - an ordinary RECORD the same flow writes from what the node read (a + * `create_record` that copies the body and the hash), the copy exit: a flow + * can copy only what it was served. + * Both node branches are pinned: one row (`findOne`, no `limit`) and a row + * list (`find`, `limit > 1`). And the key is pinned twice: with no crypto + * provider (the process-scoped ephemeral key) and with one registered on the + * engine (the provider's keyed digest), the node's hash equals the door's. + * + * Composition: the real stack the other `*.integration.test.ts` files in this + * package boot: `ObjectKernel`, `ObjectQLPlugin` (which registers the + * family's two tables), `driver-sql` on better-sqlite3 `:memory:`, and the + * real `AutomationServicePlugin`. + * The stored row is written by the engine as the system identity, with its + * canonical content hash (`hashSpec`), the shape the metadata save door + * stores. The credential is a synthetic sentinel in a credential slot the + * datasource redactor withholds. + */ + +import { createHmac } from 'node:crypto'; +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { ObjectKernel } from '@objectstack/core'; +import { ObjectQLPlugin, type ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { hashSpec } from '@objectstack/metadata-core'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { AutomationServicePlugin } from '../plugin.js'; +import type { AutomationEngine } from '../engine.js'; + +const SYS = { isSystem: true } as const; +const KEYED = /^hmac-sha256:[0-9a-f]{64}$/; + +/** The synthetic credential, in the slot the datasource redactor withholds (turso's `encryptionKey`). */ +const SENTINEL = 'flow-read-family-sentinel-4d2b'; +const DS_NAME = 'pin_flow_ds'; +/** Non-credential configuration of the same body: present in a projection of THIS row. */ +const DS_URL = 'libsql://pin-flow.example.invalid'; +const DS_BODY = { name: DS_NAME, label: 'Pin flow DS', driver: 'turso', config: { url: DS_URL, encryptionKey: SENTINEL } }; +const STORED_HASH = hashSpec(DS_BODY); + +/** An ordinary object a flow copies what it read into. */ +const COPY_OBJECT = { + name: 'pin_flow_copy', + label: 'Pin flow copy', + fields: { + title: { name: 'title', label: 'Title', type: 'text' }, + body: { name: 'body', label: 'Body', type: 'textarea' }, + hash: { name: 'hash', label: 'Hash', type: 'text' }, + }, +}; + +type RunAs = 'system' | 'user'; +type Branch = 'one' | 'list'; + +/** + * start → get_record(family) → create_record(copy of the body and hash) → end. + * `one` reads through `findOne` (no limit); `list` through `find` (`limit > 1`), + * and copies the first row. + */ +function familyReadFlow(name: string, object: string, runAs: RunAs, branch: Branch, fields?: string[]) { + const ref = branch === 'one' ? 'rec' : 'rec.0'; + return { + name, + label: name, + type: 'autolaunched', + runAs, + variables: [{ name: 'rec', type: 'object', isOutput: true }], + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { + id: 'read', + type: 'get_record', + label: 'Read', + config: { + objectName: object, + filter: { name: DS_NAME }, + outputVariable: 'rec', + ...(branch === 'list' ? { limit: 5 } : {}), + ...(fields ? { fields } : {}), + }, + }, + { + id: 'copy', + type: 'create_record', + label: 'Copy', + config: { + objectName: COPY_OBJECT.name, + fields: { title: name, body: `{${ref}.metadata}`, hash: `{${ref}.checksum}` }, + }, + }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'read' }, + { id: 'e2', source: 'read', target: 'copy' }, + { id: 'e3', source: 'copy', target: 'end' }, + ], + }; +} + +/** The trigger a user-identity run needs: a real acting user. */ +const TRIGGER = { userId: 'usr_flow_reader', tenantId: 'org_1', positions: [] as string[], permissions: [] as string[] }; + +describe('flow get_record serves the stored-metadata family the way the data door does (#21519)', () => { + let kernel: ObjectKernel; + let ql: ObjectQL; + let automation: AutomationEngine; + let door: ObjectStackProtocolImplementation; + + beforeAll(async () => { + kernel = new ObjectKernel({ logger: { level: 'fatal' } }); + await kernel.use(new ObjectQLPlugin()); + await kernel.use(new AutomationServicePlugin({ suspendedRunStore: 'memory' })); + await kernel.bootstrap(); + ql = kernel.getService('objectql'); + automation = kernel.getService('automation'); + + const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }); + await driver.connect(); + ql.registerDriver(driver, true); + ql.registry.registerObject(COPY_OBJECT as any, 'pin-21519', 'pin-21519'); + await ql.syncSchemas(); + + const metadata = JSON.stringify(DS_BODY); + await ql.insert('sys_metadata', { + id: 'meta_pin_flow_ds', name: DS_NAME, type: 'datasource', scope: 'platform', state: 'active', + metadata, checksum: STORED_HASH, + }, { context: SYS }); + await ql.insert('sys_metadata_history', { + id: 'hist_pin_flow_ds', name: DS_NAME, type: 'datasource', version: 1, operation_type: 'create', + metadata, checksum: STORED_HASH, previous_checksum: null, + }, { context: SYS }); + + door = new ObjectStackProtocolImplementation(ql as any); + }, 60_000); + + afterAll(async () => { + try { await kernel?.shutdown(); } catch { /* best-effort teardown */ } + }); + + /** The data door's answer for the same row: the control every case is judged against. */ + async function doorRow(object: string): Promise> { + const res: any = await door.findData({ object, query: { name: DS_NAME } }); + expect(res.records, `the data door must serve the ${object} row`).toHaveLength(1); + return res.records[0]; + } + + /** Run the flow and return its declared output and the record it wrote. */ + async function runFlow(def: ReturnType): Promise<{ output: any; copy: any }> { + automation.registerFlow(def.name, def as any); + const res: any = await automation.execute(def.name, { ...TRIGGER }); + expect(res.success, `run failed: ${JSON.stringify(res.error ?? res)}`).toBe(true); + const copy = await ql.findOne(COPY_OBJECT.name, { where: { title: def.name }, context: SYS }); + expect(copy, 'the flow must have written its copy').toBeTruthy(); + return { output: res.output?.rec, copy }; + } + + /** What the family's rule asks of anything a reader serves or copies. */ + function expectServedLikeTheDoor(served: unknown, door: Record, where: string) { + const text = JSON.stringify(served); + expect(text.includes(SENTINEL), `${where}: the stored credential reached it`).toBe(false); + expect(text.includes(STORED_HASH), `${where}: the stored content hash reached it`).toBe(false); + expect(door.checksum, 'control: the door serves the hash keyed').toMatch(KEYED); + } + + for (const runAs of ['system', 'user'] as const) { + for (const branch of ['one', 'list'] as const) { + it(`runAs:'${runAs}', ${branch === 'one' ? 'findOne' : 'find'} branch: the output and the written record carry the projected body and the door's keyed hash`, async () => { + const control = await doorRow('sys_metadata'); + const { output, copy } = await runFlow(familyReadFlow(`fam_${runAs}_${branch}`, 'sys_metadata', runAs, branch)); + const row = branch === 'one' ? output : output?.[0]; + expect(row, 'the node must have served the row').toBeTruthy(); + + expectServedLikeTheDoor(output, control, 'the run output'); + expectServedLikeTheDoor(copy, control, 'the written record'); + + // The projection of THIS row: its non-credential configuration is + // served, and the served body is exactly the door's. + expect(JSON.parse(row.metadata).config.url).toBe(DS_URL); + expect(JSON.parse(row.metadata)).toEqual(JSON.parse(control.metadata)); + expect(JSON.parse(copy.body)).toEqual(JSON.parse(control.metadata)); + // One key: the node's hash is the door's keyed hash for the same row. + expect(row.checksum).toBe(control.checksum); + expect(copy.hash).toBe(control.checksum); + }); + } + } + + it('the history table is served the same way, its parent hash included', async () => { + const control = await doorRow('sys_metadata_history'); + const { output, copy } = await runFlow(familyReadFlow('fam_history', 'sys_metadata_history', 'system', 'one')); + expectServedLikeTheDoor(output, control, 'the run output'); + expectServedLikeTheDoor(copy, control, 'the written record'); + expect(output.checksum).toBe(control.checksum); + expect(output.previous_checksum).toBeNull(); + expect(JSON.parse(output.metadata)).toEqual(JSON.parse(control.metadata)); + }); + + it('a projection naming the body without its type is served projected, with exactly the columns named', async () => { + const control = await doorRow('sys_metadata'); + const { output } = await runFlow(familyReadFlow('fam_projection', 'sys_metadata', 'system', 'one', ['name', 'metadata', 'checksum'])); + expectServedLikeTheDoor(output, control, 'the run output'); + expect(Object.keys(output).sort()).toEqual(['checksum', 'metadata', 'name']); + expect(JSON.parse(output.metadata)).toEqual(JSON.parse(control.metadata)); + expect(output.checksum).toBe(control.checksum); + }); + + it("with a crypto provider registered on the engine, the node serves the provider's keyed digest, as the door does", async () => { + const keyedDigest = async (plain: string): Promise => + `hmac-sha256:${createHmac('sha256', 'pin-21519-provider-key').update(plain, 'utf8').digest('hex')}`; + ql.setCryptoProvider({ keyedDigest } as any); + const control = await doorRow('sys_metadata'); + expect(control.checksum, 'control: the door serves the provider digest').toBe(await keyedDigest(STORED_HASH)); + const { output, copy } = await runFlow(familyReadFlow('fam_provider', 'sys_metadata', 'user', 'one')); + expectServedLikeTheDoor(output, control, 'the run output'); + expect(output.checksum).toBe(control.checksum); + expect(copy.hash).toBe(control.checksum); + }); + + it('an ordinary object is read and copied exactly as stored', async () => { + await ql.insert(COPY_OBJECT.name, { title: 'plain_source', body: SENTINEL, hash: STORED_HASH }, { context: SYS }); + const def = { + name: 'plain_read', label: 'plain_read', type: 'autolaunched', runAs: 'system', + variables: [{ name: 'rec', type: 'object', isOutput: true }], + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'read', type: 'get_record', label: 'Read', config: { objectName: COPY_OBJECT.name, filter: { title: 'plain_source' }, outputVariable: 'rec' } }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [{ id: 'e1', source: 'start', target: 'read' }, { id: 'e2', source: 'read', target: 'end' }], + }; + automation.registerFlow(def.name, def as any); + const res: any = await automation.execute(def.name, { ...TRIGGER }); + expect(res.success).toBe(true); + expect(res.output.rec.body).toBe(SENTINEL); + expect(res.output.rec.hash).toBe(STORED_HASH); + }); +}); From ccd620a83b3e9e2ac6a3a75fcf4fa9b74e16db8f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 18:58:14 +0000 Subject: [PATCH 3/4] test(service-automation): pin the data door control beside the flow read node's family serve Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude --- ...-record-stored-metadata-family.integration.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/services/service-automation/src/builtin/get-record-stored-metadata-family.integration.test.ts b/packages/services/service-automation/src/builtin/get-record-stored-metadata-family.integration.test.ts index c24f4d9e626..a970307b657 100644 --- a/packages/services/service-automation/src/builtin/get-record-stored-metadata-family.integration.test.ts +++ b/packages/services/service-automation/src/builtin/get-record-stored-metadata-family.integration.test.ts @@ -185,6 +185,17 @@ describe('flow get_record serves the stored-metadata family the way the data doo expect(door.checksum, 'control: the door serves the hash keyed').toMatch(KEYED); } + it('control: the data door serves both tables with the body projected and the hash keyed', async () => { + for (const object of ['sys_metadata', 'sys_metadata_history']) { + const row = await doorRow(object); + const text = JSON.stringify(row); + expect(text.includes(SENTINEL), `${object}: the door served the stored credential`).toBe(false); + expect(text.includes(STORED_HASH), `${object}: the door served the stored hash`).toBe(false); + expect(row.checksum).toMatch(KEYED); + expect(JSON.parse(row.metadata).config.url).toBe(DS_URL); + } + }); + for (const runAs of ['system', 'user'] as const) { for (const branch of ['one', 'list'] as const) { it(`runAs:'${runAs}', ${branch === 'one' ? 'findOne' : 'find'} branch: the output and the written record carry the projected body and the door's keyed hash`, async () => { From feac30ac588cbca3d25f86e649f337b2d7dd271b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 19:22:23 +0000 Subject: [PATCH 4/4] test(service-automation): alias @objectstack/metadata-protocol to source for the package's tests check:test-source-alias refuses a new unaliased artifact import; the family-serve pins and their data door control are judged against the source in the checkout. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude --- .../services/service-automation/vitest.config.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/services/service-automation/vitest.config.ts b/packages/services/service-automation/vitest.config.ts index f70ede4e51c..9dc81cc82f6 100644 --- a/packages/services/service-automation/vitest.config.ts +++ b/packages/services/service-automation/vitest.config.ts @@ -60,6 +60,18 @@ export default defineConfig({ find: /^@objectstack\/types$/, replacement: path.resolve(__dirname, '../../types/src/index.ts'), }, + { + // [#21519] `crud-nodes.ts` serves a `get_record` read of the + // stored-metadata family through the data door's own functions, which + // `@objectstack/metadata-protocol` exports, and + // `get-record-stored-metadata-family.integration.test.ts` judges the + // node against that package's data door. Unaliased, the workspace link + // resolves to `dist/`, so the verdict on both the serve and its control + // would depend on build state rather than on the source in this + // checkout. Same anchored-regex rule as the entries above. + find: /^@objectstack\/metadata-protocol$/, + replacement: path.resolve(__dirname, '../../metadata-protocol/src/index.ts'), + }, ], }, });