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
53 changes: 53 additions & 0 deletions .changeset/todo-recurrence-next-due-date.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
---
"@objectstack/example-todo": patch
---

fix(example-todo): `task_completion`'s recurrence branch computes a real next due date — it wrote a literal `DATEADD(...)` string the driver refused (#7037)

`examples/app-todo`'s `TaskCompletionFlow` spawned the next occurrence of a recurring task
with

```
due_date: 'DATEADD({completedTask.due_date}, {completedTask.recurrence_interval}, "{completedTask.recurrence_type}")'
```

**Two independent faults, stacked.** `DATEADD` exists nowhere in the platform — not a CEL
builtin, not registered by `packages/formula` under any casing. And a `create_record`
node's `fields` values are TEMPLATE-interpolated, never evaluated: the `{…}` holes are
filled and the surrounding text passes through verbatim. So what reached the engine was
the literal string `DATEADD(2026-08-10, 1, "daily")`, and the field's own coercion refused
it with `Due Date must be a valid date (ISO-8601)`, failing the whole run.

**Reachability changed with #6882; the defect did not.** While the flow was unbound the
node never executed and the dead function text was inert. Armed, every completion of a
*recurring* task produced a failed run, so the recurrence feature the node exists for had
never once worked.

**Why the repair is a `script` node and not a better expression.** No flow node evaluates
a value-producing expression. The builtin vocabulary's only expression slots are
PREDICATES (`config.condition`, `edge.condition`, `decision.conditions[].expression`,
`screen.fields[].visibleWhen`) and `flow-template` REFERENCES (`loop.collection`,
`map.collection`) — the ledger is `FLOW_NODE_EXPRESSION_PATHS` in
`@objectstack/spec/automation` — and an `assignment` node interpolates rather than
evaluates. The next due date therefore has to be computed *before* the create node runs.

