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
70 changes: 70 additions & 0 deletions .changeset/flow-function-lowered-declaration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
---
"@objectstack/spec": minor
---

fix(spec): `functions: { fn: { handler, effect: 'writes' } }` survives `objectstack build` (#4976)

`FlowFunctionEntrySchema` gains a fourth union member — the **lowered
declaration**, a `functions` entry whose `handler` has been replaced by the
string ref `objectstack build` emits:

```
functions: {
sweepProjectHealth: { handler: 'sweepProjectHealth', effect: 'writes' },
}
```

Nothing an author writes changes. This shape is produced by the CLI, not typed
by a person: `lowerCallables` replaces every inline callable with a serialisable
ref before the stack is parsed (it must — `z.function()` wraps callables and
would break the ref mapping), and since #4396 it keeps the declaration beside
the ref so what a function said about itself survives into the artifact. The
union was not extended in that change, so the artifact it started emitting was
rejected by the very schema it had to pass:

```
✗ Validation failed

functions:
✗ functions
invalid_union: Invalid input
```

Loading from source was unaffected — `objectstack dev`, `objectstack validate`
and the test suite all passed — so the failure appeared only at build, on the
one spelling the platform asks writers to use. That is the same asymmetry #4343
fixed for the bare handler ref, one shape over.

**Why this was worse than a failed build.** `effect: 'writes'` exists so a
function that writes is not counted as having written nothing (#4396, #4354): a
`script` step reports no record metrics *because* flow functions are
contractually pure, and a declared writer instead reports `unmeasuredEffect` so
the run's broken-sweep query (`selected > 0 AND acted = 0 AND unmeasured = 0`)
stays off it. The error above names no key, no entry and no reason, so the
practical repair an author reaches for is deleting the declaration — shipping an
undeclared writer, which is exactly the state it exists to prevent, recorded
permanently in `sys_automation_run`.

**One behaviour change worth stating.** `{ handler: 'someName' }` written by
hand now parses where it used to be rejected as "handler is not callable". The
rejection could not survive this member and should not have: a bare string entry
(`functions: { foo: 'foo' }`) has been accepted since #4343 with the caveat that
it registers nothing, so refusing the record spelling of the same mistake while
accepting the string spelling was two dialects for one contract. Both fail the
same way, loudly, at execute: `no function named '…' is registered` (#1870).
Everything else stays strict — the lowered member is *derived* from the authored
declaration rather than re-typed beside it, so `{ handler: 'fn', efect: 'writes' }`
still raises the named surface and the `` `efect` → `effect` `` prescription, an
unknown `effect` value is still refused, and an empty ref is still not a name.

**Runtime is unchanged and was already correct.** `normalizeFlowFunctionEntry`
returns `undefined` for a lowered entry in both its shapes, because neither
carries a callable; `mergeRuntimeModule` re-attaches the sidecar module's
function to the declaration the JSON carried *before* any collector runs, so
`effect` reaches `collectBundleFunctionEntries` intact on the built path.

The two halves are now pinned against each other by a round-trip test that
drives the real pipeline (`defineStack` → `normalizeStackInput` →
`lowerCallables` → parse) instead of a hand-written sample of what the lowering
is believed to emit — the crossing neither side previously made, which is why
both stayed green while the build failed on the join.
24 changes: 11 additions & 13 deletions examples/app-showcase/objectstack.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,23 +214,21 @@ export default defineStack({
// `sweepProjectHealth` — the handler `HealthSweepJob` names — lives here too.
// It is the case the pure contract does not cover: a nightly sweep has no
// downstream declarative node to persist for it, so it writes over an engine
// handle captured at `onEnable`.
// handle captured at `onEnable`. That is why it is spelled the DECLARED way
// (#4396) — an undeclared writer is counted as having written nothing, which
// is indistinguishable from the broken sweep #4354 exists to detect.
//
// ⚠️ Do NOT rewrite this as `{ handler: sweepProjectHealth, effect: 'writes' }`.
// That declared form (#4396) is the honest spelling for a writer and is what
// this entry wants — but it cannot survive `objectstack build` today: the CLI
// lowers it to `{ handler: 'sweepProjectHealth', effect: 'writes' }` and
// `FlowFunctionEntrySchema` accepts a bare callable, a declaration whose
// `handler` is a CALLABLE, or a bare string ref — never a declaration whose
// handler has been lowered to a string. `pnpm build` fails with
// `functions: invalid_union`. Filed as #4976; switch back once it lands.
// Nothing is lost at runtime meanwhile: `effect` has exactly one consumer,
// the `script` node's `unmeasuredEffect` metric, and the JOB path drops it
// (`collectBundleFunctions` keeps only the handler).
// This entry authored the bare form until #4976, not because the bare form was
// right but because the declared one could not survive `objectstack build`:
// the CLI lowers it to `{ handler: 'sweepProjectHealth', effect: 'writes' }`
// and `FlowFunctionEntrySchema` had no member for a declaration whose handler
// is a ref, so `pnpm build` failed with `functions: invalid_union`. #4976
// added that member; the honest spelling is back, and this app is the
// end-to-end proof that it builds.
functions: {
summarizeCompletedTask: ({ input }: { input: Record<string, unknown> }) =>
`Completed: ${String(input.title ?? 'task')} (priority ${String(input.priority ?? 'normal')}).`,
sweepProjectHealth,
sweepProjectHealth: { handler: sweepProjectHealth, effect: 'writes' as const },
},
jobs: allJobs,
emailTemplates: allEmails,
Expand Down
32 changes: 15 additions & 17 deletions examples/app-showcase/test/inert-wirings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,24 +96,22 @@ describe('declarative jobs resolve their handler (#4774 ①)', () => {
});
}

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

Expand Down
94 changes: 94 additions & 0 deletions packages/cli/src/utils/lower-callables.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

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

// ── #3855: `target` is the only handler slot ────────────────────────────────
Expand Down Expand Up @@ -124,3 +126,95 @@ describe('lowerCallables — declared `functions` entries (#4396)', () => {
expect(entry).toEqual({ name: 'syncBilling', handler: 'syncBilling', effect: 'writes' });
});
});

// ── #4976: the lowering and the schema must round-trip ──────────────────────
//
// Every test above stops at the shape `lowerCallables` EMITS, and every spec
// test parses only shapes an author WRITES. Nothing crossed the boundary — so
// when #4396 taught this step to keep a declared entry's declaration, and the
// union in `flow-function.zod.ts` was not extended in the same change, both
// halves stayed green and `objectstack build` failed on the join with
// `invalid_union: Invalid input` and no path past `functions`.
//
// These tests are that boundary, driven through the real build pipeline
// (`defineStack` → `normalizeStackInput` → `lowerCallables` → parse) rather
// than a hand-written sample of what the lowering is believed to emit: a
// hand-written sample is a third copy of the truth and drifts exactly the way
// the two halves already did.
//
// SCOPE: the map form. The ARRAY form (`functions: [{ name, handler }]`) does
// not round-trip either — in both its bare and declared spellings, since #4343
// and #4976 each only ever touched the map — and its member lives in
// `stack.zod.ts` rather than in `FlowFunctionEntrySchema`. Filed as #6238;
// extend the parametrisation below when it lands.
describe('lowerCallables → the spec parses what it emits (#4976)', () => {
const base = {
manifest: { id: 'com.example.demo', name: 'demo', version: '1.0.0', type: 'app' as const },
};

/** Exactly what `objectstack compile` does, in the order it does it. */
const buildPipeline = (functions: Record<string, unknown>) => {
const stack = defineStack({ ...base, functions } as never);
const normalized = normalizeStackInput(stack as Record<string, unknown>);
return lowerCallables(normalized);
};

const cases: Array<[label: string, functions: Record<string, unknown>]> = [
['a bare handler', { scoreLead: () => ({ score: 1 }) }],
['a declared writer', { syncBilling: { handler: () => ({ ok: true }), effect: 'writes' } }],
['a declaration that states the pure default', { scoreLead: { handler: () => ({ score: 1 }), effect: 'pure' } }],
['a declaration that states nothing', { scoreLead: { handler: () => ({ score: 1 }) } }],
['both spellings side by side', {
scoreLead: () => ({ score: 1 }),
syncBilling: { handler: () => ({ ok: true }), effect: 'writes' },
}],
];

for (const [label, functions] of cases) {
it(`parses every entry it emits for ${label}`, () => {
const emitted = (buildPipeline(functions).lowered as {
functions: Record<string, unknown>;
}).functions;

for (const [name, entry] of Object.entries(emitted)) {
const result = FlowFunctionEntrySchema.safeParse(entry);
expect(
result.success,
`emitted entry '${name}' (${JSON.stringify(entry)}) is not a shape FlowFunctionEntrySchema accepts: `
+ JSON.stringify(result.success ? [] : result.error.issues),
).toBe(true);
}
});

it(`parses the whole lowered stack for ${label}`, () => {
// The assertion the build itself makes (`compile.ts` step 3). Parsing the
// entries one by one can pass while the stack does not — `functions` is a
// union of a record and an array, so a rejected entry surfaces only as
// `invalid_union` on the parent, which is precisely the unreadable error
// the issue is about.
const { lowered } = buildPipeline(functions);
const result = ObjectStackDefinitionSchema.safeParse(lowered);
expect(
result.success,
`lowered stack rejected: ${JSON.stringify(result.success ? [] : result.error.issues)}`,
).toBe(true);
});
}

it('carries the declaration into the artifact, not just past the parse', () => {
// Surviving the parse is worthless if `effect` is dropped on the way — that
// would re-create #4396's silent un-declaring with a green build. The
// artifact must still SAY 'writes', because that string is what
// `mergeRuntimeModule` re-attaches the module's callable to at boot.
const { lowered } = buildPipeline({
syncBilling: { handler: () => ({ ok: true }), effect: 'writes' },
});
const parsed = ObjectStackDefinitionSchema.parse(lowered) as {
functions: Record<string, { handler: string; effect: string }>;
};
expect(parsed.functions.syncBilling).toEqual({ handler: 'syncBilling', effect: 'writes' });
// And it is JSON — the artifact is `objectstack.json`, not a module.
expect(JSON.parse(JSON.stringify(lowered)).functions.syncBilling)
.toEqual({ handler: 'syncBilling', effect: 'writes' });
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,27 @@ beforeAll(async () => {
process.chdir(SHOWCASE_DIR);
tempDir = mkdtempSync(join(tmpdir(), 'os-e8-endpoints-'));
const artifactPath = join(tempDir, 'objectstack.json');
writeFileSync(artifactPath, JSON.stringify(showcaseStack));
// `functions` is dropped DELIBERATELY, and saying so is the point (#4976).
//
// This line stands in for `objectstack build`, but it is only half of it: the
// real build runs `lowerCallables` first, replacing every callable with a
// string ref and carrying the functions themselves in a sibling ESM module.
// A plain `JSON.stringify` has no such step — it simply omits function-valued
// keys — so this artifact never carried the showcase's functions at all. It
// merely LOOKED like it did, because a bare entry (`sweepProjectHealth: fn`)
// vanishes key and all and leaves `functions: {}` behind, which parses.
//
// That silence broke the moment the showcase spelled its writer the honest,
// declared way: `{ handler: fn, effect: 'writes' }` keeps the object and drops
// only `handler`, leaving `{ effect: 'writes' }` — an entry declaring an
// effect for a function it does not carry, which `FlowFunctionEntrySchema`
// refuses in all four of its members, exactly as it should.
//
// Nothing is lost by omitting the key: the functions this boot actually runs
// come from the LIVE stack handed to `bootStack` below, not from this file,
// whose job is to give `MetadataPlugin` the `apis:` block to ingest.
const { functions: _functionsLiveOnly, ...artifact } = showcaseStack as Record<string, unknown>;
writeFileSync(artifactPath, JSON.stringify(artifact));

stack = await bootStack(showcaseStack, {
// The `flow`-typed endpoint delegates to `IAutomationService.execute`;
Expand Down
Loading
Loading