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
13 changes: 13 additions & 0 deletions .changeset/21519-flow-read-node-family-serve.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions packages/services/service-automation/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*"
Expand Down
82 changes: 80 additions & 2 deletions packages/services/service-automation/src/builtin/crud-nodes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<A>(
data: IDataEngine,
objectName: string,
fields: string[] | undefined,
read: (fields: string[] | undefined) => Promise<A>,
): Promise<A> {
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).
Expand Down Expand Up @@ -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
Expand 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,
Expand Down
Loading
Loading