Skip to content

Commit e39dd66

Browse files
baozhoutaoclaude
andauthored
fix(metadata-protocol): 冷启动跳过的 org 作用域行不再无声消失 (#6190) (#6600)
`loadMetaFromDb` 只水合 `organization_id IS NULL` 的行(ADR-0005 2026-05 修订): 对 `allowOrgOverride: true` 的类型这是设计本身,per-org overlay 由 `getMetaItem` / `getMetaItems` 按需加载;对其余类型,一条 org 作用域的行是平台根本没有 per-org 通道 的行,而此前这个跳过是完全静默的。 实测标本是 `flow`:#6283 / PR #6478 把 `allowOrgOverride` 回滚为 `false` 并在写入侧 证明了 declared=enforced(覆盖代码包交付的 flow → 403 NOT_OVERRIDABLE),但另一层 `allowRuntimeCreate: true` 按设计仍开着,而那正是 #6190 场景真正走的那条路:租户在 Studio 里新建一条 flow,`SysMetadataRepository.put` 对任何类型都写 `organization_id: this.organizationId`,于是该行仍然是 org 作用域的。它在本进程内一直 触发,重启后被这条过滤器丢掉,`kernel:ready` 绑定器读的是不带 org 的 `getMetaItems({ type: 'flow' })`,于是它再也不触发且没有任何日志—— `kernel:bootstrapped` 的 unbound 审计也看不见它(它压根没注册)。 新增 `reportUnhydratableOrgScopedRows()`:冷启动打一条聚合 warn,按类型给出计数、 抽样的 `name@org`、后果与处置建议。类型清单由 `DEFAULT_METADATA_TYPE_REGISTRY` 派生 (不是手写白名单),两个收窄谓词让健康部署读不到行也不打印;驱动无法下推谓词时退化为 多读几行,JS 侧复核两个谓词,不会产生误报。诊断自带 catch,永远不会变成 boot 失败, 也不会污染 #5897 的 storeUnavailable 判定。 加载行为未改变——这次只把缺席变响亮。这类行是否该存在(写入侧拒绝 / 强制 env-wide / 让绑定器按 org 读)是 #6190 上待裁决的契约问题。 Claude-Session: https://claude.ai/code/session_01KDU3qAuJyajAQm3GkUXdfA Co-authored-by: Claude <noreply@anthropic.com>
1 parent 82397b6 commit e39dd66

3 files changed

Lines changed: 460 additions & 0 deletions

File tree

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
---
4+
5+
冷启动跳过的 org 作用域元数据行不再无声消失
6+
7+
`loadMetaFromDb` 按 ADR-0005(2026-05 修订)只水合 `organization_id IS NULL` 的行,
8+
per-org overlay 由 `getMetaItem`/`getMetaItems` 按需加载——对注册表里
9+
`allowOrgOverride: true` 的类型(`view`/`dashboard`/`report` 等)这是设计本身。但对
10+
**其余类型**,一条 org 作用域的行是平台根本没有 per-org 通道的行,而在此之前这个跳过
11+
**完全静默**的。
12+
13+
实测标本是 `flow`:它是 `allowOrgOverride: false`(#6283 / PR #6478 按 ADR-0005:57
14+
回滚),同时 `allowRuntimeCreate: true`,所以租户在 Studio 里新建一条 flow 仍会写出
15+
`sys_metadata.organization_id = '<org>'`——运行时 `PUT /metadata/:type/:name`
16+
`resolveActiveOrganizationId` 透传给 `saveMetaItem`,而 `SysMetadataRepository.put`
17+
对任何类型都按 `organization_id: this.organizationId` 落库。该 flow 在本进程内一直正常
18+
触发(发布时写穿进了进程级 registry),下一次重启后被这条过滤器丢掉,`kernel:ready`
19+
绑定器读的是 `getMetaItems({ type: 'flow' })`(不带 org),于是它**再也不触发,且没有任何
20+
日志说它消失了**——`kernel:bootstrapped` 的 unbound 审计也看不见它(它压根没注册)。
21+
22+
现在冷启动会打一条聚合的 `warn`,按类型给出计数、抽样的 `name@org`,以及后果本身
23+
(「A 'flow' listed here will NOT bind its triggers in this process」)和处置建议。
24+
查询默认为空:两个收窄谓词(`organization_id IS NOT NULL` + 类型清单,清单由
25+
`DEFAULT_METADATA_TYPE_REGISTRY` 派生而非手写)让健康部署读不到行、也不打印任何东西;
26+
驱动若无法下推其中一个谓词,退化为多读几行而不是打出误报(JS 侧会复核两个谓词)。
27+
28+
加载行为**未改变**:这次只是把缺席变响亮。这类行到底该不该存在(写入侧拒绝 / 强制写成
29+
env-wide / 让绑定器按 org 读)是 #6190 上待裁决的契约问题。
Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,307 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #6190 — cold boot must SAY which org-scoped rows it walked past.
5+
*
6+
* ---------------------------------------------------------------------------
7+
* What survived the upstream ruling, measured against `origin/main`
8+
* ---------------------------------------------------------------------------
9+
* #6155 Q1=B → #6283 → PR #6478 rolled `flow`'s `allowOrgOverride` back to
10+
* `false` and proved declared=enforced on the write side: overlaying a
11+
* PACKAGED flow per org is now a 403 `NOT_OVERRIDABLE` before persistence.
12+
*
13+
* That closed one of the two write tiers. The other is still open BY DESIGN —
14+
* `flow` keeps `allowRuntimeCreate: true`, which is ADR-0005's "a deployment,
15+
* not an overlay" — and it is the tier the tenant scenario in #6190 actually
16+
* uses: authoring a BRAND-NEW flow in Studio. Measured on current main, that
17+
* write still lands `sys_metadata.organization_id = '<org>'`, because
18+
* `SysMetadataRepository.put` stamps `organization_id: this.organizationId`
19+
* for every type and the runtime `PUT /metadata/:type/:name` threads
20+
* `resolveActiveOrganizationId` into `saveMetaItem`:
21+
*
22+
* PROBE rows = [{"name":"org_sweep","org":"org_a"},
23+
* {"name":"platform_sweep","org":null}]
24+
* PROBE loadMetaFromDb = {"loaded":1,...} // only platform_sweep
25+
* PROBE logs during cold boot = [] // ← the defect
26+
* PROBE getMetaItems({type:flow}) no org = ["platform_sweep"]
27+
*
28+
* So the symptom #6190 filed — an org-scoped flow that fires all day and never
29+
* fires again after a restart — is still reachable, and the third line is why
30+
* nobody can tell: the skip was completely silent. `kernel:bootstrapped`'s
31+
* unbound audit cannot report it either, because the flow was never registered.
32+
*
33+
* This file pins the loud half. It does NOT change what boot loads — whether
34+
* such a row should exist at all (refuse the write / force it env-wide / teach
35+
* the binder to read per-org) is a contract ruling recorded on the issue.
36+
*
37+
* ---------------------------------------------------------------------------
38+
* Reverse verification, direction predicted BEFORE running
39+
* ---------------------------------------------------------------------------
40+
* Ordinary red, with a deliberately green control. Deleting the
41+
* `reportUnhydratableOrgScopedRows()` call from `loadMetaFromDb` turns the
42+
* three "warns" cases red AND the probe-failure case with them — that one
43+
* asserts the second `find` happened at all, so it goes red counting calls
44+
* rather than reading a message. The two silence cases and the registry
45+
* premise pin stay green: they assert an ABSENCE of output, which a deleted
46+
* producer trivially satisfies. Predicted 4 red / 3 green; measured 4 red /
47+
* 3 green, and the reds fail in the shape that names the defect:
48+
*
49+
* AssertionError: no [metadata_org_scoped_unhydrated] line in: []
50+
* AssertionError: expected 1 to be greater than 1
51+
*
52+
* — the silent cold boot of the PROBE output above, reproduced on demand.
53+
*
54+
* The silence cases are not slack: a "fix" that warned about every skipped
55+
* org-scoped row would pass the red half and fail there, and that shape is
56+
* wrong — for `view` and friends (`allowOrgOverride: true`) the skip IS the
57+
* ADR-0005 design, loaded on demand by `getMetaItem`/`getMetaItems`.
58+
*/
59+
import { describe, expect, it, vi } from 'vitest';
60+
// [#5619] The producer's OWN write-verb dispatch decisions (#4550 delete /
61+
// #5480 update). From `@objectstack/metadata-core`, never `@objectstack/objectql`
62+
// — objectql depends on THIS package, so that import would close a cycle.
63+
import { assertEngineDeleteDispatch, assertEngineUpdateDispatch } from '@objectstack/metadata-core';
64+
import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel';
65+
import { ObjectStackProtocolImplementation } from './protocol.js';
66+
67+
interface Row {
68+
id: string;
69+
type: string;
70+
name: string;
71+
organization_id: string | null;
72+
state: string;
73+
metadata: string;
74+
}
75+
76+
/**
77+
* A stub that honours the two predicates the audit query relies on
78+
* (`organization_id: { $null: false }` and `type: { $in: [...] }`) plus the
79+
* plain equality the boot query uses. `driver-memory`, `driver-sql` and
80+
* `driver-mongodb` all lower `$null`; this mirrors that, and the
81+
* dropped-predicate case gets its own stub below.
82+
*/
83+
function matchesWhere(r: Row, where: Record<string, unknown>): boolean {
84+
for (const [k, v] of Object.entries(where)) {
85+
if (v === undefined) continue;
86+
const actual = (r as any)[k];
87+
if (v !== null && typeof v === 'object') {
88+
const ops = v as Record<string, unknown>;
89+
if ('$null' in ops) {
90+
const isNull = actual === null || actual === undefined;
91+
if (isNull !== ops.$null) return false;
92+
}
93+
if ('$in' in ops) {
94+
if (!(ops.$in as unknown[]).includes(actual)) return false;
95+
}
96+
continue;
97+
}
98+
if (actual !== v) return false;
99+
}
100+
return true;
101+
}
102+
103+
function makeEngine(rows: Row[], opts: { dropPredicates?: boolean } = {}) {
104+
const registered: Array<{ type: string; name: string }> = [];
105+
const engine: any = {
106+
async find(_table: string, q: { where: Record<string, unknown> }) {
107+
// A driver that cannot lower `$null`/`$in` hands back a superset —
108+
// the exact degradation the JS re-check exists for.
109+
if (opts.dropPredicates) return rows.filter((r) => r.state === q.where.state);
110+
return rows.filter((r) => matchesWhere(r, q.where));
111+
},
112+
async findOne() { return null; },
113+
async insert() { return { id: 'x' }; },
114+
async update(_t: string, data: Record<string, unknown>, o?: Record<string, unknown>) {
115+
assertEngineUpdateDispatch(data, o);
116+
return { id: null };
117+
},
118+
async delete(_t: string, o?: Record<string, unknown>) {
119+
assertEngineDeleteDispatch(o);
120+
return { deleted: 0 };
121+
},
122+
registry: {
123+
registerItem: (type: string, item: any) => { registered.push({ type, name: item?.name }); },
124+
registerObject: (item: any) => { registered.push({ type: 'object', name: item?.name }); },
125+
listItems: () => [],
126+
getItem: () => undefined,
127+
getArtifactItem: () => undefined,
128+
isPackageDisabled: () => false,
129+
},
130+
};
131+
return { engine, registered };
132+
}
133+
134+
const flowBody = (name: string) => JSON.stringify({
135+
name,
136+
label: 'Escalate overdue tasks',
137+
type: 'record_change',
138+
status: 'active',
139+
nodes: [
140+
{ id: 'start', type: 'start', label: 'Start', config: { objectName: 'task', triggerType: 'record-after-update' } },
141+
{ id: 'end', type: 'end', label: 'End' },
142+
],
143+
edges: [{ id: 'e1', source: 'start', target: 'end' }],
144+
});
145+
146+
const viewBody = (name: string) => JSON.stringify({
147+
name, label: 'Overdue', object: 'task', columns: [{ field: 'name', label: 'Name' }],
148+
});
149+
150+
const row = (over: Partial<Row> & Pick<Row, 'type' | 'name'>): Row => ({
151+
id: `r_${over.type}_${over.name}_${over.organization_id ?? 'env'}`,
152+
organization_id: null,
153+
state: 'active',
154+
metadata: over.type === 'view' ? viewBody(over.name) : flowBody(over.name),
155+
...over,
156+
});
157+
158+
/** One `console.warn` capture, returned as the lines the boot printed. */
159+
async function bootAndCapture(engine: any): Promise<{ result: any; warns: string[] }> {
160+
const warns: string[] = [];
161+
const spy = vi.spyOn(console, 'warn').mockImplementation((...a: unknown[]) => {
162+
warns.push(a.map(String).join(' '));
163+
});
164+
try {
165+
const protocol = new ObjectStackProtocolImplementation(engine) as any;
166+
const result = await protocol.loadMetaFromDb();
167+
return { result, warns };
168+
} finally {
169+
spy.mockRestore();
170+
}
171+
}
172+
173+
const AUDIT = '[metadata_org_scoped_unhydrated]';
174+
175+
describe('#6190 — cold boot names the org-scoped rows it cannot hydrate', () => {
176+
// ── the premise, read from the registry rather than restated ──────────
177+
178+
it('flow is the specimen: not per-org overridable, still runtime-creatable', () => {
179+
// Both halves matter. `allowOrgOverride: false` (#6283 / PR #6478) is
180+
// why an org-scoped flow row can never be read back as an overlay;
181+
// `allowRuntimeCreate: true` is why one can still be WRITTEN. If a
182+
// later ruling closes the second flag, this case goes red and the
183+
// whole file should be re-read, not repaired.
184+
expect(DEFAULT_METADATA_TYPE_REGISTRY.find((e) => e.type === 'flow')).toMatchObject({
185+
allowOrgOverride: false,
186+
allowRuntimeCreate: true,
187+
});
188+
// The control specimen's flag, likewise read and not assumed.
189+
expect(DEFAULT_METADATA_TYPE_REGISTRY.find((e) => e.type === 'view')).toMatchObject({
190+
allowOrgOverride: true,
191+
});
192+
});
193+
194+
// ── the acceptance criterion: the absence is loud ─────────────────────
195+
196+
it('warns, naming type/name/org, when an org-scoped FLOW row is skipped', async () => {
197+
const { engine } = makeEngine([
198+
row({ type: 'flow', name: 'org_sweep', organization_id: 'org_a' }),
199+
row({ type: 'flow', name: 'platform_sweep' }),
200+
]);
201+
202+
const { result, warns } = await bootAndCapture(engine);
203+
204+
// Hydration itself is UNCHANGED — this issue's fix is the log, not a
205+
// load. The org row stays out of the process-wide registry.
206+
expect(result).toMatchObject({ loaded: 1, errors: 0, invalid: 0, storeUnavailable: false });
207+
208+
const line = warns.find((w) => w.includes(AUDIT));
209+
expect(line, `no ${AUDIT} line in: ${JSON.stringify(warns)}`).toBeDefined();
210+
expect(line).toContain('flow×1');
211+
expect(line).toContain('org_sweep@org_a');
212+
// The consequence, not just the fact — an operator reading this must
213+
// learn why an automation stopped firing after a restart.
214+
expect(line).toContain('bind its triggers');
215+
// And it must not name the row that DID load.
216+
expect(line).not.toContain('platform_sweep');
217+
});
218+
219+
it('counts every row but samples the names, so a thousand rows cost one line', async () => {
220+
const rows: Row[] = [];
221+
for (let i = 0; i < 9; i++) {
222+
rows.push(row({ type: 'flow', name: `sweep_${i}`, organization_id: `org_${i}` }));
223+
}
224+
const { engine } = makeEngine(rows);
225+
226+
const { warns } = await bootAndCapture(engine);
227+
228+
const audit = warns.filter((w) => w.includes(AUDIT));
229+
expect(audit).toHaveLength(1);
230+
expect(audit[0]).toContain('flow×9');
231+
expect(audit[0]).toContain('+4 more');
232+
});
233+
234+
it('still warns when the driver drops the predicates and returns a superset', async () => {
235+
// `driver-memory` historically dropped `is_null` outright (see its
236+
// `memory-filter-ast-vocabulary.test.ts`), so the audit re-checks both
237+
// predicates in JS. A superset must produce the SAME line — not a
238+
// false accusation against the env-wide and view rows in it.
239+
const { engine } = makeEngine([
240+
row({ type: 'flow', name: 'org_sweep', organization_id: 'org_a' }),
241+
row({ type: 'flow', name: 'platform_sweep' }),
242+
row({ type: 'view', name: 'org_grid', organization_id: 'org_a' }),
243+
], { dropPredicates: true });
244+
245+
const { warns } = await bootAndCapture(engine);
246+
247+
const line = warns.find((w) => w.includes(AUDIT));
248+
expect(line).toBeDefined();
249+
expect(line).toContain('org_sweep@org_a');
250+
expect(line).not.toContain('platform_sweep');
251+
expect(line).not.toContain('org_grid');
252+
});
253+
254+
// ── the silence that is the design, not a miss ────────────────────────
255+
256+
it('says NOTHING about an org-scoped VIEW — that skip is ADR-0005 working', async () => {
257+
// `view` is `allowOrgOverride: true`: the row is a per-org overlay,
258+
// deliberately not hydrated process-wide and served on demand by
259+
// `getMetaItem`/`getMetaItems({ organizationId })`. Warning here would
260+
// print a line at every boot of every healthy tenant.
261+
const { engine } = makeEngine([
262+
row({ type: 'view', name: 'org_grid', organization_id: 'org_a' }),
263+
row({ type: 'view', name: 'platform_grid' }),
264+
]);
265+
266+
const { result, warns } = await bootAndCapture(engine);
267+
268+
expect(result.loaded).toBe(1);
269+
expect(warns.filter((w) => w.includes(AUDIT))).toEqual([]);
270+
});
271+
272+
it('says nothing at all on a store with no org-scoped rows', async () => {
273+
const { engine } = makeEngine([
274+
row({ type: 'flow', name: 'platform_sweep' }),
275+
row({ type: 'view', name: 'platform_grid' }),
276+
]);
277+
278+
const { result, warns } = await bootAndCapture(engine);
279+
280+
expect(result.loaded).toBe(2);
281+
expect(warns.filter((w) => w.includes(AUDIT))).toEqual([]);
282+
});
283+
284+
// ── the diagnostic can never become the outage ────────────────────────
285+
286+
it('a failing audit probe does not change the boot verdict', async () => {
287+
// #5897 draws a hard line between "the store had no rows" and "the
288+
// store could not be read". A best-effort extra probe must not be able
289+
// to cross it: the first `find` succeeds, so this boot is HEALTHY, and
290+
// `storeUnavailable` must stay false even though the probe threw.
291+
let call = 0;
292+
const { engine } = makeEngine([row({ type: 'flow', name: 'platform_sweep' })]);
293+
const inner = engine.find;
294+
engine.find = async (t: string, q: any) => {
295+
call++;
296+
if (call > 1) throw new Error('probe exploded');
297+
return inner(t, q);
298+
};
299+
300+
const { result, warns } = await bootAndCapture(engine);
301+
302+
expect(call).toBeGreaterThan(1);
303+
expect(result).toMatchObject({ loaded: 1, errors: 0, storeUnavailable: false });
304+
expect(warns.filter((w) => w.includes('DB hydration skipped'))).toEqual([]);
305+
expect(warns.filter((w) => w.includes(AUDIT))).toEqual([]);
306+
});
307+
});

0 commit comments

Comments
 (0)