A `compute_next_due_date` `script` node now calls `computeNextTaskDueDate`, registered
through `defineStack({ functions })` — the pure-function shape (#1870, #4396) that
`showcase_task_completed` already uses: it takes `input`, returns the date, and
`create_next_task` persists it by reading the whole-string token `{nextDueDate}`. The
function handles all four authored cadences (daily / weekly / monthly / yearly × interval),
clamps a monthly shift to the target month's last day exactly as `@objectstack/formula`'s
`addMonths` does — so the app cannot teach a recurrence semantic that disagrees with the
platform's own formula function — and refuses an unknown `recurrence_type` or an interval
`min: 1` forbids instead of guessing a cadence.

The non-recurring path is unchanged: the `check_recurring` gate still routes straight to
`end`, skipping both nodes.

New suite `test/task-recurrence.test.ts` drives the app's real metadata, real object and
real function registry through a real kernel over sqlite: the spawned task's `due_date` is
asserted for daily / weekly / monthly completions, and a reverse fixture rebuilt from the
live flow shows the pre-fix shape — any function-call text left in a `create_record` field
value — still failing inside `create_next_task` with the date refusal, and notably *not*
with "no function named …", because nothing ever tried to call one. A class-level guard
asserts no write node in any of the app's flows leaves function-call text in a field value.
13 changes: 13 additions & 0 deletions examples/app-todo/objectstack.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import * as datasets from './src/datasets/index.js';
import * as reports from './src/reports/index.js';
import * as views from './src/views/index.js';
import { allFlows } from './src/flows/index.js';
import { todoFunctions } from './src/functions/index.js';
import * as apps from './src/apps/index.js';
import { TodoSeedData } from './src/data/index.js';
import * as translations from './src/translations/index.js';
Expand Down Expand Up @@ -53,6 +54,18 @@ export default defineStack({
datasets: Object.values(datasets),
reports: Object.values(reports),
flows: allFlows,

// Named callables a `script` flow node invokes (#1870) — the automation
// plugin bridges this map to `AutomationEngine.resolveFunction`, so a node's
// `config.function` resolves by name at run time. A flow function is PURE: it
// takes `input`, RETURNS a value, and a later declarative node persists it
// (#4396), which is why none of these declares an `effect`.
//
// `computeNextTaskDueDate` is what makes `task_completion`'s recurrence branch
// work: no flow node evaluates a value-producing expression, so the next due
// date has to be computed before `create_next_task` runs (#7037).
functions: todoFunctions,

apps: Object.values(apps),

// I18n Configuration — per-locale file organization
Expand Down
54 changes: 52 additions & 2 deletions examples/app-todo/src/flows/task.flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,11 @@ export const TaskCompletionFlow: Flow = {
// in the corpus addresses it, so the declaration is gone rather than
// re-plumbed — declared means bound.
{ name: 'completedTask', type: 'record', isInput: false, isOutput: false },
// #7037 — the next due date, computed by the `compute_next_due_date` script
// node below and persisted by `create_next_task`. Declared for the same
// reason `showcase_task_completed` declares its `summary`: a script node's
// `outputVariable` is the flow's contract with the node after it.
{ name: 'nextDueDate', type: 'date', isInput: false, isOutput: false },
],

nodes: [
Expand Down Expand Up @@ -188,6 +193,45 @@ export const TaskCompletionFlow: Flow = {
// on a `start` node and inert everywhere else, so it was a third copy of the
// same predicate, doing nothing (#4414).
{ id: 'check_recurring', type: 'decision', label: 'Is Recurring Task?' },
// #7037 — the date arithmetic the recurrence needs, on the one surface that
// actually evaluates anything.
//
// `due_date` below used to read
// `DATEADD({completedTask.due_date}, {completedTask.recurrence_interval}, "…")`.
// Two independent faults: `DATEADD` exists nowhere in the platform (not a
// CEL builtin, not registered by `packages/formula` under any casing), and a
// `create_record` node's `fields` are TEMPLATE-interpolated rather than
// evaluated — the `{…}` holes are filled and the surrounding text passed
// through verbatim. So the driver received the literal string
// `DATEADD(2026-08-10, 1, "daily")` and refused the write with `Due Date
// must be a valid date (ISO-8601)`, failing the whole run. Dead while the
// flow was unbound; live on every recurring completion once #6882 armed it.
//
// Computing it here is not a stylistic preference — no flow node evaluates a
// value-producing expression. The builtin vocabulary's only expression slots
// are PREDICATES (`config.condition`, `edge.condition`,
// `decision.conditions[].expression`, `screen.fields[].visibleWhen`) and
// `flow-template` REFERENCES (`loop.collection`, `map.collection`) — the
// ledger is `FLOW_NODE_EXPRESSION_PATHS` in `@objectstack/spec/automation`,
// and an `assignment` node interpolates rather than evaluates. A `script`
// node calling a registered function is the shipped way to compute a value
// mid-flow (#1870), and the pure-function shape — takes `input`, RETURNS a
// value, a later declarative node persists it (#4396) — is the one
// `showcase_task_completed` already uses.
{
id: 'compute_next_due_date', type: 'script', label: 'Compute Next Due Date',
config: {
// Registered in `defineStack({ functions })` — see objectstack.config.ts
// and src/functions/task.functions.ts.
function: 'computeNextTaskDueDate',
inputs: {
dueDate: '{completedTask.due_date}',
recurrenceType: '{completedTask.recurrence_type}',
interval: '{completedTask.recurrence_interval}',
},
outputVariable: 'nextDueDate',
},
},
{
id: 'create_next_task', type: 'create_record', label: 'Create Next Recurring Task',
config: {
Expand All @@ -198,7 +242,9 @@ export const TaskCompletionFlow: Flow = {
owner: '{completedTask.owner}', is_recurring: true,
recurrence_type: '{completedTask.recurrence_type}',
recurrence_interval: '{completedTask.recurrence_interval}',
due_date: 'DATEADD({completedTask.due_date}, {completedTask.recurrence_interval}, "{completedTask.recurrence_type}")',
// A whole-string token, so `interpolate()` hands the create the RAW
// value the script node returned instead of a stringified copy.
due_date: '{nextDueDate}',
status: 'not_started', is_completed: false,
},
outputVariable: 'newTaskId',
Expand All @@ -210,9 +256,13 @@ export const TaskCompletionFlow: Flow = {
edges: [
{ id: 'e1', source: 'start', target: 'get_task', type: 'default' },
{ id: 'e2', source: 'get_task', target: 'check_recurring', type: 'default' },
{ id: 'e3', source: 'check_recurring', target: 'create_next_task', type: 'default', condition: 'vars.completedTask.is_recurring == true', label: 'Yes' },
// The recurring branch now runs `compute_next_due_date` first (#7037); the
// gate itself is unchanged, so the non-recurring path still routes straight
// to `end` and skips both nodes.
{ id: 'e3', source: 'check_recurring', target: 'compute_next_due_date', type: 'default', condition: 'vars.completedTask.is_recurring == true', label: 'Yes' },
{ id: 'e4', source: 'check_recurring', target: 'end', type: 'default', condition: 'vars.completedTask.is_recurring != true', label: 'No' },
{ id: 'e5', source: 'create_next_task', target: 'end', type: 'default' },
{ id: 'e6', source: 'compute_next_due_date', target: 'create_next_task', type: 'default' },
],
};

Expand Down
18 changes: 18 additions & 0 deletions examples/app-todo/src/functions/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Flow Functions Barrel — the named callables `script` nodes invoke.
*
* `todoFunctions` is what `defineStack({ functions })` registers; the automation
* plugin bridges that map to `AutomationEngine.resolveFunction`, which is how a
* `script` node's `config.function` resolves at run time (#1870).
*/

export { computeNextTaskDueDate } from './task.functions';

import { computeNextTaskDueDate } from './task.functions';

/** Name → handler map for `defineStack({ functions })`. */
export const todoFunctions = {
computeNextTaskDueDate,
};
126 changes: 126 additions & 0 deletions examples/app-todo/src/functions/task.functions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Named callables the app's `script` flow nodes invoke, registered through
* `defineStack({ functions })` in `objectstack.config.ts` (#1870).
*
* A flow function is PURE (#4396): it takes `input`, RETURNS a value, and a
* later declarative node persists it. It does no data I/O of its own, which is
* why none of these declares an `effect`.
*/

/** The recurrence cadences `todo_task.recurrence_type` offers. */
const RECURRENCE_TYPES = ['daily', 'weekly', 'monthly', 'yearly'] as const;
type RecurrenceType = (typeof RECURRENCE_TYPES)[number];

/** Coerce a `Field.date` value to a `Date`. It reaches a flow as a `Date`, an
* ISO string, or an epoch number depending on the driver, so all three are
* accepted — the same coercion `@objectstack/formula`'s date functions do. */
function toDate(value: unknown): Date {
if (value instanceof Date) return new Date(value.getTime());
if (typeof value === 'number') return new Date(value);
return new Date(String(value));
}

/** Add `n` days in UTC. */
function addDaysUtc(d: Date, n: number): Date {
const out = new Date(d.getTime());
out.setUTCDate(out.getUTCDate() + n);
return out;
}

/**
* Add `n` calendar months in UTC, clamping the day to the target month's last
* day so Jan 31 + 1 month is Feb 28 rather than Mar 3.
*
* Deliberately the same rule as `@objectstack/formula`'s `addMonths` (see
* `packages/formula/src/stdlib.ts`), so a monthly recurrence spawned by this
* flow lands on the same day a `addMonths(...)` formula field would compute for
* it. Two different answers for "one month after Jan 31" inside one app is the
* kind of drift an example must not demonstrate.
*/
function addMonthsUtc(d: Date, n: number): Date {
const out = new Date(d.getTime());
const day = out.getUTCDate();
out.setUTCDate(1);
out.setUTCMonth(out.getUTCMonth() + n);
const lastDay = new Date(Date.UTC(out.getUTCFullYear(), out.getUTCMonth() + 1, 0)).getUTCDate();
out.setUTCDate(Math.min(day, lastDay));
return out;
}

/** `YYYY-MM-DD` — the calendar-date form a `Field.date` stores and compares on. */
function toCalendarDate(d: Date): string {
return d.toISOString().slice(0, 10);
}

/** One cadence per authored `recurrence_type` option — total over the select. */
const NEXT_DUE_BY_TYPE: Record<RecurrenceType, (due: Date, interval: number) => Date> = {
daily: (due, interval) => addDaysUtc(due, interval),
weekly: (due, interval) => addDaysUtc(due, interval * 7),
monthly: (due, interval) => addMonthsUtc(due, interval),
yearly: (due, interval) => addMonthsUtc(due, interval * 12),
};

/**
* Next due date for a recurring task: the completed task's `due_date` shifted
* by `recurrence_interval` units of `recurrence_type`.
*
* **Why this is a function and not an expression** (#7037). `TaskCompletionFlow`
* used to write the literal string
* `DATEADD({completedTask.due_date}, {completedTask.recurrence_interval}, "...")`
* into `create_record`'s `due_date`. `DATEADD` exists nowhere in the platform,
* and a `create_record` node's `fields` are TEMPLATE-interpolated rather than
* evaluated — the `{...}` holes are filled and the surrounding text passed
* through verbatim — so the driver received `DATEADD(2026-08-10, 1, "daily")`
* and refused the write with `Due Date must be a valid date (ISO-8601)`.
*
* No flow node evaluates a value-producing expression: the builtin vocabulary's
* only expression slots are PREDICATES (`config.condition`, `edge.condition`,
* `decision.conditions[].expression`, `screen.fields[].visibleWhen`) and
* `flow-template` references (`loop.collection`, `map.collection`) — see
* `FLOW_NODE_EXPRESSION_PATHS` in `@objectstack/spec/automation`. An
* `assignment` node interpolates too; it does not evaluate. So the value has to
* be computed BEFORE the create node, and a `script` node calling this function
* is the shipped way to do that.
*
* @param input.dueDate the completed task's `due_date`
* @param input.recurrenceType one of daily / weekly / monthly / yearly
* @param input.interval `recurrence_interval` (schema default 1, min 1)
* @returns the next due date as `YYYY-MM-DD`, or `null` when the completed task
* carried no `due_date` — with no previous due date there is nothing to shift,
* and the spawned task starts undated rather than on an invented day.
*/
export function computeNextTaskDueDate({ input }: { input: Record<string, unknown> }): string | null {
const { dueDate, recurrenceType, interval } = input;

if (dueDate === null || dueDate === undefined || dueDate === '') return null;
const due = toDate(dueDate);
if (Number.isNaN(due.getTime())) {
throw new Error(`computeNextTaskDueDate: '${String(dueDate)}' is not a valid due date`);
}

const type = String(recurrenceType ?? '').trim().toLowerCase();
const next = NEXT_DUE_BY_TYPE[type as RecurrenceType];
// Refuse loudly rather than guessing a cadence. An unknown value here means
// the record disagrees with `todo_task.recurrence_type`'s own option list, and
// a silently-skipped recurrence is the failure mode this whole card is about.
if (!next) {
throw new Error(
`computeNextTaskDueDate: unknown recurrence_type '${String(recurrenceType)}' ` +
`(expected one of ${RECURRENCE_TYPES.join(', ')})`,
);
}

// An absent interval takes the field's own declared default (1), which is what
// the schema would have written; anything present but not a positive whole
// number is a contradiction of `min: 1` and refuses rather than being coerced.
const steps = interval === null || interval === undefined || interval === '' ? 1 : Number(interval);
if (!Number.isFinite(steps) || steps < 1) {
throw new Error(
`computeNextTaskDueDate: recurrence_interval must be a number >= 1, got '${String(interval)}'`,
);
}

return toCalendarDate(next(due, Math.trunc(steps)));
}
Loading
Loading