Skip to content

Commit 9e8f04d

Browse files
os-zhuangclaude
andauthored
fix(driver-memory,driver-mongodb): one storage form per temporal field type (#4047) (#4060)
The non-SQL counterpart of ADR-0053 D-B (#3912). Both drivers let the writer decide a datetime value's runtime type, and both compare across types by type bracket rather than by value — so a string comparand never matched a Date value, in either direction, for EVERY operator including $gte. SQLite's affinity rules at least left one half reachable; here a window silently answers with whichever half matches the comparand type. Both columns really were mixed: each driver's own created_at/updated_at default writes one form (new Date() on mongo, ISO text in memory) while REST/JSON writes, relative-date tokens and initialData fixtures supply the other. On MongoDB the dashboard's default window — string bounds against a BSON-Date created_at — returned NOTHING, worse than the final-day loss #3777 fixed. One canon per driver, chosen by what the store natively has, applied on write and to every filter comparand: - driver-mongodb: BSON Date (the dialect's native instant, its timestamptz) — create/update/updateMany/bulkCreate/bulkUpdate convert, and translateFilter takes a temporal-kind resolver so find(), deleteMany and the aggregation $match all coerce comparands. - driver-memory: canonical UTC ISO text (sorts chronologically under the string comparison mingo performs; survives JSON persistence) — create/ update/updateMany convert, and both filter lowerings coerce. Both learn their temporal fields from syncSchema; an undeclared object is left exactly as written — schemaless stores, so guessing a type from a value would coerce data the platform never claimed. driver-memory also converges rows already in the table when the schema arrives, catching initialData and persistence restores (the in-memory analogue of backfillCanonicalDatetimes, idempotent like it). Field.date stays timezone-naive text on both — an instant would invent a midnight and re-couple it to a zone. Ordering where this meets #3777/ #4042 is load-bearing: the calendar-day rewrite runs on the bare-day STRING first, and only the resulting bound is converted to storage form. Tests: row-result probes on both, red before the fix — mixed-form windows, Date-object comparands, bound semantics on top of the converged storage, $between + array spelling, date-column non-widening, undeclared passthrough; mongodb runs against a real MongoDB (mongodb-memory-server). ADR-0053 gains the D-E addendum. Closes #4047 Claude-Session: https://claude.ai/code/session_01EkL3RGJrzjLEGURsLxfS2f Co-authored-by: Claude <noreply@anthropic.com>
1 parent 54c2627 commit 9e8f04d

10 files changed

Lines changed: 990 additions & 86 deletions
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
---
2+
"@objectstack/driver-memory": patch
3+
"@objectstack/driver-mongodb": patch
4+
---
5+
6+
fix(driver-memory,driver-mongodb): `Field.datetime` has one storage form per driver (#4047)
7+
8+
The non-SQL counterpart of ADR-0053 D-B (#3912). Both drivers let the writer
9+
decide a datetime value's runtime type, and both compare across types by type
10+
bracket rather than by value — so a string comparand never matched a `Date`
11+
value, in either direction, for **every** operator including `$gte`.
12+
13+
A datetime column genuinely held both forms: the drivers' own
14+
`created_at`/`updated_at` defaults bind a `Date` (mongo) or an ISO string
15+
(memory), while REST/JSON writes, relative-date tokens and `initialData`
16+
fixtures supply the other. A dashboard date window therefore answered with
17+
whichever half happened to match the comparand's type — on MongoDB, where
18+
`created_at` is a BSON `Date` and dashboard bounds are strings, that meant
19+
**no rows at all**, which is worse than the final-day loss #3777 fixed.
20+
21+
Each driver now has one canonical form, applied on write and to every filter
22+
comparand:
23+
24+
| Driver | `datetime` | `date` |
25+
|---|---|---|
26+
| `driver-mongodb` | BSON `Date` — the dialect's native instant, its `timestamptz` | `YYYY-MM-DD` text |
27+
| `driver-memory` | canonical UTC ISO text (sorts chronologically under the string comparison mingo performs; survives JSON persistence) | `YYYY-MM-DD` text |
28+
29+
Both learn their temporal fields from `syncSchema`, so an object that was never
30+
declared is left exactly as written — the drivers do not guess types from
31+
values. `driver-memory` additionally converges rows already in the table when
32+
the schema arrives, which catches `initialData` fixtures and anything a
33+
persistence adapter restored (the in-memory analogue of
34+
`backfillCanonicalDatetimes`, and idempotent like it).
35+
36+
`Field.date` deliberately stays timezone-naive text on both — converting it to
37+
an instant would invent a midnight and re-couple it to a zone. The
38+
calendar-day bound semantics from #3777/#4042 are unchanged and now compose
39+
with the converged storage: the whole-day rewrite runs on the calendar string
40+
first, and only the resulting bound is converted to the storage form.

docs/adr/0053-date-and-datetime-semantics.md

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -717,3 +717,73 @@ row-result coverage for the `$lte`/`$between` upper-bound cells now lives in
717717
storage, dialect physical forms, boundary rollovers) and the strategy/preview
718718
suites; the full matrix program (relative-token × live-driver × timezone)
719719
remains open under D-A3.
720+
721+
---
722+
723+
## Addendum (2026-07-30) — the non-SQL drivers get one storage form too (#4047)
724+
725+
> **Status:** landed. Extends the 2026-07-29 addendum (D-B, `Field.datetime`
726+
> has ONE storage form per SQL dialect) to `driver-memory` and
727+
> `driver-mongodb`, which had the same defect for the same reason and one worse
728+
> symptom.
729+
730+
### D-E1 — Type-bracket comparison makes a mixed column WORSE than it is on SQL
731+
732+
MongoDB compares across BSON types by type bracket, and mingo copies that rule
733+
for JS types: a string comparand never matches a `Date` value, in either
734+
direction, **for every operator**`$gte` included, not just the upper bound
735+
#3777 was about. SQLite's affinity rules at least left one half reachable; here
736+
a window silently answers with whichever half matches the comparand's type.
737+
738+
And both columns really were mixed. Each driver's own `created_at`/`updated_at`
739+
default writes one form (`new Date()` on mongo, `new Date().toISOString()` in
740+
memory) while REST/JSON writes, relative-date tokens and `initialData` fixtures
741+
supply the other. On MongoDB the dashboard's default window — string bounds
742+
against a BSON-`Date` `created_at` — therefore returned **nothing at all**.
743+
744+
### D-E2 — One canon per driver, chosen by what the store natively has
745+
746+
| Driver | `datetime` | Why |
747+
|---|---|---|
748+
| `driver-mongodb` | BSON `Date` | the dialect's native instant: indexable, range-queryable, timezone-unambiguous. The `timestamptz` of this driver. |
749+
| `driver-memory` | canonical UTC ISO text | no native instant type exists; ISO-8601 UTC sorts chronologically under the plain string comparison mingo performs, and it is the wire form, so it survives JSON persistence unchanged. |
750+
751+
`Field.date` stays timezone-naive `YYYY-MM-DD` text on both, for the Phase 1
752+
reason: an instant would invent a midnight and re-couple the value to a zone.
753+
754+
Both drivers learn their temporal fields from `syncSchema` — the only place a
755+
driver is handed an object definition — into the direct equivalent of
756+
`SqlDriver.datetimeFields`/`dateFields`. An **undeclared** object is left
757+
exactly as written: these drivers are schemaless stores, and guessing a type
758+
from a value would coerce data the platform never claimed to own.
759+
760+
### D-E3 — Existing rows, and how this composes with the bound semantics
761+
762+
`driver-memory` converges the rows already in a table when the schema arrives,
763+
which is what catches `initialData` fixtures and persistence-adapter restores
764+
(both land before any schema is declared). It is the in-memory analogue of
765+
`backfillCanonicalDatetimes` and idempotent like it. MongoDB gets no automatic
766+
backfill — rewriting a real collection is a migration, not a boot step — so a
767+
pre-existing collection keeps whatever it holds until migrated; new and updated
768+
documents converge from the first write.
769+
770+
Ordering is load-bearing where D-D meets D-E: the calendar-day upper-bound
771+
rewrite (#3777/#4042) is a *calendar* operation and runs on the bare-day
772+
STRING first; only the resulting bound is converted to the storage form.
773+
Converting first would hand `nextUtcCalendarDay` a `Date`, which it correctly
774+
refuses to widen — and the whole-day window would silently narrow back to an
775+
instant.
776+
777+
### D-E4 — Consequences for D-A3
778+
779+
The conformance matrix's **driver** axis now has non-SQL cells worth asserting,
780+
and its **storage-form** axis gains `mixed-writer-form` for them. Row-result
781+
cover lives in `mongodb-datetime-storage.test.ts` (against a real MongoDB via
782+
`mongodb-memory-server`) and `memory-datetime-storage.test.ts`. Still open
783+
under D-A3: the relative-token × live-driver × timezone combinations.
784+
785+
One evaluator remains unaligned on both the D-D and D-E rules:
786+
`@objectstack/formula`'s `matchesFilterCondition` (the RLS write-side `check`
787+
path), which cannot depend on `@objectstack/core` for the shared primitive.
788+
Deciding its home — a private copy, as `formula` already keeps for `today()`,
789+
or lowering the utility into `spec`/`types` — is the remaining piece.
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `Field.datetime` has ONE storage form in the in-memory driver — canonical
5+
* UTC ISO text (#4047), the memory twin of ADR-0053 D-B (#3912).
6+
*
7+
* mingo compares across JS types the way MongoDB compares across BSON types:
8+
* a string comparand never matches a `Date` value, in either direction, for
9+
* EVERY operator — `$gte` included. And a datetime column really did hold
10+
* both: the driver's own `created_at`/`updated_at` defaults write
11+
* `new Date().toISOString()` (string) while `initialData` fixtures and direct
12+
* SDK callers hand it `Date` objects. So a date window answered with whichever
13+
* half matched the comparand's type and silently dropped the other.
14+
*
15+
* Canonical ISO TEXT is the right canon here — unlike mongo, this store has no
16+
* native instant type, and ISO-8601 UTC text sorts chronologically under plain
17+
* string comparison, which is exactly what mingo does. It is also the wire
18+
* form, so a value round-trips through JSON persistence unchanged.
19+
*/
20+
21+
import { describe, it, expect, beforeEach } from 'vitest';
22+
import { InMemoryDriver } from './memory-driver.js';
23+
24+
const ids = (rows: any[]) => rows.map((r: any) => r.id).sort();
25+
26+
/** The object declaration is what teaches the driver which fields are temporal. */
27+
const TASK_SCHEMA = {
28+
name: 'task',
29+
fields: {
30+
title: { type: 'string' },
31+
created_at: { type: 'datetime' },
32+
due_at: { type: 'datetime' },
33+
created_on: { type: 'date' },
34+
},
35+
};
36+
37+
describe('InMemoryDriver Field.datetime storage (#4047)', () => {
38+
let driver: InMemoryDriver;
39+
40+
beforeEach(async () => {
41+
driver = new InMemoryDriver({});
42+
await driver.connect();
43+
await driver.syncSchema('task', TASK_SCHEMA);
44+
});
45+
46+
/** Both writer forms of the same instant, in one column. */
47+
async function seedMixed(): Promise<void> {
48+
const rows: Array<[string, unknown]> = [
49+
['s_morning', '2026-07-28T09:15:00.000Z'],
50+
['s_evening', '2026-07-28T21:40:00.000Z'],
51+
['d_midnight', new Date('2026-07-28T00:00:00Z')],
52+
['d_yesterday', new Date('2026-07-27T14:00:00Z')],
53+
['d_next_day', new Date('2026-07-29T00:00:00Z')],
54+
['s_old', '2026-04-19T10:00:00.000Z'],
55+
];
56+
for (const [id, created_at] of rows) {
57+
await driver.create('task', { id, title: id, created_at });
58+
}
59+
}
60+
61+
it('stores every writer form as canonical UTC ISO text', async () => {
62+
await seedMixed();
63+
const raw = await driver.find('task', {} as any);
64+
for (const row of raw) {
65+
expect(typeof (row as any).created_at, `${(row as any).id} stored form`).toBe('string');
66+
expect((row as any).created_at).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
67+
}
68+
const midnight = raw.find((r: any) => r.id === 'd_midnight') as any;
69+
expect(midnight.created_at).toBe('2026-07-28T00:00:00.000Z');
70+
});
71+
72+
it('a date window reaches rows written in BOTH forms', async () => {
73+
await seedMixed();
74+
const found = await driver.find('task', {
75+
where: { created_at: { $gte: '2026-04-29', $lte: '2026-07-28' } },
76+
} as any);
77+
expect(ids(found)).toEqual(['d_midnight', 'd_yesterday', 's_evening', 's_morning']);
78+
});
79+
80+
it('a Date-object comparand reaches the same rows', async () => {
81+
await seedMixed();
82+
const found = await driver.find('task', {
83+
where: {
84+
created_at: {
85+
$gte: new Date('2026-04-29T00:00:00Z'),
86+
$lt: new Date('2026-07-29T00:00:00Z'),
87+
},
88+
},
89+
} as any);
90+
expect(ids(found)).toEqual(['d_midnight', 'd_yesterday', 's_evening', 's_morning']);
91+
});
92+
93+
it('keeps #3777/#4042 bound semantics on top of the converged storage', async () => {
94+
await seedMixed();
95+
const gte = await driver.find('task', { where: { created_at: { $gte: '2026-07-28' } } } as any);
96+
expect(ids(gte)).toEqual(['d_midnight', 'd_next_day', 's_evening', 's_morning']);
97+
98+
const lt = await driver.find('task', { where: { created_at: { $lt: '2026-07-28' } } } as any);
99+
expect(ids(lt)).toEqual(['d_yesterday', 's_old']);
100+
101+
const instant = await driver.find('task', {
102+
where: { created_at: { $lte: '2026-07-28T12:00:00.000Z' } },
103+
} as any);
104+
expect(ids(instant)).toEqual(['d_midnight', 'd_yesterday', 's_morning', 's_old']);
105+
});
106+
107+
it('$between and the array spelling take the same coercion', async () => {
108+
await seedMixed();
109+
const between = await driver.find('task', {
110+
where: { created_at: { $between: ['2026-04-29', '2026-07-28'] } },
111+
} as any);
112+
expect(ids(between)).toEqual(['d_midnight', 'd_yesterday', 's_evening', 's_morning']);
113+
114+
const array = await driver.find('task', {
115+
where: [['created_at', '>=', '2026-04-29'], 'and', ['created_at', '<=', '2026-07-28']],
116+
} as any);
117+
expect(ids(array)).toEqual(['d_midnight', 'd_yesterday', 's_evening', 's_morning']);
118+
});
119+
120+
it('normalises rows supplied as initialData, not just ones written through create()', async () => {
121+
// A seeded fixture is a WRITE the driver never saw — but it is the shape
122+
// most tests and demos use, so leaving it un-normalised would keep the
123+
// mixed column alive on exactly the path people look at first.
124+
const seeded = new InMemoryDriver({
125+
initialData: {
126+
task: [
127+
{ id: 'i_date', created_at: new Date('2026-07-28T09:15:00Z') },
128+
{ id: 'i_iso', created_at: '2026-07-28T21:40:00.000Z' },
129+
],
130+
},
131+
});
132+
await seeded.connect();
133+
await seeded.syncSchema('task', TASK_SCHEMA);
134+
135+
const found = await seeded.find('task', {
136+
where: { created_at: { $gte: '2026-07-28', $lte: '2026-07-28' } },
137+
} as any);
138+
expect(ids(found)).toEqual(['i_date', 'i_iso']);
139+
});
140+
141+
it('update() puts the new value in the same form', async () => {
142+
// `created_at` is deliberately immutable in this driver (it re-preserves
143+
// the original on every update), so the mutable datetime field is the
144+
// honest subject for an update-path assertion.
145+
await driver.create('task', { id: 'u1', due_at: '2026-04-19T10:00:00.000Z' });
146+
await driver.update('task', 'u1', { due_at: new Date('2026-07-28T09:15:00Z') });
147+
const row: any = (await driver.find('task', { where: { id: 'u1' } } as any))[0];
148+
expect(row.due_at).toBe('2026-07-28T09:15:00.000Z');
149+
150+
// …and the converged value is reachable by a date window, which is the
151+
// point of converging it.
152+
const found = await driver.find('task', {
153+
where: { due_at: { $gte: '2026-07-28', $lte: '2026-07-28' } },
154+
} as any);
155+
expect(ids(found)).toEqual(['u1']);
156+
});
157+
158+
it('a Field.date column stays calendar-day text — not widened into an instant', async () => {
159+
for (const [id, created_on] of [
160+
['on_early', '2026-04-19'],
161+
['on_mid', '2026-07-28'],
162+
['on_late', '2026-07-29'],
163+
// A Date written to a `date` field collapses to its UTC calendar day.
164+
['on_obj', new Date('2026-07-28T21:40:00Z')],
165+
] as const) {
166+
await driver.create('task', { id, created_on });
167+
}
168+
const all = await driver.find('task', {} as any);
169+
for (const row of all) expect(typeof (row as any).created_on).toBe('string');
170+
expect((all.find((r: any) => r.id === 'on_obj') as any).created_on).toBe('2026-07-28');
171+
172+
const found = await driver.find('task', {
173+
where: { created_on: { $gte: '2026-04-29', $lte: '2026-07-28' } },
174+
} as any);
175+
expect(ids(found)).toEqual(['on_mid', 'on_obj']);
176+
});
177+
178+
it('an undeclared object is left alone (no schema → no coercion)', async () => {
179+
await driver.create('freeform', { id: 'f1', when: new Date('2026-07-28T09:15:00Z') });
180+
const row: any = (await driver.find('freeform', {} as any))[0];
181+
expect(row.when).toBeInstanceOf(Date);
182+
});
183+
});

0 commit comments

Comments
 (0)