Skip to content

Commit 0d7eb27

Browse files
committed
wip(example-todo): the pre-fix recurrence shape is refused at registration now (#19939)
Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 Co-authored-by: Claude <noreply@anthropic.com>
1 parent c03d771 commit 0d7eb27

1 file changed

Lines changed: 46 additions & 51 deletions

File tree

‎examples/app-todo/test/task-recurrence.test.ts‎

Lines changed: 46 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -29,17 +29,24 @@
2929
* to be computed BEFORE the create node, which is what a registered function
3030
* invoked by a `script` node is for (#1870, pure per #4396).
3131
*
32+
* (Both readings above are history, kept for the reasoning: since #19938 a
33+
* `fields` value may be a CEL value envelope, which IS evaluated, and since
34+
* #19939 a value slot no longer reads the `{…}` template dialect at all — the
35+
* pre-fix `DATEADD({…})` shape is now refused at registration, before any
36+
* driver sees it. The script stays: a recurrence rule is a function's job.)
37+
*
3238
* The suite drives the app's REAL metadata (`allFlows`, the real `Task` object,
3339
* the real `todoFunctions` registry) through a real kernel over sqlite, so it
3440
* fails if any link is re-broken: the function name, the node wiring, the
35-
* arithmetic, or the interpolation shape of the `due_date` slot.
41+
* arithmetic, or the shape of the `due_date` slot.
3642
*/
3743

3844
import { describe, it, expect, afterEach } from 'vitest';
3945
import { ObjectKernel } from '@objectstack/core';
4046
import { ObjectQLPlugin } from '@objectstack/objectql';
4147
import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm';
4248
import { AutomationServicePlugin, type AutomationEngine } from '@objectstack/service-automation';
49+
import { VALUE_SLOT_TEMPLATE_REFUSAL } from '@objectstack/spec/automation';
4350
import { RecordChangeTriggerPlugin } from '@objectstack/trigger-record-change';
4451

4552
import { allFlows, TaskCompletionFlow } from '../src/flows/index.js';
@@ -262,12 +269,12 @@ describe('#7037 — the recurrence branch computes a real next due date', () =>
262269
expect(script.config.outputVariable).toBe('nextDueDate');
263270
});
264271

265-
it('create_next_task reads the computed variable as a WHOLE-STRING token', () => {
266-
// A whole-string `{token}` is the one form `interpolateString` returns the
267-
// raw value for. Any surrounding text turns the slot back into
268-
// text-with-holes — which is precisely the shape that shipped `DATEADD`.
272+
it('create_next_task reads the computed variable as a CEL path — the raw value, type kept', () => {
273+
// [#19939] A value slot computes with a CEL value envelope; a bare path
274+
// hands the create the RAW value the script returned. Text around it
275+
// would be a concatenation — the shape that shipped `DATEADD`.
269276
const fields = node('create_next_task').config.fields;
270-
expect(fields.due_date).toBe('{nextDueDate}');
277+
expect(fields.due_date).toEqual({ dialect: 'cel', source: 'nextDueDate' });
271278
});
272279

273280
it('the recurring branch runs the computation BEFORE the create', () => {
@@ -291,10 +298,12 @@ describe('#7037 — the recurrence branch computes a real next due date', () =>
291298
});
292299

293300
it('GUARD: no write node in any app flow puts function-call text in a field value', () => {
294-
// The class, not the instance. `fields` values are interpolated, never
301+
// The class, not the instance. A STRING `fields` value is a literal, never
295302
// evaluated, so ANY `NAME(...)` text in one reaches the driver verbatim —
296-
// whatever the invented function is called. This is the check that would
297-
// have caught the original defect at authoring time.
303+
// whatever the invented function is called. (A computed value is a CEL
304+
// envelope, an object, which this guard leaves to the envelope's own CEL
305+
// check.) This is the check that would have caught the original defect
306+
// at authoring time.
298307
const callShaped = /^[A-Za-z_][A-Za-z0-9_]*\s*\(/;
299308
for (const flow of allFlows) {
300309
for (const n of (flow.nodes ?? []) as any[]) {
@@ -304,7 +313,7 @@ describe('#7037 — the recurrence branch computes a real next due date', () =>
304313
expect(
305314
callShaped.test(value.trim()),
306315
`flow '${flow.name}' node '${n.id}' field '${field}' = ${value} — ` +
307-
`a create/update field value is template-interpolated, not evaluated`,
316+
`a string create/update field value is a literal, not evaluated`,
308317
).toBe(false);
309318
}
310319
}
@@ -369,19 +378,23 @@ describe('#7037 — the recurrence branch computes a real next due date', () =>
369378
expect((Array.isArray(all) ? all : []).length).toBe(1);
370379
}, 30000);
371380

372-
it('REVERSE: the pre-fix shape — a function call left in the field value — is refused by the driver', async () => {
381+
it('REVERSE: the pre-fix shape — a function call left in the field value — is refused at registration', async () => {
373382
// The pre-#7037 defect, rebuilt from the REAL flow so it cannot drift: the
374383
// computation node removed, the recurring edge pointed straight at the
375384
// create, and `due_date` written as text-with-holes wrapping a call to a
376385
// function that does not exist.
377386
//
378387
// The invented name is deliberately NOT the historical one. What failed
379-
// was never specific to `DATEADD`: a `create_record` field value is
380-
// interpolated and passed through verbatim, so ANY function-call text
381-
// reaches the driver as a literal string. Pinning the class keeps the
382-
// dead name out of the repo entirely, which is the other half of this
383-
// card — an invented function name in a shipped example is exactly the
384-
// shape an AI author copies.
388+
// was never specific to `DATEADD`: ANY function-call text in a field
389+
// value was a literal. Pinning the class keeps the dead name out of the
390+
// repo entirely, which is the other half of this card — an invented
391+
// function name in a shipped example is exactly the shape an AI author
392+
// copies.
393+
//
394+
// [#19939] Before, this shape registered and the DRIVER refused the write
395+
// at run time (`Due Date must be a valid date`). The `{…}` tokens are the
396+
// retired template dialect in a value slot now, so it never registers —
397+
// and the refusal names the CEL spelling instead.
385398
const { automation, data } = await bootTodoKernel();
386399
const ctx = { context: { userId: 'u_todo' } };
387400

@@ -393,52 +406,34 @@ describe('#7037 — the recurrence branch computes a real next due date', () =>
393406
'create_next_task';
394407
broken.nodes.find((n: any) => n.id === 'create_next_task').config.fields.due_date =
395408
'SHIFT_DATE({completedTask.due_date}, {completedTask.recurrence_interval}, "{completedTask.recurrence_type}")';
396-
// The fixture is a manual flow: it must not race the real one on the same
397-
// record-change hook.
398409
delete broken.nodes.find((n: any) => n.type === 'start').config.triggerType;
399410
broken.type = 'autolaunched';
400-
automation.registerFlow(broken.name, broken);
401-
402-
// [#14147] Unlike the cases above, this fixture must START from an
403-
// already-completed row — there is no transition to stamp on, and the
404-
// object's `completed_date_required` rule refuses a completed task with a
405-
// blank `completed_date`. Seeding a server-owned column at create time is
406-
// a SYSTEM act (maintainer ruling, 2026-09-03: `context.isSystem`,
407-
// `runAs: 'system'`, a system hook or a seed), so this one write declares
408-
// itself trusted. Nothing else about the case changes: the subject is
409-
// still what the BROKEN flow fixture does with an uncomputed date, and
410-
// the assertions below are untouched.
411+
let refusal = '';
412+
try {
413+
automation.registerFlow(broken.name, broken);
414+
} catch (err) {
415+
refusal = (err as Error).message;
416+
}
417+
expect(refusal, 'the pre-fix shape must not register').toContain(VALUE_SLOT_TEMPLATE_REFUSAL);
418+
expect(refusal).toContain("node 'create_next_task' (create_record) create_record field value at config.fields.due_date");
419+
expect(refusal).not.toMatch(/no function named/);
420+
421+
// [#14147] The real flow needs an already-completed row to start from —
422+
// seeding a server-owned column at create time is a SYSTEM act
423+
// (maintainer ruling, 2026-09-03), so this one write declares itself
424+
// trusted.
411425
const created = await data.insert('todo_task', {
412426
subject: 'Reverse fixture task', status: 'completed', priority: 'normal', owner: 'u_todo',
413427
due_date: '2026-08-10', is_recurring: true, recurrence_type: 'daily', recurrence_interval: 1,
414428
completed_date: '2026-08-09T10:00:00.000Z',
415429
}, { context: { userId: 'u_todo', isSystem: true } });
416430
const id = Array.isArray(created) ? created[0].id : created.id;
417-
418-
// Driven directly rather than through the record-change hook, so the
419-
// context has to supply what the hook would: the triggering record AND the
420-
// `previous` row the start predicate transitions from. Without `previous`
421-
// the CEL gate aborts with `No such key: status` — the same totality trap
422-
// #6882 documented on the insert leg — and the run would fail for a reason
423-
// that has nothing to do with this card.
424431
const record = { ...(Array.isArray(created) ? created[0] : created), id };
425432
const trigger = { record, previous: { ...record, status: 'in_progress' }, userId: 'u_todo' };
426433

427-
const result: any = await automation.execute(broken.name, trigger);
428-
429-
// The direction that matters: the run FAILS, and it fails inside the
430-
// create with the field's own date refusal — NOT with "no function named
431-
// 'SHIFT_DATE' is registered", because nothing ever tried to CALL one.
432-
// The value was never a call; it was always just text.
433-
expect(result?.success, `unexpected result: ${JSON.stringify(result)}`).toBe(false);
434-
const text = String(result?.error ?? '') + JSON.stringify(result?.output ?? null);
435-
expect(text).toContain('create_next_task');
436-
expect(text).toMatch(/valid date|ISO-8601|invalid_date/);
437-
expect(text).not.toMatch(/no function named/);
438-
439434
// Non-vacuous: the REAL flow, on the very same kernel and record, computes
440-
// the date and completes. So the failure above is the missing computation,
441-
// not a broken fixture or an unusable harness.
435+
// the date and completes. So the refusal above is the retired dialect, not
436+
// a broken fixture or an unusable harness.
442437
const good: any = await automation.execute('task_completion', trigger);
443438
expect(good?.success, `unexpected result: ${JSON.stringify(good)}`).toBe(true);
444439
const spawned = (await data.find('todo_task', { where: { subject: 'Reverse fixture task' }, ...ctx }))

0 commit comments

Comments
 (0)