diff --git a/.changeset/22301-seed-temporal-stored-form.md b/.changeset/22301-seed-temporal-stored-form.md new file mode 100644 index 00000000000..5ddbe5b797c --- /dev/null +++ b/.changeset/22301-seed-temporal-stored-form.md @@ -0,0 +1,16 @@ +--- +'@objectstack/metadata-protocol': patch +--- + +A cel-dated seed row reaches every hook in the stored form of its temporal field + +Clause-②: no + +`` cel`daysFromNow(n)` ``, `daysAgo(n)`, `today()` and `now()` in a seed record resolve to a JS `Date`. The seed loader handed that `Date` to the engine, so what a hook saw depended on how the hook ran: + +- An in-process `handler` (a source config, which `@objectstack/verify`'s `bootStack` boots) saw the `Date` object. +- A sandboxed `body` (the compiled artifact `objectstack dev` boots) saw a full ISO instant, even on a `date` field. + +A hook that reads a date field as a string therefore accepted the row under `objectstack dev` and refused it under `bootStack`, and the row was not stored. + +The loader now puts each `Date` on a `date`, `datetime` or `time` field into the stored form the drivers write (`YYYY-MM-DD`, the UTC ISO instant, `HH:MM:SS`) before any hook runs, so every hook sees the value the column stores. A body hook on a `date` field now sees `2026-10-09` where it saw `2026-10-09T00:00:00.000Z`. Stored values do not change on any driver, because each driver already normalises a declared temporal field on write (`@objectstack/driver-memory` through the same `temporalStorageForm`). A `Date` on a field that is not temporal, or on a field the object does not declare, is handed over unchanged. diff --git a/packages/metadata-protocol/src/seed-loader.ts b/packages/metadata-protocol/src/seed-loader.ts index c0e971714f2..097d4adee50 100644 --- a/packages/metadata-protocol/src/seed-loader.ts +++ b/packages/metadata-protocol/src/seed-loader.ts @@ -23,7 +23,10 @@ import { } from '@objectstack/spec/data'; import { SEED_WRITE_EXECUTION_CONTEXT } from '@objectstack/spec/kernel'; import { resolveSeedRecord } from '@objectstack/formula'; -import { bulkWrite, withTransientRetry, defaultIsTransientError, type BulkWriteRowResult, runWithAdvisoryAggregation, type AdvisoryGroup } from '@objectstack/core'; +import { + bulkWrite, withTransientRetry, defaultIsTransientError, type BulkWriteRowResult, runWithAdvisoryAggregation, type AdvisoryGroup, + temporalComparandKind, temporalStorageForm, +} from '@objectstack/core'; // [#8442] The repo's ONE recogniser for "this throw is a record-validation // failure" — duck-typed on `code`/`name`, the same predicate `mapDataError` and // both dispatcher error exits use. Imported rather than re-spelled so the seed @@ -440,6 +443,39 @@ function seedRowNeedsOrganization(definition: unknown): boolean { return resolveInjectedSystemColumns(definition).tenant; } +/** + * [#22301] Put every `Date` a seed row resolved to into the STORED form of its + * temporal field, before the row reaches the engine. + * + * `resolveSeedRecord` evaluates an Expression envelope to whatever the CEL + * stdlib returns, and `today()` / `daysFromNow(n)` / `daysAgo(n)` / `now()` + * return a JS `Date` (ADR-0053 D1). Handed on as a `Date`, what a hook saw + * depended on HOW the hook ran, not on the value: a hook lowered to a body runs + * in the sandbox, whose JSON bridge turned it into a full ISO instant + * (`2026-10-09T00:00:00.000Z`, even on a `date` field), and the same hook as an + * in-process `handler` (a source config, as `@objectstack/verify`'s `bootStack` + * boots it) received the `Date` object itself. A hook reading the field as a + * string accepted the row under `os dev` and refused it under `bootStack`. + * Neither is the value the column stores — the driver writes the ADR-0053 form + * (`YYYY-MM-DD` for a `date`) — nor the value a REST write of the same row + * hands the hook. + * + * So the row leaves the loader in that stored form, by the ONE rule the drivers + * apply on write (`temporalStorageForm`, `@objectstack/core`): every hook sees + * what is stored, however it runs. A non-temporal field, a field the definition + * does not declare, and an Invalid Date are left exactly as resolved — the rule + * normalizes temporal FORM and never invents a value (its own totality). + */ +function seedValuesInStoredForm(record: Record, definition: unknown): void { + const fields = (definition as { fields?: Record } | null | undefined)?.fields; + if (!fields || typeof fields !== 'object') return; + for (const [key, value] of Object.entries(record)) { + if (!(value instanceof Date)) continue; + const kind = temporalComparandKind(fields[key]?.type); + if (kind) record[key] = temporalStorageForm(value, kind); + } +} + export class SeedLoaderService implements ISeedLoaderService { private engine: IDataEngine; private metadata: IMetadataService; @@ -1248,6 +1284,8 @@ export class SeedLoaderService implements ISeedLoaderService { continue; } const record = { ...(seedResult.value as Record) }; + // [#22301] The stored form, not a `Date`, before any hook can see it. + seedValuesInStoredForm(record, objectDefinition); /** * [commit 1cba33f16] Deferrals this row took on a column the write contract * requires on insert. Collected during resolution, reported once the diff --git a/packages/verify/src/harness.seed-temporal-form.test.ts b/packages/verify/src/harness.seed-temporal-form.test.ts new file mode 100644 index 00000000000..7461ae63ab4 --- /dev/null +++ b/packages/verify/src/harness.seed-temporal-form.test.ts @@ -0,0 +1,147 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #22301 item 9 — a cel-dated seed row is stored under `bootStack`, and every + * hook sees the value the column stores, however the hook runs. + * + * Measured before the fix (`origin/main` 28bff18d0, this file's app, one + * `bootStack`): for `start_date: cel\`daysFromNow(1)\`` on a `date` field the + * SQL driver stored `YYYY-MM-DD`, while + * + * · an in-process `handler` hook (a source config — what `bootStack` boots) + * saw a JS `Date` object, and + * · the same hook lowered to a sandboxed `body` (the compiled artifact + * `objectstack dev` boots) saw the full instant `YYYY-MM-DDT00:00:00.000Z`. + * + * So a hook that reads the field as a string — hotcrm's `campaign_validation` + * is the measured one — accepted the row under `objectstack dev` and refused it + * under `bootStack`. The divergence was the value the seed loader handed the + * engine (`resolveSeedRecord`'s `Date`), not the app's hook: the loader now + * hands over the ADR-0053 stored form, and every reading below is equal. + * + * The literal rows are the control: a literal ISO value is already in stored + * form and reaches every hook unchanged. + */ + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { cel, defineStack } from '@objectstack/spec'; +import { Field, ObjectSchema } from '@objectstack/spec/data'; +import { bootStack, type VerifyStack } from './harness.js'; + +/** What the in-process hook saw, per row name: `typeof` and the value. */ +const handlerSaw = new Map(); +const describeValue = (v: unknown): string => + v instanceof Date ? `Date:${v.toISOString()}` : `${typeof v}:${String(v)}`; + +const fields = { + name: Field.text({ label: 'Name', required: true }), + start_date: Field.date({ label: 'Start' }), + end_at: Field.datetime({ label: 'End' }), + seen: Field.text({ label: 'Seen' }), +}; + +const rows = (prefix: string) => [ + { name: `${prefix}-cel`, start_date: cel`daysFromNow(1)`, end_at: cel`daysFromNow(2)` }, + { name: `${prefix}-literal`, start_date: '2026-10-10', end_at: '2026-10-11T00:00:00.000Z' }, +]; + +const app = defineStack({ + manifest: { id: 'com.example.stf', namespace: 'stf', version: '0.0.0', type: 'app', name: 'Seed temporal form' }, + objects: [ + ObjectSchema.create({ name: 'stf_handler', label: 'Handler', pluralLabel: 'Handlers', sharingModel: 'public_read_write', fields }), + ObjectSchema.create({ name: 'stf_body', label: 'Body', pluralLabel: 'Bodies', sharingModel: 'public_read_write', fields }), + ObjectSchema.create({ name: 'stf_guarded', label: 'Guarded', pluralLabel: 'Guarded', sharingModel: 'public_read_write', fields }), + ], + hooks: [ + { + name: 'stf_handler_reads', + object: 'stf_handler', + events: ['beforeInsert'], + handler: async (ctx: { input: Record }) => { + handlerSaw.set(String(ctx.input.name), { + start: describeValue(ctx.input.start_date), + end: describeValue(ctx.input.end_at), + }); + }, + }, + { + name: 'stf_body_reads', + object: 'stf_body', + events: ['beforeInsert'], + body: { + language: 'js', + source: + "ctx.input.seen = typeof ctx.input.start_date + ':' + String(ctx.input.start_date) + '|' + " + + "typeof ctx.input.end_at + ':' + String(ctx.input.end_at);", + capabilities: [], + }, + }, + { + // hotcrm's `campaign_validation` shape: dates are read only when strings. + name: 'stf_guarded_requires_string_dates', + object: 'stf_guarded', + events: ['beforeInsert'], + handler: async (ctx: { input: Record }) => { + if (typeof ctx.input.start_date !== 'string' || typeof ctx.input.end_at !== 'string') { + throw Object.assign(new Error('both dates are required'), { code: 'VALIDATION_FAILED', status: 400 }); + } + }, + }, + ], + data: [ + { object: 'stf_handler', mode: 'insert', records: rows('handler') }, + { object: 'stf_body', mode: 'insert', records: rows('body') }, + { object: 'stf_guarded', mode: 'insert', records: rows('guarded') }, + ], +} as never); + +let stack: VerifyStack; +const stored = new Map>(); + +beforeAll(async () => { + stack = await bootStack(app); + for (const object of ['stf_handler', 'stf_body', 'stf_guarded']) { + for (const row of await stack.rows(object)) stored.set(String(row.name), row); + } +}, 120_000); +afterAll(async () => { + await stack?.stop(); +}); + +describe('a cel-dated seed row reaches every hook in its stored form', () => { + it('the rows are stored, in the ADR-0053 form of each field', () => { + for (const prefix of ['handler', 'body', 'guarded']) { + const row = stored.get(`${prefix}-cel`); + expect(row, `${prefix}-cel stored`).toBeDefined(); + expect(row!.start_date).toMatch(/^\d{4}-\d{2}-\d{2}$/); + expect(row!.end_at).toMatch(/^\d{4}-\d{2}-\d{2}T00:00:00\.000Z$/); + } + }); + + it('an in-process handler sees the stored string, not a Date', () => { + const row = stored.get('handler-cel')!; + expect(handlerSaw.get('handler-cel')).toEqual({ + start: `string:${row.start_date}`, + end: `string:${row.end_at}`, + }); + }); + + it('a sandboxed body sees the same value — the stored form, not a full instant on a date field', () => { + const row = stored.get('body-cel')!; + expect(row.seen).toBe(`string:${row.start_date}|string:${row.end_at}`); + }); + + it('a hook that reads the dates only as strings no longer refuses the row', () => { + expect(stored.get('guarded-cel')).toBeDefined(); + }); + + it('control — a literal row reaches both hooks unchanged and is stored as written', () => { + expect(handlerSaw.get('handler-literal')).toEqual({ + start: 'string:2026-10-10', + end: 'string:2026-10-11T00:00:00.000Z', + }); + expect(stored.get('body-literal')!.seen).toBe('string:2026-10-10|string:2026-10-11T00:00:00.000Z'); + expect(stored.get('handler-literal')).toMatchObject({ start_date: '2026-10-10', end_at: '2026-10-11T00:00:00.000Z' }); + expect(stored.get('guarded-literal')).toBeDefined(); + }); +});