Skip to content

Commit 54c2627

Browse files
os-zhuangclaude
andauthored
docs: correct temporal-field storage and filter semantics in the ObjectQL reference (#4051)
* fix(driver-sql): Field.date NOW() default reads the UTC calendar day on PG/MySQL (#4022); raise cloud-connection seed-lookup test timeout Two follow-ups from the #3994 line: 1. Field.date + defaultValue NOW() emitted a bare CURRENT_TIMESTAMP default, which resolves the calendar day in the SERVER's timezone on Postgres — measured: a UTC-12 server recorded yesterday's date; an Asia/Shanghai server records tomorrow for every default after 16:00 UTC. MySQL 8.0 additionally rejects the bare default on a DATE column outright (MariaDB is merely permissive, and the driver's UTC-pinned session masked the semantic half there). nowColumnDefault now emits a UTC expression default on both dialects — timezone('utc', now())::date on Postgres, (cast(utc_timestamp() as date)) on MySQL — the #3994 D-C3 construction one type over. Defaults only govern newly created columns (D-B3 policy). Live tests pin the expression and a defaulted-insert round-trip. 2. marketplace-install-local-seed-lookup.test.ts: 30s → 120s timeout. The test cold-imports the real @objectstack/runtime on purpose; under a fully parallel turbo run that import stalls on core contention and the 30s cap produced recurring false reds (observed 3x, passes standalone). A genuine hang still fails, just later. Closes #4022 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TPxNwPnjcn599ujXpU3ibJ * docs: correct temporal-field storage and filter semantics in the ObjectQL reference The temporal line (#3912 #3928 #3942 #3954 #3979 #3994 #4022) changed what a Field.date / Field.datetime / Field.time physically IS, and three hand-written docs still described the pre-#3912 world. Audited against the implementation (sql-driver.ts canonicalUtcDatetime / canonicalTimeOfDay / toDateOnly / nowColumnDefault / createColumn) and ADR-0053 addenda D-B1..D-B4, D-C1..D-C3, with an adversarial verification pass. - protocol/objectql/types.mdx: per-type storage form and physical column type per dialect — canonical UTC ISO TEXT on SQLite, timestamptz on Postgres, DATETIME(3) on MySQL (with why NOT TIMESTAMP: 2038 ceiling, dropped milliseconds, session-tz conversion); time as HH:MM:SS gaining .fff only when the milliseconds are non-zero; the MySQL literal respelling; and a new defaultValue: 'NOW()' section stating the UTC-clock rule. - protocol/objectql/query-syntax.mdx: the comparand table (a filter value is canonicalised by the same function as a write, so both sides of a comparison share one shape on every dialect) and the date-vs-datetime range trap — a $between of bare dates against a datetime column stops at midnight. - data-modeling/queries.mdx: the same comparand rules at the query-authoring altitude, plus UTC bucket boundaries for date bucketing. Scope note: nine further docs were audited in the same pass but their agents ran against a stale base commit that predated this work, so their output described superseded behaviour and is deliberately NOT included. Their pristine text carries no incorrect temporal claim — only looser wording — so nothing is left wrong by omitting them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TPxNwPnjcn599ujXpU3ibJ * chore: trigger CI The docs commit landed on the branch without GitHub Actions firing (0 workflow runs for that SHA; only the Vercel status attached). Neither a close/reopen re-triggered it, so this empty commit forces a synchronize event to get real CI signal before merge. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TPxNwPnjcn599ujXpU3ibJ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 12a19a8 commit 54c2627

4 files changed

Lines changed: 594 additions & 204 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
---
3+
4+
docs: correct the temporal-field storage and filter semantics in the ObjectQL type/query reference (#3912 #3942 #3994 #4022)
5+
6+
Documentation only — no package changes, so this changeset releases nothing.

content/docs/data-modeling/queries.mdx

Lines changed: 102 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ const query = {
6161
| Operator | Description | Example |
6262
|:---|:---|:---|
6363
| `$contains` | String contains substring | `{ title: { $contains: 'urgent' } }` |
64+
| `$notContains` | String does NOT contain substring | `{ title: { $notContains: 'spam' } }` |
6465
| `$startsWith` | String starts with | `{ email: { $startsWith: 'admin' } }` |
6566
| `$endsWith` | String ends with | `{ email: { $endsWith: '@acme.com' } }` |
6667

@@ -73,6 +74,41 @@ const query = {
7374

7475
---
7576

77+
## Date, Datetime & Time Comparands
78+
79+
On SQL-backed objects, filter values on temporal fields are **canonicalised by the same
80+
functions the driver's write path uses**, so the two sides of a comparison can never
81+
disagree about shape:
82+
83+
| Field type | Canonical comparand | Semantics |
84+
|:---|:---|:---|
85+
| `date` | `YYYY-MM-DD` | Timezone-naive calendar day. A `Date` collapses to its UTC day; a longer ISO string is truncated to its leading date. |
86+
| `datetime` | `YYYY-MM-DDTHH:MM:SS.sssZ` | A UTC instant. A `Date`, an epoch-millisecond number, a bare `YYYY-MM-DD` (→ midnight UTC) and a zone-naive `YYYY-MM-DD HH:MM:SS` (→ read as UTC) all fold to this one form. |
87+
| `time` | `HH:MM:SS`, with `.fff` **only** when the milliseconds are non-zero | Timezone-naive wall clock. `'14:30'` and `'14:30:00'` canonicalise identically, so they are the same filter. |
88+
89+
```typescript
90+
{
91+
object: 'meeting',
92+
where: {
93+
meeting_day: { $gte: '2026-01-01' }, // date → '2026-01-01'
94+
starts_at: { $gte: '2026-01-01T00:00:00Z' }, // datetime → '2026-01-01T00:00:00.000Z'
95+
start_time: { $between: ['09:00', '18:00'] } // time → '09:00:00' / '18:00:00'
96+
}
97+
}
98+
```
99+
100+
<Callout type="info">
101+
Canonicalisation runs on **every SQL dialect**, not just SQLite — a zone-naive string bound
102+
into a Postgres `timestamptz` would otherwise be read in the *server's* timezone. MySQL
103+
is the one dialect that cannot parse the `T`/`Z` spelling, so the driver binds the same
104+
instant as a MySQL datetime literal — a physical respelling only; every layer above the
105+
bind (filter authoring, API payloads, CEL) stays on the canonical `…Z` form. Values the
106+
driver cannot interpret (empty strings, junk) pass through untouched rather than being
107+
silently rewritten.
108+
</Callout>
109+
110+
---
111+
76112
## Logical Operators
77113

78114
### `$and` — All conditions must match
@@ -167,7 +203,7 @@ Sort results with `orderBy` (array of sort nodes):
167203
}
168204
```
169205

170-
### Cursor-Based Pagination (Keyset)
206+
### Cursor-Based Pagination (Keyset) — not implemented
171207

172208
```typescript
173209
{
@@ -176,9 +212,13 @@ Sort results with `orderBy` (array of sort nodes):
176212
}
177213
```
178214

179-
<Callout type="info">
180-
`cursor` is an opaque record (`Record<string, unknown>`) carrying the keyset
181-
position from the previous page. There is no `keyset`/`after` query property.
215+
<Callout type="warn">
216+
`cursor` is declared on `QuerySchema` as an opaque record (`Record<string, unknown>`)
217+
and `QueryBuilder.cursor()` will set it on a query, but **nothing on the server reads
218+
it** — the query engine, the REST query dispatcher, and the SQL / in-memory / MongoDB
219+
drivers all ignore it, so a cursor query silently returns the same first page every
220+
time. Use `limit` + `offset` until keyset pagination is wired up. (There is no
221+
`keyset`/`after` query property either.)
182222
</Callout>
183223

184224
---
@@ -211,17 +251,27 @@ position from the previous page. There is no `keyset`/`after` query property.
211251
This object form of a field node isn't wired up for top-level `fields` projection. When the
212252
object's schema is registered, the engine's unknown-field filter compares each entry against
213253
the schema's field names via `String(f)`, so an object entry never matches and is silently
214-
dropped from the projection — the aliased field is simply missing from results, no error. Use
215-
`expand` to pull in a relationship's fields instead.
254+
dropped from the projection — the aliased field is simply missing from results, no error.
255+
256+
Dotted relationship paths (`'owner.name'`) fare no better: the unknown-field filter validates
257+
only the **head** segment and keeps the path, but there is no populate step anywhere in the
258+
engine, so the SQL driver selects `owner.name` verbatim and the database rejects it. What you
259+
get back depends on whether the driver recognises that dialect's error text (`no such column`,
260+
or `column … does not exist`): if it does, its recovery retry re-runs the query as `SELECT *`
261+
and you get every column; if it doesn't, the error is rethrown. Either way there is never an
262+
`owner.name` key.
263+
264+
Use `expand` to pull in a relationship's fields instead.
216265
</Callout>
217266

218267
---
219268

220269
## Expand (Related Records)
221270

222-
Load related records through `lookup` / `master_detail` fields with `expand`.
223-
Each key is a relationship field name; the value is a nested query that can
224-
select fields, filter, and expand further (default max depth: 3).
271+
Load related records through `lookup`, `master_detail`, and `user` fields with
272+
`expand`. Each key is a relationship field name; the value is a nested query that
273+
can select fields, filter, and expand further (max depth 3 — a fixed constant, not
274+
configurable).
225275

226276
```typescript
227277
{
@@ -239,8 +289,12 @@ select fields, filter, and expand further (default max depth: 3).
239289
```
240290

241291
The engine resolves `expand` via batch `$in` queries (driver-agnostic), so it
242-
works on every driver. Per-parent `limit` / `offset` / `orderBy` are **not**
243-
applied on this path.
292+
works on every driver. A nested `limit` / `offset` is **not forwarded at all**
293+
one batch query serves every parent, so a per-parent window cannot be expressed.
294+
A nested `orderBy` *is* forwarded to that batch query, but it has no observable
295+
effect: the expanded records are re-keyed to each parent by id, so a multi-value
296+
relationship keeps the order stored on the parent record. Paginate or sort by
297+
querying the related object directly.
244298

245299
---
246300

@@ -481,6 +535,38 @@ As noted under [Aggregations](#aggregations) above, `having` is not currently en
481535
it's dropped before reaching the aggregation engine or any driver.
482536
</Callout>
483537

538+
### Date Bucketing in `groupBy`
539+
540+
A `groupBy` entry is either a bare field name or a structured node that buckets a
541+
`date` / `datetime` column into uniform periods:
542+
543+
```typescript
544+
{
545+
object: 'deal',
546+
aggregations: [
547+
{ function: 'sum', field: 'amount', alias: 'revenue' }
548+
],
549+
groupBy: ['stage', { field: 'closed_at', dateGranularity: 'quarter' }]
550+
}
551+
```
552+
553+
`dateGranularity` accepts `day` | `week` | `month` | `quarter` | `year`. The engine
554+
pushes bucketing down to the driver only when that driver advertises the granularity
555+
via `supports.queryDateGranularity` — SQLite reports `week: false`, for instance, so a
556+
weekly bucket falls back to fetching rows and bucketing in memory. The fallback is
557+
transparent to the query — you get the same buckets either way.
558+
559+
<Callout type="warn">
560+
Buckets are computed in **UTC**. A non-UTC reference timezone is only reachable through
561+
`ObjectQL.aggregate(object, { …, timezone })`; the `POST /api/v1/data/:object/query`
562+
route forwards only `where` / `groupBy` / `aggregations`, so a query sent over REST
563+
always buckets on UTC calendar boundaries.
564+
565+
The optional `alias` on a structured `groupBy` node is honoured only on the in-memory
566+
path. The SQL driver ignores it and always projects the bucket under the **field name**,
567+
so don't depend on the aliased key being present.
568+
</Callout>
569+
484570
---
485571

486572
## Common Query Patterns
@@ -530,9 +616,12 @@ it's dropped before reaching the aggregation engine or any driver.
530616
```typescript
531617
{
532618
object: 'activity',
533-
fields: ['id', 'type', 'description', 'user.name', 'created_at'],
619+
fields: ['id', 'type', 'description', 'user', 'created_at'],
620+
expand: {
621+
user: { object: 'user', fields: ['name'] }
622+
},
534623
where: {
535-
created_at: { $gte: '2026-01-01T00:00:00Z' }
624+
created_at: { $gte: '2026-01-01T00:00:00Z' } // canonicalised to '…T00:00:00.000Z'
536625
},
537626
orderBy: [{ field: 'created_at', order: 'desc' }],
538627
limit: 10

0 commit comments

Comments
 (0)