Skip to content

Commit 375ded2

Browse files
committed
fix(spec): FlowFunctionEntrySchema 收下 lowered declaration,声明形写手重新扛得住 objectstack build (#4976)
`lowerCallables` 自 #4396 起会把声明形 `functions` 条目降级成 `{ handler: '<ref>', effect: 'writes' }` —— 保留声明、只把可调用体换成字符串 ref。同一次改动没有同步扩 union,于是 CLI 亲手产出的形状被它自己必须通过的 schema 拒收,`objectstack build` 报 `invalid_union: Invalid input`,路径止步于 `functions`,不点名键、不点名条目、不给原因。 补上第四个 union 成员:lowered declaration。它由 `FlowFunctionDeclarationSchema.extend({ handler })` 派生而来,而不是在旁边重抄 一份 —— 两者只差一个字段,所以严格性、surface 名、别名表与 `` `efect` → `effect` `` 处方原样随行,声明形将来加键也自动带过去。别名区未 触碰(不新增 `strictObject` 注册)。 `effect` 在此保持 optional + 默认值:走 `defineStack` 的路径会先把 `'pure'` 默认值落实,但 `{ strict: false }` 会跳过那次 parse,要求必填就会把同一个 `invalid_union` 还给那条路径。 runtime 半边本就正确,未改一行:`normalizeFlowFunctionEntry` 对两种 lowered 形状都返回 `undefined`(都不携带可调用体),而 `mergeRuntimeModule` 在任何 collector 之前就把 sidecar 模块的函数重新挂回 JSON 携带的声明上,所以 `effect` 在构建路径上完整抵达 `collectBundleFunctionEntries`。 两半之间补上跨界 pin:走真实流水线(`defineStack` → `normalizeStackInput` → `lowerCallables` → parse),而不是手写一份「以为 lowering 会产出什么」的样例 —— 正是这条从未有人跨过的边界,让两侧各自全绿而 build 死在接缝上。 行为面唯一变化:手写 `{ handler: 'someName' }` 由拒收变为接受。该拒收无法与本 成员共存,也本不该存在 —— 裸字符串条目(`functions: { foo: 'foo' }`)自 #4343 起就被接受并附带「注册不到任何东西」的说明,只拒 record 拼法而放行 string 拼法 是同一个契约的两种方言。两者失败方式一致且响亮:execute 时 `no function named '…' is registered`(#1870)。 showcase 换回诚实拼法(`{ handler: sweepProjectHealth, effect: 'writes' }`), 并把「钉死裸形」的守卫倒转为「唯一真正写数据的条目必须声明」—— 那才是值得守的 事实。相邻缺口(数组形 `functions: [{ name, handler }]` 同样无法往返)另立 #6238,不在本 PR 范围。
1 parent 773f80a commit 375ded2

6 files changed

Lines changed: 323 additions & 59 deletions

File tree

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
fix(spec): `functions: { fn: { handler, effect: 'writes' } }` survives `objectstack build` (#4976)
6+
7+
`FlowFunctionEntrySchema` gains a fourth union member — the **lowered
8+
declaration**, a `functions` entry whose `handler` has been replaced by the
9+
string ref `objectstack build` emits:
10+
11+
```
12+
functions: {
13+
sweepProjectHealth: { handler: 'sweepProjectHealth', effect: 'writes' },
14+
}
15+
```
16+
17+
Nothing an author writes changes. This shape is produced by the CLI, not typed
18+
by a person: `lowerCallables` replaces every inline callable with a serialisable
19+
ref before the stack is parsed (it must — `z.function()` wraps callables and
20+
would break the ref mapping), and since #4396 it keeps the declaration beside
21+
the ref so what a function said about itself survives into the artifact. The
22+
union was not extended in that change, so the artifact it started emitting was
23+
rejected by the very schema it had to pass:
24+
25+
```
26+
✗ Validation failed
27+
28+
functions:
29+
✗ functions
30+
invalid_union: Invalid input
31+
```
32+
33+
Loading from source was unaffected — `objectstack dev`, `objectstack validate`
34+
and the test suite all passed — so the failure appeared only at build, on the
35+
one spelling the platform asks writers to use. That is the same asymmetry #4343
36+
fixed for the bare handler ref, one shape over.
37+
38+
**Why this was worse than a failed build.** `effect: 'writes'` exists so a
39+
function that writes is not counted as having written nothing (#4396, #4354): a
40+
`script` step reports no record metrics *because* flow functions are
41+
contractually pure, and a declared writer instead reports `unmeasuredEffect` so
42+
the run's broken-sweep query (`selected > 0 AND acted = 0 AND unmeasured = 0`)
43+
stays off it. The error above names no key, no entry and no reason, so the
44+
practical repair an author reaches for is deleting the declaration — shipping an
45+
undeclared writer, which is exactly the state it exists to prevent, recorded
46+
permanently in `sys_automation_run`.
47+
48+
**One behaviour change worth stating.** `{ handler: 'someName' }` written by
49+
hand now parses where it used to be rejected as "handler is not callable". The
50+
rejection could not survive this member and should not have: a bare string entry
51+
(`functions: { foo: 'foo' }`) has been accepted since #4343 with the caveat that
52+
it registers nothing, so refusing the record spelling of the same mistake while
53+
accepting the string spelling was two dialects for one contract. Both fail the
54+
same way, loudly, at execute: `no function named '…' is registered` (#1870).
55+
Everything else stays strict — the lowered member is *derived* from the authored
56+
declaration rather than re-typed beside it, so `{ handler: 'fn', efect: 'writes' }`
57+
still raises the named surface and the `` `efect` → `effect` `` prescription, an
58+
unknown `effect` value is still refused, and an empty ref is still not a name.
59+
60+
**Runtime is unchanged and was already correct.** `normalizeFlowFunctionEntry`
61+
returns `undefined` for a lowered entry in both its shapes, because neither
62+
carries a callable; `mergeRuntimeModule` re-attaches the sidecar module's
63+
function to the declaration the JSON carried *before* any collector runs, so
64+
`effect` reaches `collectBundleFunctionEntries` intact on the built path.
65+
66+
The two halves are now pinned against each other by a round-trip test that
67+
drives the real pipeline (`defineStack``normalizeStackInput`
68+
`lowerCallables` → parse) instead of a hand-written sample of what the lowering
69+
is believed to emit — the crossing neither side previously made, which is why
70+
both stayed green while the build failed on the join.

examples/app-showcase/objectstack.config.ts

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -214,23 +214,21 @@ export default defineStack({
214214
// `sweepProjectHealth` — the handler `HealthSweepJob` names — lives here too.
215215
// It is the case the pure contract does not cover: a nightly sweep has no
216216
// downstream declarative node to persist for it, so it writes over an engine
217-
// handle captured at `onEnable`.
217+
// handle captured at `onEnable`. That is why it is spelled the DECLARED way
218+
// (#4396) — an undeclared writer is counted as having written nothing, which
219+
// is indistinguishable from the broken sweep #4354 exists to detect.
218220
//
219-
// ⚠️ Do NOT rewrite this as `{ handler: sweepProjectHealth, effect: 'writes' }`.
220-
// That declared form (#4396) is the honest spelling for a writer and is what
221-
// this entry wants — but it cannot survive `objectstack build` today: the CLI
222-
// lowers it to `{ handler: 'sweepProjectHealth', effect: 'writes' }` and
223-
// `FlowFunctionEntrySchema` accepts a bare callable, a declaration whose
224-
// `handler` is a CALLABLE, or a bare string ref — never a declaration whose
225-
// handler has been lowered to a string. `pnpm build` fails with
226-
// `functions: invalid_union`. Filed as #4976; switch back once it lands.
227-
// Nothing is lost at runtime meanwhile: `effect` has exactly one consumer,
228-
// the `script` node's `unmeasuredEffect` metric, and the JOB path drops it
229-
// (`collectBundleFunctions` keeps only the handler).
221+
// This entry authored the bare form until #4976, not because the bare form was
222+
// right but because the declared one could not survive `objectstack build`:
223+
// the CLI lowers it to `{ handler: 'sweepProjectHealth', effect: 'writes' }`
224+
// and `FlowFunctionEntrySchema` had no member for a declaration whose handler
225+
// is a ref, so `pnpm build` failed with `functions: invalid_union`. #4976
226+
// added that member; the honest spelling is back, and this app is the
227+
// end-to-end proof that it builds.
230228
functions: {
231229
summarizeCompletedTask: ({ input }: { input: Record<string, unknown> }) =>
232230
`Completed: ${String(input.title ?? 'task')} (priority ${String(input.priority ?? 'normal')}).`,
233-
sweepProjectHealth,
231+
sweepProjectHealth: { handler: sweepProjectHealth, effect: 'writes' as const },
234232
},
235233
jobs: allJobs,
236234
emailTemplates: allEmails,

examples/app-showcase/test/inert-wirings.test.ts

Lines changed: 15 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -96,24 +96,22 @@ describe('declarative jobs resolve their handler (#4774 ①)', () => {
9696
});
9797
}
9898

99-
it('every functions entry is authored in a form `objectstack build` can carry', () => {
100-
// `objectstack build` LOWERS each inline callable to a serialisable string
101-
// ref before the stack is parsed, and `FlowFunctionEntrySchema` accepts a
102-
// bare callable, a declaration whose `handler` is a CALLABLE, or a bare
103-
// string ref — but NOT a declaration whose handler has been lowered to a
104-
// string, which is exactly what the CLI emits for the declared form
105-
// (`{ handler: fn, effect: 'writes' }`, #4396). So authoring the declared
106-
// form here builds green from source and fails `pnpm build` with
107-
// `functions: invalid_union`. Filed as #4976.
99+
it('the sweep DECLARES that it writes — an undeclared writer reads as a broken sweep', () => {
100+
// The inverse of the guard that stood here until #4976. That one pinned
101+
// every entry to the BARE form, because the declared spelling could not
102+
// survive `objectstack build`: the CLI lowers it to
103+
// `{ handler: 'sweepProjectHealth', effect: 'writes' }` and
104+
// `FlowFunctionEntrySchema` had no member for a declaration whose handler
105+
// is a ref, so the reference app was pinned to the dishonest spelling to
106+
// keep `pnpm build` green.
108107
//
109-
// Pinning the bare form keeps that failure out of the reference app until
110-
// the schema accepts the lowered declaration. Delete this guard — don't
111-
// work around it — when #4976 lands.
112-
const declared = functionNames().filter((name) => typeof functionEntry(name) !== 'function');
113-
expect(
114-
declared,
115-
`declared-form functions entry/entries cannot survive \`objectstack build\` (#4976): ${declared.join(', ')}`,
116-
).toEqual([]);
108+
// #4976 added that member, so the pin inverts rather than disappears — the
109+
// thing worth guarding was never "bare", it was that the one entry which
110+
// genuinely writes says so. `sweepProjectHealth` is a nightly job with no
111+
// downstream declarative node to count its writes, so undeclared it reports
112+
// `selected: N, acted: 0` — indistinguishable from the broken sweep #4354
113+
// exists to detect, permanently, in `sys_automation_run`.
114+
expect(functionEntry('sweepProjectHealth')).toMatchObject({ effect: 'writes' });
117115
});
118116
});
119117

packages/cli/src/utils/lower-callables.test.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { describe, it, expect } from 'vitest';
4+
import { defineStack, normalizeStackInput, ObjectStackDefinitionSchema } from '@objectstack/spec';
5+
import { FlowFunctionEntrySchema } from '@objectstack/spec/automation';
46
import { lowerCallables } from './lower-callables.js';
57

68
// ── #3855: `target` is the only handler slot ────────────────────────────────
@@ -124,3 +126,95 @@ describe('lowerCallables — declared `functions` entries (#4396)', () => {
124126
expect(entry).toEqual({ name: 'syncBilling', handler: 'syncBilling', effect: 'writes' });
125127
});
126128
});
129+
130+
// ── #4976: the lowering and the schema must round-trip ──────────────────────
131+
//
132+
// Every test above stops at the shape `lowerCallables` EMITS, and every spec
133+
// test parses only shapes an author WRITES. Nothing crossed the boundary — so
134+
// when #4396 taught this step to keep a declared entry's declaration, and the
135+
// union in `flow-function.zod.ts` was not extended in the same change, both
136+
// halves stayed green and `objectstack build` failed on the join with
137+
// `invalid_union: Invalid input` and no path past `functions`.
138+
//
139+
// These tests are that boundary, driven through the real build pipeline
140+
// (`defineStack` → `normalizeStackInput` → `lowerCallables` → parse) rather
141+
// than a hand-written sample of what the lowering is believed to emit: a
142+
// hand-written sample is a third copy of the truth and drifts exactly the way
143+
// the two halves already did.
144+
//
145+
// SCOPE: the map form. The ARRAY form (`functions: [{ name, handler }]`) does
146+
// not round-trip either — in both its bare and declared spellings, since #4343
147+
// and #4976 each only ever touched the map — and its member lives in
148+
// `stack.zod.ts` rather than in `FlowFunctionEntrySchema`. Filed as #6238;
149+
// extend the parametrisation below when it lands.
150+
describe('lowerCallables → the spec parses what it emits (#4976)', () => {
151+
const base = {
152+
manifest: { id: 'com.example.demo', name: 'demo', version: '1.0.0', type: 'app' as const },
153+
};
154+
155+
/** Exactly what `objectstack compile` does, in the order it does it. */
156+
const buildPipeline = (functions: Record<string, unknown>) => {
157+
const stack = defineStack({ ...base, functions } as never);
158+
const normalized = normalizeStackInput(stack as Record<string, unknown>);
159+
return lowerCallables(normalized);
160+
};
161+
162+
const cases: Array<[label: string, functions: Record<string, unknown>]> = [
163+
['a bare handler', { scoreLead: () => ({ score: 1 }) }],
164+
['a declared writer', { syncBilling: { handler: () => ({ ok: true }), effect: 'writes' } }],
165+
['a declaration that states the pure default', { scoreLead: { handler: () => ({ score: 1 }), effect: 'pure' } }],
166+
['a declaration that states nothing', { scoreLead: { handler: () => ({ score: 1 }) } }],
167+
['both spellings side by side', {
168+
scoreLead: () => ({ score: 1 }),
169+
syncBilling: { handler: () => ({ ok: true }), effect: 'writes' },
170+
}],
171+
];
172+
173+
for (const [label, functions] of cases) {
174+
it(`parses every entry it emits for ${label}`, () => {
175+
const emitted = (buildPipeline(functions).lowered as {
176+
functions: Record<string, unknown>;
177+
}).functions;
178+
179+
for (const [name, entry] of Object.entries(emitted)) {
180+
const result = FlowFunctionEntrySchema.safeParse(entry);
181+
expect(
182+
result.success,
183+
`emitted entry '${name}' (${JSON.stringify(entry)}) is not a shape FlowFunctionEntrySchema accepts: `
184+
+ JSON.stringify(result.success ? [] : result.error.issues),
185+
).toBe(true);
186+
}
187+
});
188+
189+
it(`parses the whole lowered stack for ${label}`, () => {
190+
// The assertion the build itself makes (`compile.ts` step 3). Parsing the
191+
// entries one by one can pass while the stack does not — `functions` is a
192+
// union of a record and an array, so a rejected entry surfaces only as
193+
// `invalid_union` on the parent, which is precisely the unreadable error
194+
// the issue is about.
195+
const { lowered } = buildPipeline(functions);
196+
const result = ObjectStackDefinitionSchema.safeParse(lowered);
197+
expect(
198+
result.success,
199+
`lowered stack rejected: ${JSON.stringify(result.success ? [] : result.error.issues)}`,
200+
).toBe(true);
201+
});
202+
}
203+
204+
it('carries the declaration into the artifact, not just past the parse', () => {
205+
// Surviving the parse is worthless if `effect` is dropped on the way — that
206+
// would re-create #4396's silent un-declaring with a green build. The
207+
// artifact must still SAY 'writes', because that string is what
208+
// `mergeRuntimeModule` re-attaches the module's callable to at boot.
209+
const { lowered } = buildPipeline({
210+
syncBilling: { handler: () => ({ ok: true }), effect: 'writes' },
211+
});
212+
const parsed = ObjectStackDefinitionSchema.parse(lowered) as {
213+
functions: Record<string, { handler: string; effect: string }>;
214+
};
215+
expect(parsed.functions.syncBilling).toEqual({ handler: 'syncBilling', effect: 'writes' });
216+
// And it is JSON — the artifact is `objectstack.json`, not a module.
217+
expect(JSON.parse(JSON.stringify(lowered)).functions.syncBilling)
218+
.toEqual({ handler: 'syncBilling', effect: 'writes' });
219+
});
220+
});

packages/spec/src/automation/flow-function.test.ts

Lines changed: 62 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,28 @@ describe('FlowFunctionEntrySchema', () => {
6666
expect(FlowFunctionEntrySchema.safeParse({ handler: () => 1, effect: 'writes' }).success).toBe(true);
6767
});
6868

69-
it('rejects a declaration whose handler is not callable', () => {
70-
expect(FlowFunctionEntrySchema.safeParse({ handler: 'scoreLead' }).success).toBe(false);
69+
it('rejects a declaration whose handler is neither a callable nor a ref', () => {
70+
// Narrowed in #4976, and the narrowing is the point rather than a
71+
// concession. This assertion used to read `{ handler: 'scoreLead' }` —
72+
// "handler is not callable" — but a string handler is exactly what
73+
// `objectstack build` emits for a declared entry, so the union now accepts
74+
// it (see the lowered-declaration cases below). What survives is the
75+
// verdict on a handler that is neither: no callable, no name.
76+
expect(FlowFunctionEntrySchema.safeParse({ handler: 42 }).success).toBe(false);
77+
expect(FlowFunctionEntrySchema.safeParse({ handler: '' }).success).toBe(false);
78+
expect(FlowFunctionEntrySchema.safeParse({ effect: 'writes' }).success).toBe(false);
79+
});
80+
81+
it('accepts a hand-authored `{ handler: <name> }` for the same reason it accepts a bare name', () => {
82+
// The inversion #4976 causes, stated plainly rather than left as a
83+
// surprise. Hand-authoring the lowered form registers nothing — but that
84+
// was ALREADY true of the bare string member (`functions: { foo: 'foo' }`),
85+
// which has been accepted since #4343 with exactly that caveat. Rejecting
86+
// the record spelling while accepting the string spelling of one mistake
87+
// was two dialects for one contract; the loud failure is the same either
88+
// way, at execute: "no function named '…' is registered" (#1870).
89+
expect(FlowFunctionEntrySchema.safeParse({ handler: 'scoreLead' }).success).toBe(true);
90+
expect(FlowFunctionEntrySchema.safeParse('scoreLead').success).toBe(true);
7191
});
7292

7393
// #4343 — what `objectstack build` produces. The CLI lowers every inline
@@ -82,10 +102,48 @@ describe('FlowFunctionEntrySchema', () => {
82102
expect(FlowFunctionEntrySchema.safeParse('').success).toBe(false);
83103
});
84104

85-
it('drops a lowered ref when normalizing — it names a function without carrying one', () => {
105+
// #4976 — the other half of what `objectstack build` emits. #4396 taught
106+
// `lowerCallables` to keep a declared entry's declaration beside its lowered
107+
// ref; this union was not extended in the same change, so the artifact
108+
// `{ syncBilling: { handler: 'syncBilling', effect: 'writes' } }` failed the
109+
// build with `invalid_union: Invalid input` — no path past `functions`, no
110+
// key named. An author who cannot read that error deletes the declaration and
111+
// ships an undeclared writer, which is the exact state `effect` exists to
112+
// prevent (#4354).
113+
it('accepts a lowered DECLARATION, the other form a built artifact carries', () => {
114+
expect(FlowFunctionEntrySchema.safeParse({ handler: 'syncBilling', effect: 'writes' }).success).toBe(true);
115+
expect(FlowFunctionEntrySchema.safeParse({ handler: 'syncBilling', effect: 'pure' }).success).toBe(true);
116+
});
117+
118+
it('applies the pure default to a lowered declaration that states no effect', () => {
119+
// `defineStack`'s parse normally materialises `effect` before the lowering
120+
// ever runs, but `{ strict: false }` skips that parse — so the member must
121+
// accept the shape without it, or that path keeps the failure this fixes.
122+
const parsed = FlowFunctionEntrySchema.parse({ handler: 'syncBilling' });
123+
expect(parsed).toEqual({ handler: 'syncBilling', effect: 'pure' });
124+
});
125+
126+
it('keeps the declaration strict once lowered — a typo in a built artifact still names itself', () => {
127+
// Derived from `FlowFunctionDeclarationSchema` rather than re-typed, so the
128+
// surface name, the alias table and the prescription travel with it.
129+
const result = FlowFunctionEntrySchema.safeParse({ handler: 'syncBilling', efect: 'writes' });
130+
expect(result.success).toBe(false);
131+
const messages = JSON.stringify(result.error!.issues);
132+
expect(messages).toContain('`functions` entry');
133+
expect(messages).toContain('`efect` → `effect`');
134+
// And a value the runtime has no meaning for is still refused.
135+
expect(FlowFunctionEntrySchema.safeParse({ handler: 'syncBilling', effect: 'write' }).success).toBe(false);
136+
});
137+
138+
it('drops BOTH lowered shapes when normalizing — each names a function without carrying one', () => {
86139
// The callable for that name comes from the sidecar ESM module the build
87-
// emits; binding the string would register a name pointing at nothing.
140+
// emits; binding the ref would register a name pointing at nothing. This is
141+
// not where `effect` is lost on the built path — `mergeRuntimeModule` has
142+
// already re-attached the callable to the declaration by the time a boot
143+
// normalizes anything (pinned in `packages/runtime`'s
144+
// `artifact-function-declarations.test.ts`).
88145
expect(normalizeFlowFunctionEntry('scoreLead')).toBeUndefined();
146+
expect(normalizeFlowFunctionEntry({ handler: 'syncBilling', effect: 'writes' })).toBeUndefined();
89147
});
90148
});
91149

0 commit comments

Comments
 (0)