Skip to content

Commit 7196447

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-21620-container-named-after-object
2 parents 5573152 + a4f0cb0 commit 7196447

6 files changed

Lines changed: 380 additions & 2 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/service-automation': patch
3+
---
4+
5+
fix(service-automation): a flow's `get_record` node that reads the stored-metadata tables is served what the generic data door serves (#21519)
6+
7+
Clause-②: no
8+
9+
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.
10+
11+
**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.
12+
13+
**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.

‎packages/services/service-automation/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
"@objectstack/core": "workspace:*",
2929
"@objectstack/formula": "workspace:*",
3030
"@objectstack/metadata-core": "workspace:*",
31+
"@objectstack/metadata-protocol": "workspace:*",
3132
"@objectstack/platform-objects": "workspace:*",
3233
"@objectstack/spec": "workspace:*",
3334
"@objectstack/types": "workspace:*"

‎packages/services/service-automation/src/builtin/crud-nodes.ts‎

Lines changed: 80 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,14 @@ import type {
1818
import type { AutomationContext, IDataEngine } from '@objectstack/spec/contracts';
1919
import type { DroppedFieldsEvent } from '@objectstack/spec/data';
2020
import { StandardErrorCode } from '@objectstack/spec/api';
21+
import { isStoredMetadataBodyObject } from '@objectstack/spec/kernel';
22+
import {
23+
ephemeralStoredHashDigest,
24+
redactStoredMetadataRows,
25+
serveStoredMetadataHashColumnRows,
26+
storedMetadataBodyProjection,
27+
type StoredHashDigest,
28+
} from '@objectstack/metadata-protocol';
2129
import type { AutomationEngine } from '../engine.js';
2230
import { interpolate, interpolateFilter, type VariableMap } from './template.js';
2331
import { refuseNode } from '../guard-refusal.js';
@@ -196,6 +204,72 @@ function resolveFieldValues(
196204
return out;
197205
}
198206

207+
/**
208+
* [#21519] The keyed digest a stored content hash is served under: the data
209+
* engine's registered crypto provider's (`getKeyedDigest`, read at the moment
210+
* of use, because a host registers the provider after the kernel starts), else
211+
* `@objectstack/metadata-protocol`'s process-scoped ephemeral key. The same two
212+
* sources, in the same order, the generic data door reads, so the hash this
213+
* node serves is the hash the door serves for the same row: one key, never a
214+
* second one.
215+
*/
216+
function storedHashDigestOf(data: IDataEngine): StoredHashDigest {
217+
const accessor = (data as { getKeyedDigest?: () => StoredHashDigest | undefined }).getKeyedDigest;
218+
const provider = typeof accessor === 'function' ? accessor.call(data) : undefined;
219+
return provider ?? ephemeralStoredHashDigest;
220+
}
221+
222+
/**
223+
* [#21519] Run one `get_record` read and serve its answer the way the generic
224+
* data door serves the same rows.
225+
*
226+
* The stored-metadata-body family (`sys_metadata` / `sys_metadata_history`,
227+
* judged by the family's own predicate, `isStoredMetadataBodyObject`) holds a
228+
* stored metadata body, credential material included, and a content hash over
229+
* that whole body. Every door that serves either serves the body as its type's
230+
* read projection and the hash in keyed form. A flow's read node is such a
231+
* door: what it reads becomes the run's output, a flow caller is handed that
232+
* back, and any record the flow writes from it is a copy. Both run identities
233+
* are served the same way: `runAs: 'system'` reads elevated, so the engine
234+
* cannot tell this read from the platform's own internal readers, which need
235+
* the stored form. The rule is therefore applied here, at the node.
236+
*
237+
* Built only from the door's own functions (`@objectstack/metadata-protocol`),
238+
* never a copy: the projection (`storedMetadataBodyProjection`, which adds the
239+
* `type` column a body-only projection needs to choose its redactor; it is
240+
* taken back off the served rows), the redactor (`redactStoredMetadataRows`)
241+
* and the keyed serve (`serveStoredMetadataHashColumnRows`, under
242+
* {@link storedHashDigestOf}). Any other object is read and returned as is.
243+
*/
244+
async function serveFamilyRead<A>(
245+
data: IDataEngine,
246+
objectName: string,
247+
fields: string[] | undefined,
248+
read: (fields: string[] | undefined) => Promise<A>,
249+
): Promise<A> {
250+
if (!isStoredMetadataBodyObject(objectName)) return read(fields);
251+
const projection = storedMetadataBodyProjection(objectName, fields);
252+
const answer = await read(projection.fields as string[] | undefined);
253+
const opts = { dropType: projection.addedType };
254+
const digest = storedHashDigestOf(data);
255+
if (Array.isArray(answer)) {
256+
return (await serveStoredMetadataHashColumnRows(
257+
objectName,
258+
redactStoredMetadataRows(objectName, answer, opts),
259+
digest,
260+
)) as A;
261+
}
262+
if (answer !== null && typeof answer === 'object') {
263+
const [served] = await serveStoredMetadataHashColumnRows(
264+
objectName,
265+
redactStoredMetadataRows(objectName, [answer], opts),
266+
digest,
267+
);
268+
return served as A;
269+
}
270+
return answer;
271+
}
272+
199273
/**
200274
* CRUD built-in nodes — `get_record` / `create_record` / `update_record` /
201275
* `delete_record`, wired to the runtime data layer (ObjectQL / IDataEngine).
@@ -288,9 +362,12 @@ export function registerCrudNodes(engine: AutomationEngine, ctx: PluginContext):
288362
// #1888 — honor flow.runAs: read under the run's effective identity
289363
// (system → RLS-bypassing; user → the triggering user).
290364
const dataCtx = resolveRunDataContext(context);
365+
// [#21519] A read of the stored-metadata family is served the
366+
// data door's way under either identity: body projected, hash keyed.
291367
try {
292368
if (limit && limit > 1) {
293-
const records = await data.find(objectName, { where: filter, fields, limit, context: dataCtx });
369+
const records = await serveFamilyRead(data, objectName, fields, (projection) =>
370+
data.find(objectName, { where: filter, fields: projection, limit, context: dataCtx }));
294371
if (outputVariable) variables.set(outputVariable, records);
295372
// #4354 — the `selected` half of the broken-sweep signal:
296373
// this is the count that made #4347 diagnosable at all
@@ -301,7 +378,8 @@ export function registerCrudNodes(engine: AutomationEngine, ctx: PluginContext):
301378
metrics: { selected: Array.isArray(records) ? records.length : 0 },
302379
};
303380
}
304-
const record = await data.findOne(objectName, { where: filter, fields, context: dataCtx });
381+
const record = await serveFamilyRead(data, objectName, fields, (projection) =>
382+
data.findOne(objectName, { where: filter, fields: projection, context: dataCtx }));
305383
if (outputVariable) variables.set(outputVariable, record);
306384
return {
307385
success: true,

0 commit comments

Comments
 (0)