diff --git a/.changeset/31224-flow-start-config-refusals.md b/.changeset/31224-flow-start-config-refusals.md new file mode 100644 index 00000000000..0e57b0d64af --- /dev/null +++ b/.changeset/31224-flow-start-config-refusals.md @@ -0,0 +1,22 @@ +--- +'@objectstack/spec': minor +--- + +fix(spec)!: the flow parse refuses a start node's `config.filter` beside `config.timeRelative`, and a `config.schedule` the schedule readers cannot use + +Clause-②: yes (narrowing) — `FlowSchema` refuses two start-node `config` shapes it accepted before, at every door that parses a flow; `@objectstack/spec/automation` also exports `flowStartConfigRefusals`, `flowStartScheduleReadsNothing` and the `FlowStartConfigRefusal` type. +Metadata-protocol: narrowed +Skills-docs: none — `skills/**` and `content/docs/**` teach `timeRelative.filter` inside the descriptor and typed `{ type: 'cron', expression }` schedules, which this accepts. The generated `skills/objectstack-automation/references/_index.md` gains the source files the flow closure now reaches (`system/job.zod.ts` and its imports). + + + +**BREAKING** accept-set narrowing of `FlowSchema` (every door that parses a flow: `defineStack`, `os validate` / `os build`, the metadata save door including drafts, publish, and the automation engine's registration). + +- **`config.filter` beside `config.timeRelative`.** Before, it parsed and nothing read it: the time-relative sweep reads its row filter from `timeRelative.filter`, so the flow launched for every record in the date window. Now it is refused at `nodes[i].config.filter`, when `config.timeRelative` is an object (the start node the engine routes as a sweep); a `timeRelative: null` start node is not judged. +- **A `config.schedule` the readers cannot use.** Both readers (the schedule trigger, and the time-relative sweep's cadence) pass it through `normalizeSchedule`. When that reads nothing, a schedule flow stays unbound (one warn in the server log) and a sweep falls back to daily 08:00 UTC. Now, at `nodes[i].config.schedule`: + - an object carrying `type` is judged against the published `ScheduleSchema` (the discriminant, a cron `timezone` that is an IANA zone, a positive integer `intervalMs`, an ISO `at`), and then for being readable at all (a cron `expression` written as an envelope is not). The schema does not declare the aliases `cron` and `every` on a typed object, so the two typed aliases the reader happens to bind today, `{ type: 'cron', cron }` and `{ type: 'interval', every }`, are refused too, and the refusal names the declared spelling (`expression`, `intervalMs`). Neither occurs in objectstack or hotcrm; + - any other value is refused only when the reader reads nothing from it (an empty string, `{}`, `{ cadence: 'daily' }`, a number, an array, a non-positive interval); + - accepted as before: a bare cron string, and the untyped aliases the reader takes (`{ cron }`, `{ expression }`, `{ every }`, `{ intervalMs }`, `{ at }`). +- **One reading, pinned.** `flowStartScheduleReadsNothing` mirrors `normalizeSchedule` branch for branch, and `@objectstack/trigger-schedule` pins the two equal over every form either side distinguishes. + +**What an author does.** Nothing for a sweep whose filter sits inside `timeRelative`, or a schedule that binds today in a form the published schema declares. For a refused shape, the parse refusal names where the key belongs, or the schedule spellings the readers take. diff --git a/packages/spec/api-surface/automation.json b/packages/spec/api-surface/automation.json index b11e147d815..7a7b47ae248 100644 --- a/packages/spec/api-surface/automation.json +++ b/packages/spec/api-surface/automation.json @@ -201,6 +201,7 @@ "FlowSchema (const)", "FlowSlotRefusalCode (type)", "FlowSlotRefusalParams (interface)", + "FlowStartConfigRefusal (interface)", "FlowTextSlot (interface)", "FlowTriggerKind (type)", "FlowValueSlot (type)", @@ -346,6 +347,8 @@ "flowNodeTextSlotSources (function)", "flowNodeValueTemplateRefusals (function)", "flowScreenFieldOptionKey (function)", + "flowStartConfigRefusals (function)", + "flowStartScheduleReadsNothing (function)", "flowTextSlotSources (function)", "getApprovalNodeConfigJsonSchema (function)", "getBuiltinNodeConfigContracts (function)", diff --git a/packages/spec/export-origins/automation.json b/packages/spec/export-origins/automation.json index 4a6d1d8853e..740a3e6ff88 100644 --- a/packages/spec/export-origins/automation.json +++ b/packages/spec/export-origins/automation.json @@ -195,6 +195,7 @@ "FlowSchema": "src/automation/flow.zod.ts#FlowSchema (const)", "FlowSlotRefusalCode": "src/automation/flow-node-expression-paths.ts#FlowSlotRefusalCode (type)", "FlowSlotRefusalParams": "src/automation/flow-node-expression-paths.ts#FlowSlotRefusalParams (interface)", + "FlowStartConfigRefusal": "src/automation/flow-start-config-refusals.ts#FlowStartConfigRefusal (interface)", "FlowTextSlot": "src/automation/flow-text-slot-template.ts#FlowTextSlot (interface)", "FlowTriggerKind": "src/automation/flow-trigger-kind.ts#FlowTriggerKind (type)", "FlowValueSlot": "src/automation/builtin-node-config.zod.ts#FlowValueSlot (type)", @@ -339,6 +340,8 @@ "flowNodeTextSlotSources": "src/automation/flow-text-slot-template.ts#flowNodeTextSlotSources (function)", "flowNodeValueTemplateRefusals": "src/automation/flow-value-slot-template.ts#flowNodeValueTemplateRefusals (function)", "flowScreenFieldOptionKey": "src/automation/flow-screen-option-key.ts#flowScreenFieldOptionKey (function)", + "flowStartConfigRefusals": "src/automation/flow-start-config-refusals.ts#flowStartConfigRefusals (function)", + "flowStartScheduleReadsNothing": "src/automation/flow-start-config-refusals.ts#flowStartScheduleReadsNothing (function)", "flowTextSlotSources": "src/automation/flow-text-slot-template.ts#flowTextSlotSources (function)", "getApprovalNodeConfigJsonSchema": "src/automation/approval.zod.ts#getApprovalNodeConfigJsonSchema (function)", "getBuiltinNodeConfigContracts": "src/automation/flow-node-config-refusals.ts#getBuiltinNodeConfigContracts (function)", diff --git a/packages/spec/src/automation/flow-start-config-refusals.test.ts b/packages/spec/src/automation/flow-start-config-refusals.test.ts new file mode 100644 index 00000000000..d6501fc7636 --- /dev/null +++ b/packages/spec/src/automation/flow-start-config-refusals.test.ts @@ -0,0 +1,147 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, expect, it } from 'vitest'; +import { FlowSchema } from './flow.zod'; +import { flowStartConfigRefusals, flowStartScheduleReadsNothing } from './flow-start-config-refusals'; + +const flowWith = (config: Record) => ({ + name: 'start_config_probe', + label: 'Start Config Probe', + type: 'schedule', + runAs: 'system', + nodes: [ + { id: 'start', type: 'start', label: 'Start', config }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [{ id: 'e1', source: 'start', target: 'end' }], +}); + +/** The parse's issues anchored on the start node's config. */ +const startIssues = (config: Record) => { + const parsed = FlowSchema.safeParse(flowWith(config)); + if (parsed.success) return []; + return parsed.error.issues + .filter((i) => i.path[0] === 'nodes' && i.path[1] === 0 && i.path[2] === 'config') + .map((i) => ({ path: i.path.join('.'), message: i.message })); +}; + +const TIME_RELATIVE = { + object: 'contract', + dateField: 'end_date', + offsetDays: -30, +}; + +describe('flow start config — `filter` beside `timeRelative` (#31224 B1)', () => { + it('refuses it at the parse, naming where the filter belongs', () => { + const issues = startIssues({ triggerType: 'time_relative', timeRelative: TIME_RELATIVE, filter: { status: 'active' } }); + expect(issues).toHaveLength(1); + expect(issues[0]!.path).toBe('nodes.0.config.filter'); + expect(issues[0]!.message).toMatch(/nothing reads it/); + expect(issues[0]!.message).toMatch(/launches for every record in the date window/); + expect(issues[0]!.message).toMatch(/Move it into `config\.timeRelative\.filter`/); + }); + + it('accepts the filter inside `timeRelative`, and a `filter` on a start node with no `timeRelative`', () => { + expect(flowStartConfigRefusals(flowWith({ timeRelative: { ...TIME_RELATIVE, filter: { status: 'active' } } }))).toEqual([]); + expect(flowStartConfigRefusals(flowWith({ triggerType: 'schedule', schedule: '0 8 * * *', filter: { a: 1 } }))).toEqual([]); + }); + + it('judges no `filter` beside `timeRelative: null`: the trigger-kind resolver does not route that start node as a sweep', () => { + expect(flowStartConfigRefusals(flowWith({ timeRelative: null, filter: { status: 'active' } }))).toEqual([]); + expect(startIssues({ timeRelative: null, filter: { status: 'active' } })).toEqual([]); + }); +}); + +describe('flow start config — `schedule` (#31224 B1, ruling option (a))', () => { + it('accepts the bare cron string, the three typed forms, and the untyped aliases the reader takes', () => { + for (const schedule of [ + '0 8 * * *', + { type: 'cron', expression: '0 8 * * *' }, + { type: 'cron', expression: '0 8 * * *', timezone: 'America/New_York' }, + { type: 'interval', intervalMs: 60000 }, + { type: 'once', at: '2027-01-01T08:00:00Z' }, + { cron: '0 8 * * *' }, + { expression: '0 8 * * *' }, + { every: 60000 }, + { intervalMs: 60000 }, + { at: '2027-01-01T08:00:00Z' }, + ]) { + expect(flowStartConfigRefusals(flowWith({ triggerType: 'schedule', schedule })), JSON.stringify(schedule)).toEqual([]); + } + }); + + it('refuses a typed cron whose timezone is not an IANA zone, at the parse', () => { + const issues = startIssues({ triggerType: 'schedule', schedule: { type: 'cron', expression: '0 8 * * *', timezone: 'UTC+8' } }); + expect(issues).toHaveLength(1); + expect(issues[0]!.path).toBe('nodes.0.config.schedule'); + expect(issues[0]!.message).toMatch(/is not a schedule at `schedule\.timezone`/); + expect(issues[0]!.message).toMatch(/a schedule flow stays unbound/); + }); + + it('refuses a typed form the published ScheduleSchema refuses (unknown type, non-positive interval, no expression)', () => { + for (const schedule of [ + { type: 'hourly' }, + { type: 'interval', intervalMs: 0 }, + { type: 'cron' }, + { type: 'once', at: 'tomorrow' }, + ]) { + const found = flowStartConfigRefusals(flowWith({ triggerType: 'schedule', schedule })); + expect(found.map((f) => f.path.join('.')), JSON.stringify(schedule)).toEqual(['nodes.0.config.schedule']); + } + }); + + it('refuses an untyped value the reader reads nothing from, and an empty string', () => { + for (const schedule of [{ cadence: 'daily' }, {}, [], 42, true, ' ', { every: -5 }]) { + const found = flowStartConfigRefusals(flowWith({ triggerType: 'schedule', schedule })); + expect(found, JSON.stringify(schedule)).toHaveLength(1); + expect(found[0]!.message).toMatch(/schedule flow stays unbound and a time-relative sweep falls back to daily 08:00 UTC/); + } + }); + + it('refuses the typed aliases the published ScheduleSchema does not declare, naming the declared spelling', () => { + const cron = startIssues({ triggerType: 'schedule', schedule: { type: 'cron', cron: '0 8 * * *' } }); + expect(cron).toHaveLength(1); + expect(cron[0]!.path).toBe('nodes.0.config.schedule'); + expect(cron[0]!.message).toMatch(/is not a schedule at `schedule\.expression`/); + expect(cron[0]!.message).toMatch(/names its cron in `expression`, not `cron`/); + + const every = startIssues({ triggerType: 'schedule', schedule: { type: 'interval', every: 60000 } }); + expect(every).toHaveLength(1); + expect(every[0]!.path).toBe('nodes.0.config.schedule'); + expect(every[0]!.message).toMatch(/is not a schedule at `schedule\.intervalMs`/); + expect(every[0]!.message).toMatch(/names its period in `intervalMs`, not `every`/); + + // Without the alias key, no alias hint. + const bare = flowStartConfigRefusals(flowWith({ triggerType: 'schedule', schedule: { type: 'cron' } })); + expect(bare[0]!.message).not.toMatch(/not `cron`/); + }); + + it('refuses a typed cron whose expression is an envelope: it passes the schema, and the reader reads nothing', () => { + const found = flowStartConfigRefusals(flowWith({ + triggerType: 'schedule', + schedule: { type: 'cron', expression: { dialect: 'cron', source: '0 8 * * *' } }, + })); + expect(found).toHaveLength(1); + expect(found[0]!.message).toMatch(/names no schedule the readers can use/); + }); + + it('judges no absent or null schedule', () => { + expect(flowStartConfigRefusals(flowWith({ triggerType: 'schedule', schedule: null }))).toEqual([]); + expect(flowStartConfigRefusals(flowWith({ triggerType: 'manual' }))).toEqual([]); + }); + + it('reads only top-level start nodes', () => { + const flow = flowWith({ triggerType: 'manual' }) as any; + flow.nodes.splice(1, 0, { id: 'mid', type: 'assignment', label: 'Mid', config: { schedule: {} } }); + expect(flowStartConfigRefusals(flow)).toEqual([]); + }); +}); + +describe('flowStartScheduleReadsNothing — the reader\'s precedence', () => { + it('the first candidate present decides, as `normalizeSchedule` does', () => { + // `intervalMs` is present and unusable, so the reader never reaches `every`. + expect(flowStartScheduleReadsNothing({ intervalMs: -1, every: 60000 })).toBe(true); + expect(flowStartScheduleReadsNothing({ expression: '', cron: '0 8 * * *' })).toBe(false); + expect(flowStartScheduleReadsNothing(undefined)).toBe(false); + }); +}); diff --git a/packages/spec/src/automation/flow-start-config-refusals.ts b/packages/spec/src/automation/flow-start-config-refusals.ts new file mode 100644 index 00000000000..837ceab7107 --- /dev/null +++ b/packages/spec/src/automation/flow-start-config-refusals.ts @@ -0,0 +1,160 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { ScheduleSchema } from '../system/job.zod'; + +/** + * Two start-node `config` shapes that parse and then do nothing (#31224 item + * B, ruling B1 option (a)). + * + * A flow's start node `config` is an open record (`FlowNodeSchema.config`), so + * the parse never judged these two keys, and the readers that do read them + * skip what they cannot use without telling the author: + * + * - **`config.filter` beside `config.timeRelative`.** A time-relative sweep + * reads its row filter from `timeRelative.filter` (`time-relative-trigger.ts` + * in `@objectstack/trigger-schedule`); nothing reads a `filter` on the start + * node's own `config`, so a sweep written that way launches the flow for + * every row in the date window. Refused with the move as the prescription. + * + * - **A `config.schedule` the schedule readers cannot use.** Both readers + * (`ScheduleTrigger` for a schedule flow, `TimeRelativeTrigger` for a sweep's + * cadence) pass it through `normalizeSchedule` (`schedule-trigger.ts`). A + * descriptor it reads nothing from leaves a schedule flow unbound (one warn + * in the server log) and a sweep on its default daily 08:00 UTC cadence, + * either way not what was written. Three readings, one per form: + * - a bare cron STRING is not judged here (the reader takes it verbatim); + * - an object that carries `type` is judged against the published + * {@link ScheduleSchema} (the discriminant, a cron `timezone` that is a + * member of `iana_time_zone`, a positive integer `intervalMs`, an ISO + * `at`), and then, as an untyped one is, for being readable at all. + * By ruling, the typed aliases the reader happens to bind + * (`{ type: 'cron', cron }`, `{ type: 'interval', every }`) are refused + * too, because the published schema does not declare those keys; the + * refusal names the declared spelling (`expression`, `intervalMs`); + * - any other value is refused only when the reader reads nothing from it + * ({@link flowStartScheduleReadsNothing}). The untyped aliases the + * reader accepts (`{ cron }`, `{ expression }`, `{ every }`, + * `{ intervalMs }`, `{ at }`) run today and stay accepted. + * + * Top-level `start` nodes only: a start node is never a region member. + */ + +export interface FlowStartConfigRefusal { + readonly path: ReadonlyArray; + readonly message: string; +} + +function isRecord(v: unknown): v is Record { + return !!v && typeof v === 'object' && !Array.isArray(v); +} + +/** + * `true` when the flow schedule readers read NOTHING from `raw`: the exact + * complement of what `normalizeSchedule` (`@objectstack/trigger-schedule`) + * turns into a job schedule, which pins the two together over a table of + * shapes. `null` / `undefined` read as "not declared" and answer `false`: an + * absent schedule is not a written one. + */ +export function flowStartScheduleReadsNothing(raw: unknown): boolean { + if (raw == null) return false; + if (typeof raw === 'string') return raw.trim().length === 0; + if (typeof raw !== 'object') return true; + const s = raw as Record; + const type = typeof s.type === 'string' ? s.type : undefined; + // Each branch is `normalizeSchedule`'s, step for step (truthiness included): + // the first candidate present decides, and a decided value that is not + // usable reads nothing even when a later alias would have been. + if (type === 'cron' || (!type && (typeof s.cron === 'string' || typeof s.expression === 'string'))) { + const expression = + (typeof s.expression === 'string' && s.expression) || (typeof s.cron === 'string' && s.cron) || undefined; + return !expression; + } + if (type === 'interval' || (!type && (typeof s.intervalMs === 'number' || typeof s.every === 'number'))) { + const intervalMs = + (typeof s.intervalMs === 'number' && s.intervalMs) || (typeof s.every === 'number' && s.every) || undefined; + return !intervalMs || intervalMs <= 0; + } + if (type === 'once' || (!type && typeof s.at === 'string')) { + return !(typeof s.at === 'string' && s.at); + } + return true; +} + +/** + * The key a typed schedule spells another way. `normalizeSchedule` reads the + * untyped aliases `cron` and `every` inside its typed branches too, but the + * published {@link ScheduleSchema} declares only `expression` and + * `intervalMs` on a typed object (ruling 6717636410 §2), so the refusal names + * the declared spelling. + */ +function typedAliasHint(raw: Record): string { + const has = (k: string) => Object.prototype.hasOwnProperty.call(raw, k); + if (raw.type === 'cron' && has('cron') && !has('expression')) { + return " A typed cron schedule names its cron in `expression`, not `cron`: write `{ type: 'cron', expression: '…' }`."; + } + if (raw.type === 'interval' && has('every') && !has('intervalMs')) { + return " A typed interval schedule names its period in `intervalMs`, not `every`: write `{ type: 'interval', intervalMs: … }`."; + } + return ''; +} + +function scheduleRefusal(raw: unknown): string | undefined { + if (raw == null || typeof raw === 'string') { + return typeof raw === 'string' && flowStartScheduleReadsNothing(raw) + ? 'The start node\'s `config.schedule` is an empty string, so no schedule is read from it: a schedule ' + + 'flow stays unbound and a time-relative sweep falls back to daily 08:00 UTC. Write a cron expression ' + + "(e.g. '0 8 * * *') or remove the key." + : undefined; + } + if (isRecord(raw) && Object.prototype.hasOwnProperty.call(raw, 'type')) { + const parsed = ScheduleSchema.safeParse(raw); + if (!parsed.success) { + const issue = parsed.error.issues[0]; + const at = issue && issue.path.length > 0 ? ` at \`schedule.${issue.path.join('.')}\`` : ''; + return ( + `The start node's \`config.schedule\` is not a schedule${at}: ${issue?.message ?? 'refused'}.` + + typedAliasHint(raw) + + ' The schedule ' + + 'readers cannot use it, so a schedule flow stays unbound and a time-relative sweep falls back to daily ' + + "08:00 UTC. Write `{ type: 'cron', expression: '0 8 * * *', timezone: 'UTC' }`, " + + "`{ type: 'interval', intervalMs: 60000 }` or `{ type: 'once', at: '' }`, or a bare cron string." + ); + } + } + if (!flowStartScheduleReadsNothing(raw)) return undefined; + return ( + 'The start node\'s `config.schedule` names no schedule the readers can use (a cron `expression` as a ' + + 'string, a positive `intervalMs`, or an `at` datetime), so a schedule flow stays unbound and a ' + + 'time-relative sweep falls back to daily 08:00 UTC. Write a cron string such as \'0 8 * * *\', or ' + + "`{ type: 'cron', expression: '0 8 * * *' }`, `{ type: 'interval', intervalMs: 60000 }` or " + + "`{ type: 'once', at: '' }`." + ); +} + +/** The refusals of every top-level start node's `config` in `flow`. */ +export function flowStartConfigRefusals(flow: unknown): FlowStartConfigRefusal[] { + if (!isRecord(flow) || !Array.isArray(flow.nodes)) return []; + const out: FlowStartConfigRefusal[] = []; + flow.nodes.forEach((node, index) => { + if (!isRecord(node) || node.type !== 'start' || !isRecord(node.config)) return; + const config = node.config; + // A sweep is a start node whose `timeRelative` is a non-null object, the + // exact test the trigger-kind resolver (`flow-trigger-kind.ts`) routes by, + // so `timeRelative: null` is not judged as a sweep. + const isSweep = config.timeRelative != null && typeof config.timeRelative === 'object'; + if (isSweep && Object.prototype.hasOwnProperty.call(config, 'filter')) { + out.push({ + path: ['nodes', index, 'config', 'filter'], + message: + 'The start node declares `config.filter` beside `config.timeRelative`, and nothing reads it: a ' + + 'time-relative sweep takes its row filter from `timeRelative.filter`, so as written the flow ' + + 'launches for every record in the date window. Move it into `config.timeRelative.filter`.', + }); + } + if (Object.prototype.hasOwnProperty.call(config, 'schedule')) { + const message = scheduleRefusal(config.schedule); + if (message) out.push({ path: ['nodes', index, 'config', 'schedule'], message }); + } + }); + return out; +} diff --git a/packages/spec/src/automation/flow.zod.ts b/packages/spec/src/automation/flow.zod.ts index 3219078af4c..dbce9c700ee 100644 --- a/packages/spec/src/automation/flow.zod.ts +++ b/packages/spec/src/automation/flow.zod.ts @@ -28,6 +28,7 @@ import { predicateSlotRefusal, resolveFlowNodeExpressions, type FlowNodeSiblingB import { flowNodeConfigRefusals } from './flow-node-config-refusals'; import { apiTriggerSecretRefRefusals } from './api-trigger-secret-ref.zod'; import { apiTriggerSignatureRefusals } from './api-trigger-signature.zod'; +import { flowStartConfigRefusals } from './flow-start-config-refusals'; import { saveNoticePlacementRefusals } from './save-notice-placement'; // [#23543 F3] The loose `config` keys of the two sibling-block node types — one // table, shared with the two D2 lifts that still run at rehydration. @@ -1704,6 +1705,15 @@ export const FlowSchema = lazySchema(() => strictObject( ctx.addIssue({ code: 'custom', path: [...refusal.path], message: refusal.message }); } + // [#31224 B1] Two start-node `config` shapes that parse and then do nothing: + // a `filter` beside `timeRelative` (the sweep reads `timeRelative.filter`), + // and a `schedule` the schedule readers cannot use. Why each, and why the + // untyped aliases the reader accepts stay accepted, is + // `flow-start-config-refusals.ts`'s header. + for (const refusal of flowStartConfigRefusals(flow)) { + ctx.addIssue({ code: 'custom', path: [...refusal.path], message: refusal.message }); + } + // [#28251] A `save_notice` node answers the write that fired the run, so it // stands only in a flow a create or update record trigger starts — anywhere // else no write waits on the run. Refused at each such node, at any region diff --git a/packages/spec/src/automation/index.ts b/packages/spec/src/automation/index.ts index a64470e56d0..d4033e6e477 100644 --- a/packages/spec/src/automation/index.ts +++ b/packages/spec/src/automation/index.ts @@ -48,6 +48,7 @@ export * from './time-relative-trigger.zod'; export * from './flow-trigger-kind'; // An `api` flow's hook secret named by reference — the deployment fills it. export * from './api-trigger-secret-ref.zod'; +export * from './flow-start-config-refusals'; // A third-party sender's signature scheme on an `api` hook, keyed by `secretRef`. export * from './api-trigger-signature.zod'; // The acting-organization declaration a time-triggered flow carries, and the diff --git a/packages/triggers/trigger-schedule/src/schedule-reader-spec-parity.test.ts b/packages/triggers/trigger-schedule/src/schedule-reader-spec-parity.test.ts new file mode 100644 index 00000000000..6fcda19808c --- /dev/null +++ b/packages/triggers/trigger-schedule/src/schedule-reader-spec-parity.test.ts @@ -0,0 +1,38 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, expect, it } from 'vitest'; +import { flowStartScheduleReadsNothing } from '@objectstack/spec/automation'; +import { normalizeSchedule } from './schedule-trigger.js'; + +/** + * [#31224 B1] The flow parse refuses a start-node `config.schedule` this + * reader reads nothing from (`flowStartConfigRefusals`, `@objectstack/spec`), + * through a predicate that mirrors {@link normalizeSchedule} branch for + * branch. If the two drift, the parse either refuses a schedule that runs or + * accepts one that never binds, so they are pinned together here, over every + * form either side distinguishes. + */ +const SHAPES: unknown[] = [ + '0 8 * * *', '', ' ', + { type: 'cron', expression: '0 8 * * *' }, { type: 'cron', expression: '' }, { type: 'cron' }, + { type: 'cron', cron: '0 8 * * *' }, { type: 'cron', expression: { dialect: 'cron', source: '0 8 * * *' } }, + { cron: '0 8 * * *' }, { expression: '0 8 * * *' }, { expression: '', cron: '0 8 * * *' }, { cron: '' }, + { type: 'interval', intervalMs: 60000 }, { type: 'interval', intervalMs: 0 }, { type: 'interval', intervalMs: -1 }, + { type: 'interval', every: 60000 }, { type: 'interval' }, { intervalMs: 60000 }, { every: 60000 }, + { intervalMs: -1, every: 60000 }, { intervalMs: 0, every: 60000 }, { every: Number.NaN }, + { type: 'once', at: '2027-01-01T08:00:00Z' }, { type: 'once', at: '' }, { type: 'once' }, { at: '2027-01-01T08:00:00Z' }, + { type: 'hourly' }, { type: 7, cron: '0 8 * * *' }, { cadence: 'daily' }, {}, [], 42, true, +]; + +describe('flowStartScheduleReadsNothing ⇔ normalizeSchedule(x) === null', () => { + for (const shape of SHAPES) { + it(JSON.stringify(shape) ?? String(shape), () => { + expect(flowStartScheduleReadsNothing(shape)).toBe(normalizeSchedule(shape) === null); + }); + } + + it('an absent schedule is not a written one: neither side binds it, and the parse does not refuse it', () => { + expect(normalizeSchedule(undefined)).toBeNull(); + expect(flowStartScheduleReadsNothing(undefined)).toBe(false); + }); +}); diff --git a/skills/objectstack-automation/references/_index.md b/skills/objectstack-automation/references/_index.md index c4775792008..d273230fa9c 100644 --- a/skills/objectstack-automation/references/_index.md +++ b/skills/objectstack-automation/references/_index.md @@ -24,9 +24,11 @@ from `node_modules` — there is no local copy in the skill bundle. - `node_modules/@objectstack/spec/src/automation/api-trigger-secret-ref.zod.ts` — An inbound `api` hook's secret, by reference. - `node_modules/@objectstack/spec/src/automation/api-trigger-signature.zod.ts` — An inbound `api` hook's verifier for a THIRD-PARTY sender, declared. - `node_modules/@objectstack/spec/src/automation/control-flow.zod.ts` — Structured control-flow constructs (ADR-0031) — the **native + AI-authored** +- `node_modules/@objectstack/spec/src/automation/schedule-organization.zod.ts` — The ACTING ORGANIZATION of a time-triggered flow — the one start-node key - `node_modules/@objectstack/spec/src/automation/schemaless-node-config.zod.ts` — Config contracts for the **descriptor-schemaless** builtins whose designer - `node_modules/@objectstack/spec/src/data/context-tokens.zod.ts` — Context Tokens — the declarative placeholders that resolve against the - `node_modules/@objectstack/spec/src/data/date-macros.zod.ts` — Date Macro Tokens — the declarative placeholders the UI substitutes +- `node_modules/@objectstack/spec/src/data/hook-body.zod.ts` — Exports: HookBodyCapability, ExpressionBodySchema, ScriptBodySchema, HookBodySchema - `node_modules/@objectstack/spec/src/kernel/metadata-protection.zod.ts` — Metadata Protection Model — Phase 1 (ADR-0010) - `node_modules/@objectstack/spec/src/kernel/override-declaration.zod.ts` — The override declaration (ADR-0146 D2) - `node_modules/@objectstack/spec/src/shared/expression.zod.ts` — Expression Protocol @@ -34,6 +36,8 @@ from `node_modules` — there is no local copy in the skill bundle. - `node_modules/@objectstack/spec/src/shared/protection.zod.ts` — Package-level metadata protection (ADR-0010 §3.7 — Phase 4.3) - `node_modules/@objectstack/spec/src/shared/retry-policy.zod.ts` — The **single declaration** of the exponential-backoff retry policy. - `node_modules/@objectstack/spec/src/shared/suggestions.zod.ts` — "Did you mean?" Suggestion Utilities +- `node_modules/@objectstack/spec/src/shared/value-domain.zod.ts` — Standard value domains: one closed vocabulary and one membership predicate for settings and fields. +- `node_modules/@objectstack/spec/src/system/job.zod.ts` — Exports: CronScheduleSchema, IntervalScheduleSchema, OnceScheduleSchema, ScheduleSchema, JobSchema ## How to read these