From c0aefbdf682be3892230aaecc24a7f7b527302c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 03:41:04 +0000 Subject: [PATCH 1/2] fix(example-todo): remove the inert is_completed/is_overdue flags and repair every filter that read them --- examples/app-todo/README.md | 7 +- .../app-todo/src/dashboards/task.dashboard.ts | 12 +- examples/app-todo/src/flows/task.flow.ts | 6 +- examples/app-todo/src/objects/task.hook.ts | 17 +- examples/app-todo/src/objects/task.object.ts | 44 ++- examples/app-todo/src/reports/task.report.ts | 8 +- examples/app-todo/src/translations/en.ts | 2 - examples/app-todo/src/translations/ja-JP.ts | 2 - examples/app-todo/src/translations/zh-CN.ts | 2 - examples/app-todo/src/views/task.view.ts | 12 +- .../test/derived-flag-removal.test.ts | 296 ++++++++++++++++++ 11 files changed, 366 insertions(+), 42 deletions(-) create mode 100644 examples/app-todo/test/derived-flag-removal.test.ts diff --git a/examples/app-todo/README.md b/examples/app-todo/README.md index 16c82b7de4..cc78fb40a2 100644 --- a/examples/app-todo/README.md +++ b/examples/app-todo/README.md @@ -56,7 +56,7 @@ examples/app-todo/ - ✅ **Select** (`status`, `priority`, `category`) — Single-select with colors - ✅ **Multi-Select** (`tags`) — Multiple tag selection - ✅ **Date / DateTime** (`due_date`, `reminder_date`, `completed_date`) -- ✅ **Boolean** (`is_completed`, `is_overdue`, `is_recurring`) +- ✅ **Boolean** (`is_recurring`) - ✅ **Number** (`estimated_hours`, `actual_hours`, `recurrence_interval`) - ✅ **Percent** (`progress_percent`) — Progress tracking - ✅ **Lookup** (`owner`) — User assignment @@ -90,8 +90,9 @@ examples/app-todo/ ### Validations & Automation - Completed date required when status is "completed" (validation rule) - Recurrence type required for recurring tasks (validation rule) -- Auto-set `is_completed`, `completed_date`, `progress_percent` on status - change (data hook) +- Auto-set `completed_date` on the completion transition, cleared on reopen + (data hook). Completion and overdue state are read from `status` / `due_date` + directly — the app declares no derived boolean flags (#7226) - Auto-detect overdue tasks and send urgent notifications (flow) ## 💡 How to Run diff --git a/examples/app-todo/src/dashboards/task.dashboard.ts b/examples/app-todo/src/dashboards/task.dashboard.ts index 1decd2bf0f..78beba22e0 100644 --- a/examples/app-todo/src/dashboards/task.dashboard.ts +++ b/examples/app-todo/src/dashboards/task.dashboard.ts @@ -32,7 +32,7 @@ export const TaskDashboard: Dashboard = { id: 'completed_today', title: 'Completed Today', type: 'metric', - filter: { is_completed: true, completed_date: { $gte: '{today}' } }, + filter: { status: 'completed', completed_date: { $gte: '{today}' } }, dataset: 'task_metrics', values: ['task_count'], layout: { x: 3, y: 0, w: 3, h: 2 }, @@ -42,7 +42,7 @@ export const TaskDashboard: Dashboard = { id: 'overdue_tasks', title: 'Overdue Tasks', type: 'metric', - filter: { is_overdue: true, is_completed: false }, + filter: { due_date: { $lt: '{today}' }, status: { $ne: 'completed' } }, dataset: 'task_metrics', values: ['task_count'], layout: { x: 6, y: 0, w: 3, h: 2 }, @@ -66,7 +66,7 @@ export const TaskDashboard: Dashboard = { id: 'tasks_by_status', title: 'Tasks by Status', type: 'pie', - filter: { is_completed: false }, + filter: { status: { $ne: 'completed' } }, dataset: 'task_metrics', dimensions: ['status'], values: ['task_count'], @@ -78,7 +78,7 @@ export const TaskDashboard: Dashboard = { id: 'tasks_by_priority', title: 'Tasks by Priority', type: 'bar', - filter: { is_completed: false }, + filter: { status: { $ne: 'completed' } }, dataset: 'task_metrics', dimensions: ['priority'], values: ['task_count'], @@ -92,7 +92,7 @@ export const TaskDashboard: Dashboard = { id: 'weekly_task_completion', title: 'Weekly Task Completion', type: 'line', - filter: { is_completed: true, completed_date: { $gte: '{4_weeks_ago}' } }, + filter: { status: 'completed', completed_date: { $gte: '{4_weeks_ago}' } }, dataset: 'task_metrics', dimensions: ['completed_date'], values: ['task_count'], @@ -104,7 +104,7 @@ export const TaskDashboard: Dashboard = { id: 'tasks_by_category', title: 'Tasks by Category', type: 'donut', - filter: { is_completed: false }, + filter: { status: { $ne: 'completed' } }, dataset: 'task_metrics', dimensions: ['category'], values: ['task_count'], diff --git a/examples/app-todo/src/flows/task.flow.ts b/examples/app-todo/src/flows/task.flow.ts index d26037c204..7f233f399e 100644 --- a/examples/app-todo/src/flows/task.flow.ts +++ b/examples/app-todo/src/flows/task.flow.ts @@ -23,7 +23,7 @@ export const TaskReminderFlow: Flow = { // `limit > 1` is the declared way to make this a LIST read (`find`, not // `findOne`) — the undeclared `getAll` that sat here was never read, so // this sweep silently fetched a single task (#4277 rejects the key now). - config: { objectName: 'todo_task', filter: { due_date: '{tomorrow}', is_completed: false }, outputVariable: 'tasksToRemind', limit: 200 }, + config: { objectName: 'todo_task', filter: { due_date: '{tomorrow}', status: { $ne: 'completed' } }, outputVariable: 'tasksToRemind', limit: 200 }, }, { id: 'loop_tasks', type: 'loop', label: 'Loop Through Tasks', @@ -75,7 +75,7 @@ export const OverdueEscalationFlow: Flow = { // `limit > 1` = LIST read; the undeclared `getAll` was never read (#4277). config: { objectName: 'todo_task', - filter: { due_date: { $lt: '{3_days_ago}' }, is_completed: false, is_overdue: true }, + filter: { due_date: { $lt: '{3_days_ago}' }, status: { $ne: 'completed' } }, outputVariable: 'overdueTasks', limit: 200, }, }, @@ -245,7 +245,7 @@ export const TaskCompletionFlow: Flow = { // 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, + status: 'not_started', }, outputVariable: 'newTaskId', }, diff --git a/examples/app-todo/src/objects/task.hook.ts b/examples/app-todo/src/objects/task.hook.ts index b9d98f1402..0e20efbf5f 100644 --- a/examples/app-todo/src/objects/task.hook.ts +++ b/examples/app-todo/src/objects/task.hook.ts @@ -105,10 +105,19 @@ const taskHook: Hook = { // Could trigger notifications or integrations here } - // Check if task became overdue - if (data.is_overdue && previous && !previous.is_overdue) { - logger?.info?.(`Task ${ctx.input.id} is now overdue`); - } + // [#7226] A "task became overdue" leg USED TO SIT HERE, gated on + // `data.is_overdue && previous && !previous.is_overdue`. It could never + // run: `is_overdue` was a `readonly` boolean nothing ever wrote, so + // `data.is_overdue` was absent on every update and the branch was dead + // code that read as working automation. + // + // It is not re-armed against `due_date`, and that is deliberate. Becoming + // overdue is the passage of TIME, not a record write — a task nobody + // touches crosses its due date with no update to observe, so a record hook + // is structurally the wrong instrument and any version of this branch would + // fire late, or never. The clock-driven sweep already exists in the right + // place: `flows/task.flow.ts`'s `overdue_escalation`, a scheduled flow that + // runs daily and selects on `due_date` directly. } } }; diff --git a/examples/app-todo/src/objects/task.object.ts b/examples/app-todo/src/objects/task.object.ts index 393e8d5bc4..d1bee05fa9 100644 --- a/examples/app-todo/src/objects/task.object.ts +++ b/examples/app-todo/src/objects/task.object.ts @@ -125,19 +125,37 @@ export const Task = ObjectSchema.create({ min: 1, }), - // Flags - is_completed: Field.boolean({ - label: 'Is Completed', - defaultValue: false, - readonly: true, - }), - - is_overdue: Field.boolean({ - label: 'Is Overdue', - defaultValue: false, - readonly: true, - }), - + // [#7226] `is_completed` / `is_overdue` USED TO LIVE HERE, and were removed. + // + // Both were `readonly: true` booleans defaulting to `false` that nothing in + // the app ever wrote — no hook leg, no flow node, no action handler, and the + // seed data set neither. They were therefore `false` on every row for the + // life of the app, while twelve view / dashboard / report / flow filters + // read them as if they were maintained. `is_completed: true` tiles ("Completed + // Today", "Weekly Task Completion", the two completed-task reports) were + // permanently empty, and the divergence became visible once #7036 started + // stamping `completed_date` on the completion transition: a task could carry + // a completion date and `is_completed: false` at the same time. + // + // They are GONE rather than derived, and the reason is measured, not + // stylistic. `Field.formula(...)` computes correctly for both — including the + // temporal one (`date(record.due_date) < today()` evaluates per read, with a + // per-call `now` snapshot) — but a formula field is VIRTUAL: no driver + // materialises a column for it, so a FILTER naming one matches nothing. + // Measured on this app's own sqlite-wasm driver: `where { is_completed: false }` + // against a formula field returns 0 rows with no error, where the stored + // boolean returned every row. Deriving them would have silently emptied the + // "Due Today" view, the reminder flow and both open-task reports — trading a + // wrong answer for an invisible one. + // + // `status` and `due_date` are stored, indexed columns that already carry the + // information, so every consumer now asks them directly: + // is_completed == true -> status equals 'completed' + // is_completed == false -> status not_equals 'completed' + // is_overdue == true -> due_date less_than '{today}' AND status not_equals 'completed' + // Consistent by construction, with no second writer that can drift — which is + // the pattern a reference app should be teaching. + // Progress progress_percent: Field.percent({ label: 'Progress (%)', diff --git a/examples/app-todo/src/reports/task.report.ts b/examples/app-todo/src/reports/task.report.ts index eb97541824..e87b443bb6 100644 --- a/examples/app-todo/src/reports/task.report.ts +++ b/examples/app-todo/src/reports/task.report.ts @@ -28,7 +28,7 @@ export const TasksByPriorityReport = defineReport({ dataset: 'task_metrics', rows: ['priority'], values: ['task_count'], - runtimeFilter: { is_completed: false }, + runtimeFilter: { status: { $ne: 'completed' } }, }); /** Tasks by Owner Report */ @@ -40,7 +40,7 @@ export const TasksByOwnerReport = defineReport({ dataset: 'task_metrics', rows: ['owner'], values: ['est_hours', 'actual_hours'], - runtimeFilter: { is_completed: false }, + runtimeFilter: { status: { $ne: 'completed' } }, }); // ADR-0021 Phase 2: the former `OverdueTasksReport` (a flat record list, no @@ -57,7 +57,7 @@ export const CompletedTasksReport = defineReport({ dataset: 'task_metrics', rows: ['category'], values: ['est_hours', 'actual_hours'], - runtimeFilter: { is_completed: true }, + runtimeFilter: { status: 'completed' }, }); /** Time Tracking Report */ @@ -72,5 +72,5 @@ export const TimeTrackingReport = defineReport({ dataset: 'task_metrics', rows: ['owner', 'category'], values: ['est_hours', 'actual_hours'], - runtimeFilter: { is_completed: true }, + runtimeFilter: { status: 'completed' }, }); diff --git a/examples/app-todo/src/translations/en.ts b/examples/app-todo/src/translations/en.ts index 81e62a4610..7d15832a6e 100644 --- a/examples/app-todo/src/translations/en.ts +++ b/examples/app-todo/src/translations/en.ts @@ -71,8 +71,6 @@ export const en: TranslationData = { }, }, recurrence_interval: { label: 'Recurrence Interval' }, - is_completed: { label: 'Is Completed' }, - is_overdue: { label: 'Is Overdue' }, progress_percent: { label: 'Progress (%)' }, estimated_hours: { label: 'Estimated Hours' }, actual_hours: { label: 'Actual Hours' }, diff --git a/examples/app-todo/src/translations/ja-JP.ts b/examples/app-todo/src/translations/ja-JP.ts index 82aeed834a..042f63b5fe 100644 --- a/examples/app-todo/src/translations/ja-JP.ts +++ b/examples/app-todo/src/translations/ja-JP.ts @@ -70,8 +70,6 @@ export const jaJP: TranslationData = { }, }, recurrence_interval: { label: '繰り返し間隔' }, - is_completed: { label: '完了済み' }, - is_overdue: { label: '期限超過' }, progress_percent: { label: '進捗率 (%)' }, estimated_hours: { label: '見積時間' }, actual_hours: { label: '実績時間' }, diff --git a/examples/app-todo/src/translations/zh-CN.ts b/examples/app-todo/src/translations/zh-CN.ts index 27acb342a6..99b7e7cf54 100644 --- a/examples/app-todo/src/translations/zh-CN.ts +++ b/examples/app-todo/src/translations/zh-CN.ts @@ -74,8 +74,6 @@ export const zhCN: TranslationData = { }, }, recurrence_interval: { label: '重复间隔' }, - is_completed: { label: '是否完成' }, - is_overdue: { label: '是否逾期' }, progress_percent: { label: '进度 (%)' }, estimated_hours: { label: '预估工时' }, actual_hours: { label: '实际工时' }, diff --git a/examples/app-todo/src/views/task.view.ts b/examples/app-todo/src/views/task.view.ts index 25eaa2c842..c38e40d5e4 100644 --- a/examples/app-todo/src/views/task.view.ts +++ b/examples/app-todo/src/views/task.view.ts @@ -46,9 +46,13 @@ export const TaskViews = defineView({ { field: 'owner' }, { field: 'category' }, ], + // [#7226] "Overdue" asked the removed `is_overdue`/`is_completed` flags, + // which nothing maintained — so this view was permanently EMPTY. It now + // asks the stored, indexed columns that carry the same fact: past due, and + // not finished. `{today}` is the platform date macro, resolved per request. filter: [ - { field: 'is_overdue', operator: 'equals', value: true }, - { field: 'is_completed', operator: 'equals', value: false }, + { field: 'due_date', operator: 'less_than', value: '{today}' }, + { field: 'status', operator: 'not_equals', value: 'completed' }, ], sort: [{ field: 'due_date', order: 'asc' }], }, @@ -65,9 +69,11 @@ export const TaskViews = defineView({ { field: 'owner' }, { field: 'category' }, ], + // [#7226] `is_completed == false` was vacuously true for every row; it now + // asks `status` directly so a completed task really does drop out. filter: [ { field: 'due_date', operator: 'equals', value: '{today}' }, - { field: 'is_completed', operator: 'equals', value: false }, + { field: 'status', operator: 'not_equals', value: 'completed' }, ], sort: [{ field: 'priority', order: 'desc' }], }, diff --git a/examples/app-todo/test/derived-flag-removal.test.ts b/examples/app-todo/test/derived-flag-removal.test.ts new file mode 100644 index 0000000000..189c8be515 --- /dev/null +++ b/examples/app-todo/test/derived-flag-removal.test.ts @@ -0,0 +1,296 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#7226] `is_completed` / `is_overdue` are GONE, and every surface that read + * them now asks a stored column. + * + * ## What was wrong + * + * Both were `readonly: true` booleans defaulting to `false` that nothing in the + * app ever wrote — no hook leg, no flow node, no action handler, and the seed + * data set neither. They were `false` on every row for the life of the app, + * while twelve view / dashboard / report / flow filters read them as if they + * were maintained. Every `is_completed: true` surface ("Completed Today", the + * weekly-completion trend, both completed-task reports) and the whole "Overdue + * Tasks" view were therefore permanently EMPTY, and `task.hook.ts` carried an + * `afterUpdate` branch gated on `data.is_overdue && previous && !previous.is_overdue` + * that could never run. + * + * ## Why removed rather than derived as formulas + * + * A formula computes both correctly — including the temporal one — so the + * obvious repair looks available. It is not, and the reason is a STORAGE fact + * rather than a taste judgment: a `formula` field is virtual, no driver + * materialises a column for it, and so a FILTER naming one matches nothing. + * That is measured here, not asserted — {@link REVERSE} registers the + * formula-shaped object and shows `where { is_completed: false }` answering + * **0 rows with no error** where the stored column answers every row. Deriving + * would have silently emptied the "Due Today" view, the daily reminder flow and + * both open-task reports: a wrong answer traded for an invisible one. + * + * `status` and `due_date` are stored, indexed columns that already carry the + * information, and both are declared dimensions on the `task_metrics` dataset, + * so the dashboard/report filters now sit on the semantic layer's own vocabulary. + * + * ## What these tests pin + * + * 1. **Nothing references the removed fields** — a recursive walk of the app's + * REAL `defineStack` (objects, views, dashboards, reports, datasets, flows, + * actions, translations and seed data in one pass), so a dangling reference + * re-introduced anywhere fails here rather than at runtime as a silent zero. + * 2. **The replacements actually select** — driven against the real engine + * across BOTH sides of the completion transition. This is the anti-vacuity + * half: a filter asserted only on a never-completed task is green for the + * same reason the old broken flag was green (everything is false), so each + * assertion below checks a row moving INTO and OUT OF the selected set. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { ObjectKernel } from '@objectstack/core'; +import { ObjectQLPlugin } from '@objectstack/objectql'; +import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; + +import { Task } from '../src/objects/task.object.js'; +import taskHook from '../src/objects/task.hook.js'; +import TodoApp from '../objectstack.config.js'; + +/** The two fields this card retired. */ +const REMOVED_FIELDS = ['is_completed', 'is_overdue'] as const; + +const openDrivers: Array<{ disconnect?: () => Promise }> = []; +afterEach(async () => { + while (openDrivers.length) { + try { await openDrivers.pop()?.disconnect?.(); } catch { /* noop */ } + } +}); + +/** + * A real kernel over in-process sqlite-wasm with the app's real `todo_task` + * object and its real lifecycle hook — the same shape + * `task-completion-trigger.test.ts` boots, so the completion stamp behaves here + * exactly as it does in the app. + */ +async function bootEngine(objectDef: unknown = Task) { + const kernel = new ObjectKernel({ logger: { level: 'silent' } } as any); + await kernel.use(new ObjectQLPlugin()); + await kernel.bootstrap(); + const objectql: any = kernel.getService('objectql'); + + const driver: any = new SqliteWasmDriver({ filename: ':memory:' }); + await driver.connect(); + objectql.registerDriver(driver, true); + openDrivers.push(driver); + objectql.registry.registerObject(objectDef, 'todo', 'todo'); + await objectql.syncSchemas(); + objectql.bindHooks([taskHook], { packageId: 'app:com.example.todo' }); + return objectql; +} + +/** + * Every string that appears anywhere in `value` — as an object KEY or as a + * string VALUE — flattened with the path that reached it. + * + * Both halves matter and they catch different re-introductions: a filter written + * as `{ is_completed: false }` hides the name in a KEY, while a view filter + * `{ field: 'is_completed', ... }` hides it in a VALUE. Cycles are guarded so a + * metadata graph with back-references cannot hang the walk. + */ +function walkStrings(value: unknown, path = '$', seen = new WeakSet()): Array<[string, string]> { + const out: Array<[string, string]> = []; + if (typeof value === 'string') { out.push([path, value]); return out; } + if (!value || typeof value !== 'object') return out; + if (seen.has(value as object)) return out; + seen.add(value as object); + if (Array.isArray(value)) { + value.forEach((v, i) => out.push(...walkStrings(v, `${path}[${i}]`, seen))); + return out; + } + for (const [k, v] of Object.entries(value as Record)) { + out.push([`${path}.${k}`, k]); // the KEY itself + out.push(...walkStrings(v, `${path}.${k}`, seen)); + } + return out; +} + +describe('#7226 — the inert derived flags are removed, app-wide', () => { + it('the object no longer declares either field', () => { + const fields = (Task as unknown as { fields: Record }).fields; + for (const name of REMOVED_FIELDS) { + expect(fields, `todo_task must not declare '${name}'`).not.toHaveProperty(name); + } + // Non-vacuous: the columns that replaced them are really there, so this + // test cannot pass by the object failing to load. + expect(fields).toHaveProperty('status'); + expect(fields).toHaveProperty('due_date'); + expect(fields).toHaveProperty('completed_date'); + }); + + it('NOTHING in the whole app stack references either field — keys or values', () => { + // The app's real `defineStack` default export: objects, views, dashboards, + // reports, datasets, flows, actions, apps, translations and seed data. + const hits = walkStrings(TodoApp) + .filter(([, s]) => (REMOVED_FIELDS as readonly string[]).includes(s)); + + expect( + hits.map(([p, s]) => `${p} -> ${s}`), + 'a removed flag is still referenced; that filter can only ever match zero rows', + ).toEqual([]); + + // Non-vacuous: the walk really does reach deep filter internals. If this + // ever goes empty the assertion above is worthless, so pin a name that IS + // expected to appear at depth. + const statusHits = walkStrings(TodoApp).filter(([, s]) => s === 'status'); + expect(statusHits.length, 'the walk must actually reach filter internals').toBeGreaterThan(5); + }); + + it("the hook's afterUpdate no longer carries the unreachable overdue branch", () => { + // Read the handler's own source: the branch was dead code, so no runtime + // observation can distinguish "removed" from "never fired". + const src = String((taskHook as unknown as { handler: unknown }).handler); + expect(src).not.toMatch(/\bis_overdue\b/); + // The completion log leg is untouched and still present. + expect(src).toMatch(/completed by/); + }); +}); + +describe('#7226 — the replacement filters really select, on BOTH sides of the transition', () => { + /** Fixed calendar anchors either side of "now", so the pin cannot drift. */ + const PAST = '2020-01-01'; + const FUTURE = '2999-01-01'; + + it('status replaces is_completed — and the set FLIPS on the completion transition', async () => { + const ql = await bootEngine(); + const t = await ql.insert('todo_task', { subject: 'write the report', status: 'in_progress', priority: 'normal' }); + const id = t.id ?? t._id; + + const open = () => ql.find('todo_task', { where: { status: { $ne: 'completed' } } }); + const done = () => ql.find('todo_task', { where: { status: 'completed' } }); + + // BEFORE: the task is open. This is the state the old flag also reported + // correctly (by accident), so on its own it proves nothing. + expect((await open()).map((r: any) => r.id ?? r._id)).toEqual([id]); + expect(await done()).toEqual([]); + + // Drive the real completion transition through the real hook. + await ql.update('todo_task', id, { status: 'completed' }); + + // AFTER: the sets have swapped. THIS is the half the old `is_completed` + // flag failed — it stayed `false`, so "Completed Today" and both completed + // reports stayed empty forever while `completed_date` was stamped. + expect(await open()).toEqual([]); + const completed = await done(); + expect(completed.map((r: any) => r.id ?? r._id)).toEqual([id]); + // ...and it is consistent with the #7036 stamp by construction, which is + // the divergence this card was filed for. + expect(completed[0].completed_date, 'completion date and completion state agree').toBeTruthy(); + + // Reopening puts it back — the filter tracks the column in both directions. + await ql.update('todo_task', id, { status: 'in_progress' }); + expect((await open()).map((r: any) => r.id ?? r._id)).toEqual([id]); + expect(await done()).toEqual([]); + }); + + it('due_date + status replaces is_overdue — selecting exactly the overdue rows', async () => { + const ql = await bootEngine(); + const mk = async (subject: string, status: string, due?: string) => + (await ql.insert('todo_task', { subject, status, priority: 'normal', ...(due ? { due_date: due } : {}) })); + + const late = await mk('late', 'in_progress', PAST); + const soon = await mk('not yet due', 'in_progress', FUTURE); + const noDue = await mk('no due date', 'not_started'); + const lateDone = await mk('late but finished', 'completed', PAST); + + // The shape the `overdue` view and the "Overdue Tasks" tile now declare. + const overdue = await ql.find('todo_task', { + where: { due_date: { $lt: new Date().toISOString().slice(0, 10) }, status: { $ne: 'completed' } }, + }); + const ids = overdue.map((r: any) => r.id ?? r._id); + + // Exactly one row qualifies, and each exclusion is a DIFFERENT reason — + // future due date, no due date, and already completed. + expect(ids).toEqual([late.id ?? late._id]); + expect(ids).not.toContain(soon.id ?? soon._id); + expect(ids).not.toContain(noDue.id ?? noDue._id); + expect(ids).not.toContain(lateDone.id ?? lateDone._id); + + // Non-vacuous: all four rows exist and are visible to an unfiltered read, + // so the three exclusions are the filter working, not an empty table. + expect(await ql.find('todo_task', {})).toHaveLength(4); + }); +}); + +/** + * REVERSE VERIFICATION — the measurement that chose removal over derivation. + * + * Predicted direction, recorded BEFORE running it: the formula field READS + * correctly (so "just derive it" looks right) but is UNFILTERABLE, and the + * failure is silent — 0 rows, no error — rather than an exception. That + * asymmetry is the whole argument: an exception would have been safe, because + * someone would have seen it. + */ +describe('REVERSE — why the derive route was rejected, measured', () => { + /** `todo_task` as it would look on the derive route. */ + const DERIVED = { + name: 'derived_task', + label: 'Derived Task', + fields: { + id: { name: 'id', label: 'ID', type: 'text' as const, primaryKey: true }, + subject: { name: 'subject', label: 'Subject', type: 'text' as const }, + status: { name: 'status', label: 'Status', type: 'text' as const }, + due_date: { name: 'due_date', label: 'Due', type: 'date' as const }, + is_completed: { + name: 'is_completed', label: 'Is Completed', type: 'formula' as const, + expression: { dialect: 'cel', source: 'record.status == "completed"' }, + }, + is_overdue: { + name: 'is_overdue', label: 'Is Overdue', type: 'formula' as const, + expression: { + dialect: 'cel', + source: 'record.status != "completed" && !isBlank(record.due_date) && date(record.due_date) < today()', + }, + }, + }, + }; + + it('a formula field COMPUTES both flags correctly — including the temporal one', async () => { + const ql = await bootEngine(DERIVED); + await ql.insert('derived_task', { id: 'a', subject: 'done', status: 'completed', due_date: '2020-01-01' }); + await ql.insert('derived_task', { id: 'b', subject: 'late', status: 'in_progress', due_date: '2020-01-01' }); + await ql.insert('derived_task', { id: 'c', subject: 'later', status: 'in_progress', due_date: '2999-01-01' }); + await ql.insert('derived_task', { id: 'd', subject: 'undated', status: 'not_started' }); + + const byId = Object.fromEntries((await ql.find('derived_task', {})).map((r: any) => [r.id, r])); + + // `today()` in a stored formula FIELD is legitimate and evaluates per read. + expect(byId.a.is_completed).toBe(true); + expect(byId.a.is_overdue).toBe(false); // completed, so not overdue + expect(byId.b.is_completed).toBe(false); + expect(byId.b.is_overdue).toBe(true); // past due and open + expect(byId.c.is_overdue).toBe(false); // due in the future + expect(byId.d.is_overdue).toBe(false); // no due date at all + }); + + it('...and is UNFILTERABLE: 0 rows, no error — which is why deriving was refused', async () => { + const ql = await bootEngine(DERIVED); + await ql.insert('derived_task', { id: 'a', subject: 'done', status: 'completed', due_date: '2020-01-01' }); + await ql.insert('derived_task', { id: 'b', subject: 'late', status: 'in_progress', due_date: '2020-01-01' }); + + // A formula field materialises no column on any driver, so the predicate + // matches nothing — and returns cleanly rather than throwing. + expect(await ql.find('derived_task', { where: { is_completed: true } })).toEqual([]); + expect(await ql.find('derived_task', { where: { is_overdue: true } })).toEqual([]); + + // THE decisive one. On the old stored boolean this returned EVERY row; as a + // formula it returns NONE. Eight filters in this app relied on exactly this + // predicate ("Due Today", the reminder flow, both open-task reports, three + // distribution charts), so the derive route would have silently emptied + // every one of them. + expect(await ql.find('derived_task', { where: { is_completed: false } })).toEqual([]); + + // CONTROL — the stored column answers correctly on the same rows and the + // same engine, so the emptiness above is about the field being virtual, not + // about the fixture or the driver. + expect((await ql.find('derived_task', { where: { status: 'completed' } })).map((r: any) => r.id)).toEqual(['a']); + expect((await ql.find('derived_task', { where: { status: { $ne: 'completed' } } })).map((r: any) => r.id)).toEqual(['b']); + }); +}); From 628b244e96fbf44305833ad4e2a6e9482b664f5a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 03:46:14 +0000 Subject: [PATCH 2/2] fix(example-todo): remove the inert is_completed/is_overdue flags and repair every filter that read them Both were readonly booleans defaulting to false that nothing ever wrote, while twelve view/dashboard/report/flow filters read them as if maintained. Every is_completed:true surface and the whole Overdue Tasks view were permanently empty; the eight is_completed:false filters matched completed tasks too. Removed rather than derived as formulas: a formula field is virtual, so a filter naming one matches nothing -- measured at 0 rows with no error, where the stored boolean returned every row. Deriving would have silently emptied the Due Today view, the reminder flow and both open-task reports. Every consumer now asks status / due_date directly. The hook's unreachable overdue branch is removed rather than re-armed: becoming overdue is the passage of time, not a record write, and the overdue_escalation scheduled flow already covers it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ARidKDYSCD56LaygrvDPnk --- .changeset/todo-remove-inert-derived-flags.md | 57 +++++++++++++++++++ .../test/derived-flag-removal.test.ts | 12 +++- 2 files changed, 66 insertions(+), 3 deletions(-) create mode 100644 .changeset/todo-remove-inert-derived-flags.md diff --git a/.changeset/todo-remove-inert-derived-flags.md b/.changeset/todo-remove-inert-derived-flags.md new file mode 100644 index 0000000000..4c782fa0db --- /dev/null +++ b/.changeset/todo-remove-inert-derived-flags.md @@ -0,0 +1,57 @@ +--- +"@objectstack/example-todo": patch +--- + +fix(example-todo): remove the inert `is_completed` / `is_overdue` flags and repair every filter that read them (#7226) + +`examples/app-todo/src/objects/task.object.ts` declared `is_completed` and +`is_overdue` as `readonly: true` booleans defaulting to `false`. Nothing in the +app ever wrote either one — no hook leg, no flow node, no action handler, and +the seed data set neither — so both were `false` on every row for the life of +the app, while **twelve** view / dashboard / report / flow filters read them as +if they were maintained. + +The consequence was not cosmetic. Every surface asking `is_completed: true` was +permanently empty: the "Completed Today" tile, the "Weekly Task Completion" +trend, and both the "Completed Tasks" and "Time Tracking" reports. So was the +whole "Overdue Tasks" list view, which asked `is_overdue: true`. The eight +surfaces asking `is_completed: false` were vacuously true instead — they matched +completed tasks too. `task.hook.ts` also carried an `afterUpdate` branch gated +on `data.is_overdue && previous && !previous.is_overdue`, which could never run. +Since #7036 started stamping `completed_date` on the completion transition, the +divergence was directly readable in the shipped app: a task could carry a +completion date and `is_completed: false` at the same time. + +**Removed rather than derived as formula fields, for a measured reason.** A +`Field.formula(...)` computes both correctly — including the temporal one +(`date(record.due_date) < today()` evaluates per read, with a per-call `now` +snapshot) — so deriving looks like the obvious repair. It is not: a `formula` +field is virtual, no driver materialises a column for it, and so a *filter* +naming one matches nothing. Measured on this app's own sqlite-wasm driver, +`where { is_completed: false }` against a formula field returns **0 rows with no +error**, where the stored boolean returned every row. Deriving would therefore +have silently emptied the "Due Today" view, the daily reminder flow and both +open-task reports — trading a wrong answer for an invisible one. + +`status` and `due_date` are stored, indexed columns that already carry the +information, and both are declared dimensions on the `task_metrics` dataset, so +every consumer now asks the semantic layer's own vocabulary directly: + +| was | is now | +|---|---| +| `is_completed == true` | `status equals 'completed'` | +| `is_completed == false` | `status not_equals 'completed'` | +| `is_overdue == true` | `due_date less_than '{today}'` AND `status not_equals 'completed'` | + +Updated across `task.object.ts`, `task.hook.ts`, `task.view.ts`, +`task.dashboard.ts`, `task.report.ts`, `task.flow.ts`, the three translation +bundles and the README. The hook's dead overdue branch is removed rather than +re-armed against `due_date`: becoming overdue is the passage of time, not a +record write, so a record hook is structurally the wrong instrument — the +clock-driven `overdue_escalation` scheduled flow already covers it. + +Pinned by `examples/app-todo/test/derived-flag-removal.test.ts`, which walks the +app's real `defineStack` for any surviving reference, drives the replacement +filters across **both** sides of the completion transition (so a filter cannot +pass for the same reason the old flag did — everything being false), and records +the formula-filter measurement that decided the route. diff --git a/examples/app-todo/test/derived-flag-removal.test.ts b/examples/app-todo/test/derived-flag-removal.test.ts index 189c8be515..cfa89f5723 100644 --- a/examples/app-todo/test/derived-flag-removal.test.ts +++ b/examples/app-todo/test/derived-flag-removal.test.ts @@ -172,7 +172,7 @@ describe('#7226 — the replacement filters really select, on BOTH sides of the expect(await done()).toEqual([]); // Drive the real completion transition through the real hook. - await ql.update('todo_task', id, { status: 'completed' }); + await ql.update('todo_task', { status: 'completed' }, { where: { id } }); // AFTER: the sets have swapped. THIS is the half the old `is_completed` // flag failed — it stayed `false`, so "Completed Today" and both completed @@ -185,7 +185,7 @@ describe('#7226 — the replacement filters really select, on BOTH sides of the expect(completed[0].completed_date, 'completion date and completion state agree').toBeTruthy(); // Reopening puts it back — the filter tracks the column in both directions. - await ql.update('todo_task', id, { status: 'in_progress' }); + await ql.update('todo_task', { status: 'in_progress' }, { where: { id } }); expect((await open()).map((r: any) => r.id ?? r._id)).toEqual([id]); expect(await done()).toEqual([]); }); @@ -198,7 +198,13 @@ describe('#7226 — the replacement filters really select, on BOTH sides of the const late = await mk('late', 'in_progress', PAST); const soon = await mk('not yet due', 'in_progress', FUTURE); const noDue = await mk('no due date', 'not_started'); - const lateDone = await mk('late but finished', 'completed', PAST); + // Completed via the real transition, not seeded as `completed`: the app's + // `completed_date_required` rule is satisfied by the hook's stamp on the + // UPDATE path, so inserting a completed task directly is refused (#7036). + const lateDone = await mk('late but finished', 'in_progress', PAST); + await ql.update( + 'todo_task', { status: 'completed' }, { where: { id: lateDone.id ?? lateDone._id } }, + ); // The shape the `overdue` view and the "Overdue Tasks" tile now declare. const overdue = await ql.find('todo_task', {