feat(spec,driver-sql,formula): addDays whole-day offset on a field reference, compiled on SQL and evaluated in memory - #15102
Conversation
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019vx3536MUFc8XYVLNoKhs3
…corpus rows on both paths (wip) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019vx3536MUFc8XYVLNoKhs3
…tax section, changesets, regenerated spec artifacts (wip) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019vx3536MUFc8XYVLNoKhs3
…ference, compiled on SQL and evaluated in memory
FieldReferenceSchema gains addDays — an integer literal of any sign or a nested
{ $field } reference to a numeric column — so a dataset measure can express
completed_at <= due_date + grace_days. driver-sql compiles the offset on the
cross-field arm per dialect with the ruled NULL semantics written into the
predicate; matchesFilter evaluates it identically; the shared conformance corpus
carries the literal, column, negative, NULL-offset, NULL-base and $not rows on
both paths; the analytics detector keeps routing the reference to the engine
path and a dataset-level pin drives the on-time count on both.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019vx3536MUFc8XYVLNoKhs3
📓 Docs Drift CheckThis PR changes 3 package(s): 48 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 4 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 132 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin cb9d4b8f06101bdb4c8a77c90974991fbd9d25bb && git checkout cb9d4b8f06101bdb4c8a77c90974991fbd9d25bb
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 29db3cd2ada4d88f97c52c00634609df9c573444 b45c07858b2a27cdc0ab78b80f7aa1544e8cd2de && git checkout -B drift-repro 29db3cd2ada4d88f97c52c00634609df9c573444 && git merge --no-ff b45c07858b2a27cdc0ab78b80f7aa1544e8cd2de
node scripts/docs-audit/affected-docs.mjs --json 29db3cd2ada4d88f97c52c00634609df9c573444
|
|
Landing provenance (PM seat
Generated by Claude Code |
Fixes #14104
Clause ②: yes — path limb:
packages/spec/src/data/filter.zod.ts(a published accept set widens —FieldReferenceSchemagainsaddDays, regenerated into the JSON schema, the authorable surface andcontent/docs/references/data/filter.mdx); content limb: the filter grammar gains a key an author can write, soos validateaccepts a shape it refused yesterday and two published runtimes (driver-sql,formula) give it a meaning.What this lands (maintainer ruling A, 2026-09-02, recorded on #14104)
{ completed_at: { $lte: { $field: 'due_date', addDays: { $field: 'grace_days' } } } }— a whole-day offset on a field reference, so a dataset measure can say "completed by its deadline, where the deadline is a stored date plus a grace period held in another column".FieldReferenceSchema.addDays: an integer literal of any sign (a negative value subtracts; nosubDays, no other unit) or a nested{ $field }reference (dot-path allowed, exactly as$fieldallows). No new position: the reference is legal exactly where a bare one is (the whole comparand of the six scalar comparison operators);$in/$nin/$betweenkeep the list-position refusal. A fractional number, a string, or an object without$fieldis refused at the schema door with a pointed first sentence, and the ordering slot repeats that sentence instead of zod's generic union text. The "Execution support" docblock is rewritten to the landed state (SQL push-down has compiled$fieldsince PR feat(driver-sql): compile $field to column-to-column comparison (#5222) #7582; the offset rides the same arm). No transform, so no ADR-0122XParsedalias.driver-sql, inherited unchanged bydriver-sqlite-wasm) — the PR feat(driver-sql): compile $field to column-to-column comparison (#5222) #7582 cross-field arm reads the offset off the same comparand node and compilescolumn OP (referenced + offset days)per dialect: SQLitedate(col, 'N days')for adatecolumn andstrftime('%Y-%m-%dT%H:%M:%fZ', col, 'N days')for adatetimecolumn (the driver's canonical text form, so a shifted value is byte-identical to a stored one and the compare stays a plain text compare — no julian-day arithmetic anywhere, so no stop); PostgreSQLdate + integer/timestamptz + make_interval(days => n); MySQLdate_add(col, interval n day). The offset value iscoalesce(offset, 0), truncated toward zero on every dialect; a literal binds as a parameter where the column would. The offset rides the four [spec] SqlDriver 将$field编译为列对列比较(cross-field comparison push-down) #5222 rulings (same-table, declared-only, tenant column forbidden, same class) and adds two: day arithmetic compiles only between twodatecolumns or twodatetimecolumns, and the offset column must be numeric. Everything else isINVALID_FILTER/ 400, operands withheld from the caller and named in the server log.formulamatchesFilter) —resolveValueshifts the referenced value by the resolved whole days in the shape it arrived in (a calendar day stays a calendar day, so$ltestill covers the whole shifted day; an ISO instant keeps its time of day;Dateand epoch number likewise); the offset is truncated toward zero, matching the dialects.$not的语义在 driver-sql 与 driver-memory / formula 之间分叉:NULL 行的去留相反,$not: {}一个是 TRUE 一个是 FALSE #5146 used for$not:due_date + NULLisdue_date$neincluded — no deadline is never "on time";$notre-admits the row (the predicate is total)$eq, satisfies$newhen the offset deadline existsIn SQL that is
(referenced IS NOT NULL AND target IS NOT NULL AND target OP shifted)for the orderings and$eq, and(referenced IS NOT NULL AND (target IS NULL OR target != shifted))for$ne; in memory a NULL base resolves to a sentinelevalOpanswersfalsefor before any operator runs. So with an offset the$eq/$nepair is deliberately NOT the both-NULL-matching pair the bare arm emits — a row with no due date does not "equal" its deadline.$notcomposition, measured and pinned — the cross-field arm was already classified null-total (operatorIsNullTotal), so the$not的语义在 driver-sql 与 driver-memory / formula 之间分叉:NULL 行的去留相反,$not: {}一个是 TRUE 一个是 FALSE #5146 negation rewrite leaves it unguarded andNOTis the exact complement. The corpus pins$notof$lte/$eq/$newith a column offset on both paths; rows with no deadline are in every$notset and in no positive set.cross-field-conformance-cases.ts) — a second, nine-row fixture (cross_field_task: adatepair and adatetimepair on the same calendar days at 12:00Z, a nullable numericgrace_days) carrying every NULL arrangement of target / base / offset, a negative grace, and a month boundary; 21 expectations generated for both pairs (42 cases) + 10 refusals.driver-sqlruns it perDIALECT_CELLS(SQLite here; the live PG + MySQL cells are named skips locally and run in theTemporal Conformance (live PG + MySQL)job, which runs the wholedriver-sqlsuite with both URLs);driver-sqlite-wasmruns it through its own sql.js dialect; the analytics face runs it throughservice.query(whereand read scope).comparand-shape.tsisFieldReferenceistypeof value.$field === 'string'with extra keys ignored (mirroringdriver-sql'sfieldReferenceOf), so a reference carryingaddDaysis still detected andNativeSQLStrategy.canHandlestill declines it to the engine path; pinned incross-field-offset-dataset.test.tstogether withfindCrossFieldComparandunder every combinator. The temporal comparand doors treat the object as never judged — measured at this head:packages/objectql/src/temporal-comparand-door.tsfindUninterpretableTemporalComparandanswersnullfor the column-offset, literal-offset and bare reference shapes on adatetimefield and fires on the junk-string control;packages/core/src/utils/temporal-comparand.tsisUninterpretableTemporalComparand('datetime', ref)answersfalsefor both shapes andtruefor the control. Neither package is edited.read-scope-sql.tsuntouched.packages/services/service-analytics/src/__tests__/cross-field-offset-dataset.test.ts: aDatasetSchemadataset over the offset fixture withdone_on_time(count, filtercompleted_on <= due_on + grace_days),late, thedatetime-pair twin and a derivedon_time_rate, driven throughqueryDatasetagainst a realSqliteWasmDriver: 2 on time, 4 late, rate 2/9, the datetime twin the same 2, grouped by a dimension without losing the filter; and, with both capabilities declared, only the offset-filtered passes decline native SQL (exactly one raw statement for the plain count). Home: beside the analytics service's owncross-field-engine-fallback.test.ts, not thepackages/rest/src/analytics-dataset-*family — that family pins the caller's view of a refusal envelope through the route's catch with a failing driver double; this card's claim is about ROWS on a real engine, and the harness for that lives here.duty.grace_days, a relation path. The memory evaluator walks it (pinned inmatches-filter-field-reference-offset.test.ts); SQL push-down refuses a dotted reference under the maintainer's 2026-08-06 same-table ruling on [spec] SqlDriver 将$field编译为列对列比较(cross-field comparison push-down) #5222 (no JOIN planning, no alias contract), and the offset inherits that rule rather than reopening it —INVALID_FILTERnamingdotted pathin the server log, pinned in the corpus refusals and at dataset level. So on a SQL deployment the dotted offset is a loud refusal, never a wrong number, and the same-table spelling is the one that answers on both paths. Recorded as an open question in the report for the PM.content/docs/protocol/objectql/query-syntax.mdxgains a "Comparing Two Fields" section (the page had no$fieldparagraph to sit beside; the section documents the bare reference and the offset, with anos:checktyped block that type-checks undercheck:skill-examples); generatedcontent/docs/references/data/filter.mdx, the JSON schema, the authorable surface and the strictness-ledger counts viacheck:generated --fix.@objectstack/specminor,@objectstack/driver-sqlminor,@objectstack/formulaminor.driver-sqlite-wasmandservice-analyticscarry no changeset: only their tests moved.Declared trigger-file touch (#6009)
packages/drivers/driver-sql/src/sql-driver.tsis on #6009'sRestart-when(any PR touchingsqliteCanonicalDatetimeSqlorbackfillCanonicalDatetimes). This diff does not change either function or any call to them. It does add a TSDoc{@link sqliteCanonicalDatetimeSql}mention in the newcrossFieldOffsetExprdocblock (the SQLite day-add is applied on top of the canonical-text expressionfilterColumnExpralready returns for a not-yet-backfilled column), so a textual scan of the diff will hit the symbol name. Declared here for the hold's owner; no #6009 work is done in this PR.Verification record — at
b45c078(git rev-parse --short HEADafter the last commit)Every command run in the worktree at that head, exit captured before any pipe; verdict lines quoted from the tools' own output. Build closure first (
pnpm --filter '@objectstack/driver-sql^...' build,pnpm --filter '@objectstack/service-analytics^...' build, thendriver-sql,driver-sqlite-wasm,formula,spec,lint,client,client-react), heavy runs underscripts/pm/os-verify-lock.sh.pnpm --filter @objectstack/spec testpnpm --filter @objectstack/driver-sql testdeclareUnprovisionedCell(NOT MEASURED locally; theTemporal Conformance (live PG + MySQL)job runs them)pnpm --filter @objectstack/driver-sqlite-wasm testpnpm --filter @objectstack/formula testpnpm --filter @objectstack/service-analytics testtypecheck× spec, driver-sql, formula, driver-sqlite-wasm, service-analyticscheck:test-typecheck: OK — @objectstack/spec's test layer compiles(the new pins are inside its program)pnpm --filter @objectstack/spec check:generated(second run, no--fix)✓ All 15 generated artifacts are up to date.check:authorable-surface/check:liveness/check:docs/check:api-surface✅ Successfully generated 1600 schemas.· exit 0 ·✅ 229 generated files in sync·public API surface + factory signatures unchanged ✓node scripts/check-system-context-census.mjs --fixthen plain--fix: 0 anchor(s) rewritten·OK — 106 elevation read sites in 20 packages across 45 files, all anchoredeslint --no-inline-configover the 10 edited TypeScript filescheck:doc-authoring✓ doc authoring guard: 14658 customer-facing string(s) across 723 spec sources clean(a first run at an earlier head flagged tracker ids in three new strings; stripped, with a negative pin added)node scripts/pm/dispatch-gates.mjs --commandsunion — 79 commands (24 node, 55 pnpm) derived from the 17-path change set vs merge base1c7adc7check-dev-prereqs.mjs(exit 1 — stale/absentdist/of 58 unrelated packages, asks for a fullpnpm build),check-test-completeness.mjs(exit 3PREREQUISITE NOT MET— grades a saved turbo test log),check:dual-build-cjs-loads(exit 3PREREQUISITE NOT MET— 76 packages withoutdist/),check:type-check-debt(exit 3PREREQUISITE NOT MET— 25 ledgered deps unbuilt),check:entry-nameability(exit 0 but printsNOT MEASURED: no callable export on @objectstack/spec/qa, pre-existing). CI runs the farm.Also run:
check:skill-examples(✅ 257 prose examples type-check across 3 surface(s)— covers the newos:checkblock),check:strictness-ledger(is current — 439 site(s) measured),check:nul-bytesOK,check:cross-package-test-inputsOK,check:driver-conformanceexit 0.Reverse verification (two legs, restore proven by blob hash, script with
traprestore)fieldReferenceOffsetOfforced tonull; marker count on disk 1):sql-driver-cross-field-conformance.test.ts→ 43 failed / 74 passed / 2 skipped; every red is in the[addDays]blocks — 17 distinctSQL push-down disagreedconformance cases plus the offset refusal arm (expected a refusal— the offset silently compiled as a bare comparison); 0in-memory evaluator disagreed. The formula pins stayed green (28 passed) — memory untouched. Restored withgit checkout HEAD --;git hash-object= HEAD blob61008e7c…, marker count 0.resolveValuereturns the bare base; an executable string marker):matches-filter-field-reference-offset.test.ts→ 20 failed / 8 passed (the 8 are the offset-free controls). Rebuiltformula;node scripts/ablation-dist-preflight.mjs @objectstack/formula 'ABLATION-14104-MEM'→✓ dist/: marker present in 2 built files; the dist-resolvingdriver-sqlconformance suite → 34 failed / 83 passed, every assertionin-memory evaluator disagreed, 0 SQL disagreements. Restored (hash-object= HEAD blob087ba9fc…, marker count 0), rebuilt, preflight--absent→✓ dist/: marker absent from all 6 built files, suite back to 117 passed / 2 skipped. (A first attempt used a comment marker the bundler strips — the preflight correctly refused it as "only in sourcemaps"; the leg was re-run with the executable marker above.)🤖 Generated with Claude Code
https://claude.ai/code/session_019vx3536MUFc8XYVLNoKhs3
Generated by Claude Code