fix(objectql): a CEL defaultValue stores the declared type's contract shape, not a raw Date (#7373) - #7469
Conversation
…ct shape, not a raw `Date` (#7373) `applyFieldDefaults` produces a default three ways and only two honoured the stored-value contract: the `NOW()` token routes through `resolveNowDefault`, a literal is checked against `valueSchemaFor(def, 'stored')` at author time (#7127) — and the expression envelope's result was assigned verbatim. The temporal stdlib returns a JS `Date` (ADR-0053 D1: `today()`/`daysFromNow(n)`/ `daysAgo(n)` are UTC-midnight of the reference-tz calendar day, `now()` the raw instant), so `{ dialect: 'cel', source: 'daysFromNow(7)' }` on a `datetime` put a `Date` OBJECT in the column while `valueSchemaFor` names an ISO-8601 STRING. `validateRecord` accepts a `Date` on `date`/`datetime` by explicit decision, so nothing refused the write and the divergence was silent — while `os migrate value-shapes` walks stored values against that same schema and reports such a row as a violation by the platform's own scan. The expression branch now routes a `Date` result through the SAME per-type table the `NOW()` token uses, rather than growing a second copy of the contract: `datetime` → `YYYY-MM-DDTHH:MM:SS.sssZ`, `date` → `YYYY-MM-DD`, `time` → `HH:MM:SS[.fff]`. Storage on SQL and MongoDB is byte-identical to before: `SqlDriver.formatInput` already coerced a `Date` through `canonicalUtcDatetime`/`toDateOnly`, and mongodb's `storageDatetimeValue`/`storageDateValue` do the same. What changes is the memory driver, which applies its temporal canon to filter comparands only (`coerceTemporalValue`) and stored writes as handed. Same declaration, different stored shape per datasource — the split #4597 / #4560 closed for the `NOW()` token, reappearing on the CEL branch and closed the same way, engine-side. Normalization rather than refusal: refusing a `Date` here would make the rule depend on WHO wrote the value — `validateRecord` accepts one from any caller, temporal types are not in ADR-0104's strict value-shape block, and the documented envelope (#7244) stores correctly on SQL today. Non-`Date` results pass through untouched, as does an `Invalid Date` (the totality the driver canons keep). No day can shift: the ADR-0053 `Date` is UTC-midnight OF the reference-tz day and is read back with UTC getters. Pins in `engine-cel-default-temporal-shape.test.ts` assert the stored value against `valueSchemaFor` itself, and assert the TYPE as well as the text — `JSON.stringify` renders a `Date` as its ISO string, which is what made the original defect read as correct. Reverse-verified: the four subject pins fail against the pre-fix assignment; the four controls (`NOW()` token, literals, non-date CEL results, caller-supplied values) pass in both states. Refs #7373, #7244, ADR-0053, ADR-0104. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012tjfhdVGuYU9KH7SNbor56
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 15 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also reference the affected code. These are read-only:
|
Closes #7373.
The defect
applyFieldDefaultsproduces a default three ways, and only two of them honoured the stored-value contract:NOW()tokenresolveNowDefault→ the form the declared type storesvalueSchemaFor(def, 'stored')at author time (#7127)The temporal stdlib returns a JS
Date(ADR-0053 D1:today()/daysFromNow(n)/daysAgo(n)are UTC-midnight of the reference-tz calendar day;now()the raw instant), so{ dialect: 'cel', source: 'daysFromNow(7)' }on adatetimeput aDateobject in the column whilevalueSchemaFornames an ISO-8601 string.validateRecordaccepts aDateondate/datetimeby explicit decision, so nothing refused the write — andos migrate value-shapes, which walks stored values against that same schema, reports such a row as a violation by the platform's own scan.The fix
The expression branch routes a
Dateresult through the same per-type table theNOW()token uses — one table, both branches, not a second copy of the contract:datetime(and anything else)YYYY-MM-DDTHH:MM:SS.sssZdateYYYY-MM-DDtimeHH:MM:SS[.fff]Measurement behind the choice
In-tree CEL
defaultValuecensus — thedialect: 'cel'corpus is almost entirelyexpression:(formula fields, virtual — never stored),condition:/when:(rule predicates). Exactly two aredefaultValue, one temporal:engine-write-formula-hydration.test.ts:157now()datetimeDateobjectengine-select-option-default.test.ts:210'approved'textstring(unaffected)Per-driver behaviour when handed a
Date— measured by reading each driver's write seam:datetimedatecanonicalUtcDatetime→toISOString()toDateOnly→YYYY-MM-DDstorageDatetimeValuekeeps BSONDate; an ISO string parses to the identicalDatestorageDateValue→YYYY-MM-DDcoerceTemporalValue(filter comparands); stores writes as handedDateobject → contract shapeSo SQL and MongoDB already stored exactly what the engine now produces; the only stored-shape change is on the memory driver, which is where the defect was visible. This is the split #4597 / #4560 closed for the
NOW()token, reappearing on the CEL branch and closed the same way — engine-side, so one answer serves every driver.Why normalize rather than refuse. Refusing a
Dateout of a CEL default would make the rule depend on who wrote the value:validateRecordaccepts aDateondate/datetimefrom any caller (if (value instanceof Date) return null), and temporal types are not in ADR-0104's strict value-shape block at all — so there is no strict path that already refuses this on a user write for a defaults path to be bypassing. Refusal would also break the documented envelope (#7244) on precisely the SQL backends where it stores correctly today.No day-shift. ADR-0053 D1's
Dateis UTC-midnight of the reference-tz calendar day, and it is read back withtoISOString()— UTC getters, the samegetUTC*the ADR names for the driver filter path. Reading those parts in local time is the move that would shift a day, and nothing here does it. Pinned directly: at an instant whose UTC day andAmerica/Los_Angelesday differ, adatefield defaulted bytoday()stores the LA day.Non-
Dateresults pass through untouched (a CEL default's result type is otherwise a runtime concern), as does anInvalid Date— the same totality the driver canons keep.Pins
packages/objectql/src/engine-cel-default-temporal-shape.test.ts— 8 tests. They assert stored values againstvalueSchemaForitself rather than a hand-copied shape, and assert the type as well as the text:JSON.stringifyrenders aDateas its ISO string, which is exactly what made the original defect read as correct.Subjects:
datetime,date,timeCEL defaults; the reference-tz day-shift guard.Controls:
NOW()token byte-identical, literal defaults untouched, non-date CEL results passed through, caller-supplied values untouched.Reverse-verified by restoring the pre-fix
out[f.name] = result.value— the 4 subject pins fail, the 4 controls pass in both states.Verification
packages/objectqlsuite: 177 files / 3135 tests passcheck:adr-anchors,check:durability-log-level,check:engine-double-contract,check:stack-collection-maps,scripts/check-engine-split-ratio.mjs,check:nul-bytescheck-empty-changeset(1 declaring changeset added),check-changeset-no-major(no major)tsc --noEmitandeslintclean on the changed filesChangeset
patch, on@objectstack/objectqlalone. Argued from the gate text:check-changeset-no-major.mjsrecords that the launch window ships even breaking changes asminor, somajor(and the ADR-0087 trio) is out regardless. Betweenpatchandminor, the observable change is confined to correcting values the platform's own value-shape scan already classified as violations, on the one backend that stored them — no API, type, or message-contract change. That matches the recent precedent for contract-correcting behaviour fixes (nested-plugin-view-container-expansion,driver-sql-json-column-operator-refusal, bothpatch).No
packages/spectouch — deliberately.resolveNowDefaultkeeps its name so the reference inpackages/spec/src/data/default-value-shape.tsstays accurate; only its parameter is renamednow→instant, sincedaysFromNow(7)is not "now" and takes the identical per-type treatment.Refs #7373, #7244, #7127, #4597 / #4560, ADR-0053, ADR-0104.
Generated by Claude Code