diff --git a/.changeset/temporal-docs-accuracy.md b/.changeset/temporal-docs-accuracy.md
new file mode 100644
index 0000000000..4f4747f65c
--- /dev/null
+++ b/.changeset/temporal-docs-accuracy.md
@@ -0,0 +1,6 @@
+---
+---
+
+docs: correct the temporal-field storage and filter semantics in the ObjectQL type/query reference (#3912 #3942 #3994 #4022)
+
+Documentation only — no package changes, so this changeset releases nothing.
diff --git a/content/docs/data-modeling/queries.mdx b/content/docs/data-modeling/queries.mdx
index 69d71c59fa..1a10a54d1e 100644
--- a/content/docs/data-modeling/queries.mdx
+++ b/content/docs/data-modeling/queries.mdx
@@ -61,6 +61,7 @@ const query = {
| Operator | Description | Example |
|:---|:---|:---|
| `$contains` | String contains substring | `{ title: { $contains: 'urgent' } }` |
+| `$notContains` | String does NOT contain substring | `{ title: { $notContains: 'spam' } }` |
| `$startsWith` | String starts with | `{ email: { $startsWith: 'admin' } }` |
| `$endsWith` | String ends with | `{ email: { $endsWith: '@acme.com' } }` |
@@ -73,6 +74,41 @@ const query = {
---
+## Date, Datetime & Time Comparands
+
+On SQL-backed objects, filter values on temporal fields are **canonicalised by the same
+functions the driver's write path uses**, so the two sides of a comparison can never
+disagree about shape:
+
+| Field type | Canonical comparand | Semantics |
+|:---|:---|:---|
+| `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. |
+| `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. |
+| `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. |
+
+```typescript
+{
+ object: 'meeting',
+ where: {
+ meeting_day: { $gte: '2026-01-01' }, // date → '2026-01-01'
+ starts_at: { $gte: '2026-01-01T00:00:00Z' }, // datetime → '2026-01-01T00:00:00.000Z'
+ start_time: { $between: ['09:00', '18:00'] } // time → '09:00:00' / '18:00:00'
+ }
+}
+```
+
+
+Canonicalisation runs on **every SQL dialect**, not just SQLite — a zone-naive string bound
+into a Postgres `timestamptz` would otherwise be read in the *server's* timezone. MySQL
+is the one dialect that cannot parse the `T`/`Z` spelling, so the driver binds the same
+instant as a MySQL datetime literal — a physical respelling only; every layer above the
+bind (filter authoring, API payloads, CEL) stays on the canonical `…Z` form. Values the
+driver cannot interpret (empty strings, junk) pass through untouched rather than being
+silently rewritten.
+
+
+---
+
## Logical Operators
### `$and` — All conditions must match
@@ -167,7 +203,7 @@ Sort results with `orderBy` (array of sort nodes):
}
```
-### Cursor-Based Pagination (Keyset)
+### Cursor-Based Pagination (Keyset) — not implemented
```typescript
{
@@ -176,9 +212,13 @@ Sort results with `orderBy` (array of sort nodes):
}
```
-
-`cursor` is an opaque record (`Record`) carrying the keyset
-position from the previous page. There is no `keyset`/`after` query property.
+
+`cursor` is declared on `QuerySchema` as an opaque record (`Record`)
+and `QueryBuilder.cursor()` will set it on a query, but **nothing on the server reads
+it** — the query engine, the REST query dispatcher, and the SQL / in-memory / MongoDB
+drivers all ignore it, so a cursor query silently returns the same first page every
+time. Use `limit` + `offset` until keyset pagination is wired up. (There is no
+`keyset`/`after` query property either.)
---
@@ -211,17 +251,27 @@ position from the previous page. There is no `keyset`/`after` query property.
This object form of a field node isn't wired up for top-level `fields` projection. When the
object's schema is registered, the engine's unknown-field filter compares each entry against
the schema's field names via `String(f)`, so an object entry never matches and is silently
-dropped from the projection — the aliased field is simply missing from results, no error. Use
-`expand` to pull in a relationship's fields instead.
+dropped from the projection — the aliased field is simply missing from results, no error.
+
+Dotted relationship paths (`'owner.name'`) fare no better: the unknown-field filter validates
+only the **head** segment and keeps the path, but there is no populate step anywhere in the
+engine, so the SQL driver selects `owner.name` verbatim and the database rejects it. What you
+get back depends on whether the driver recognises that dialect's error text (`no such column`,
+or `column … does not exist`): if it does, its recovery retry re-runs the query as `SELECT *`
+and you get every column; if it doesn't, the error is rethrown. Either way there is never an
+`owner.name` key.
+
+Use `expand` to pull in a relationship's fields instead.
---
## Expand (Related Records)
-Load related records through `lookup` / `master_detail` fields with `expand`.
-Each key is a relationship field name; the value is a nested query that can
-select fields, filter, and expand further (default max depth: 3).
+Load related records through `lookup`, `master_detail`, and `user` fields with
+`expand`. Each key is a relationship field name; the value is a nested query that
+can select fields, filter, and expand further (max depth 3 — a fixed constant, not
+configurable).
```typescript
{
@@ -239,8 +289,12 @@ select fields, filter, and expand further (default max depth: 3).
```
The engine resolves `expand` via batch `$in` queries (driver-agnostic), so it
-works on every driver. Per-parent `limit` / `offset` / `orderBy` are **not**
-applied on this path.
+works on every driver. A nested `limit` / `offset` is **not forwarded at all** —
+one batch query serves every parent, so a per-parent window cannot be expressed.
+A nested `orderBy` *is* forwarded to that batch query, but it has no observable
+effect: the expanded records are re-keyed to each parent by id, so a multi-value
+relationship keeps the order stored on the parent record. Paginate or sort by
+querying the related object directly.
---
@@ -481,6 +535,38 @@ As noted under [Aggregations](#aggregations) above, `having` is not currently en
it's dropped before reaching the aggregation engine or any driver.
+### Date Bucketing in `groupBy`
+
+A `groupBy` entry is either a bare field name or a structured node that buckets a
+`date` / `datetime` column into uniform periods:
+
+```typescript
+{
+ object: 'deal',
+ aggregations: [
+ { function: 'sum', field: 'amount', alias: 'revenue' }
+ ],
+ groupBy: ['stage', { field: 'closed_at', dateGranularity: 'quarter' }]
+}
+```
+
+`dateGranularity` accepts `day` | `week` | `month` | `quarter` | `year`. The engine
+pushes bucketing down to the driver only when that driver advertises the granularity
+via `supports.queryDateGranularity` — SQLite reports `week: false`, for instance, so a
+weekly bucket falls back to fetching rows and bucketing in memory. The fallback is
+transparent to the query — you get the same buckets either way.
+
+
+Buckets are computed in **UTC**. A non-UTC reference timezone is only reachable through
+`ObjectQL.aggregate(object, { …, timezone })`; the `POST /api/v1/data/:object/query`
+route forwards only `where` / `groupBy` / `aggregations`, so a query sent over REST
+always buckets on UTC calendar boundaries.
+
+The optional `alias` on a structured `groupBy` node is honoured only on the in-memory
+path. The SQL driver ignores it and always projects the bucket under the **field name**,
+so don't depend on the aliased key being present.
+
+
---
## Common Query Patterns
@@ -530,9 +616,12 @@ it's dropped before reaching the aggregation engine or any driver.
```typescript
{
object: 'activity',
- fields: ['id', 'type', 'description', 'user.name', 'created_at'],
+ fields: ['id', 'type', 'description', 'user', 'created_at'],
+ expand: {
+ user: { object: 'user', fields: ['name'] }
+ },
where: {
- created_at: { $gte: '2026-01-01T00:00:00Z' }
+ created_at: { $gte: '2026-01-01T00:00:00Z' } // canonicalised to '…T00:00:00.000Z'
},
orderBy: [{ field: 'created_at', order: 'desc' }],
limit: 10
diff --git a/content/docs/protocol/objectql/query-syntax.mdx b/content/docs/protocol/objectql/query-syntax.mdx
index b43acf1a61..c7e1b97dea 100644
--- a/content/docs/protocol/objectql/query-syntax.mdx
+++ b/content/docs/protocol/objectql/query-syntax.mdx
@@ -30,10 +30,12 @@ import type { QueryAST } from '@objectstack/spec/data';
const query: QueryAST = {
object: 'contact',
- fields: ['name', 'email', { field: 'account', fields: ['company_name'] }],
- where: {
- is_active: true,
- 'account.industry': 'tech',
+ fields: ['name', 'email', 'account'],
+ where: { is_active: true },
+ // Related records are loaded through `expand` — not through a JOIN and not
+ // through a dotted `account.industry` path (see §2 and §4).
+ expand: {
+ account: { object: 'account', fields: ['company_name'] },
},
orderBy: [{ field: 'created_at', order: 'desc' }],
limit: 10,
@@ -41,7 +43,7 @@ const query: QueryAST = {
```
**Runtime compilation:**
-- PostgreSQL / MySQL → Optimized SQL with JOINs (`@objectstack/driver-sql`)
+- PostgreSQL / MySQL → parameterised single-table SQL (`@objectstack/driver-sql`); related records are a second, batched `$in` read, not a JOIN
- MongoDB → Native queries + aggregation pipeline (`@objectstack/driver-mongodb`)
- SQLite → Portable SQL, in-process or in-browser via `@objectstack/driver-sqlite-wasm`
- In-Memory → In-process evaluation, no external database (`@objectstack/driver-memory`)
@@ -70,7 +72,7 @@ interface QueryAST {
cursor?: Record; // Keyset pagination cursor
joins?: JoinNode[]; // Explicit JOINs
aggregations?: AggregationNode[]; // Aggregation functions
- groupBy?: string[]; // GROUP BY fields
+ groupBy?: GroupByNode[]; // GROUP BY targets (string | object)
having?: FilterCondition; // HAVING clause
windowFunctions?: WindowFunctionNode[]; // Window functions (OVER)
distinct?: boolean; // SELECT DISTINCT
@@ -78,6 +80,29 @@ interface QueryAST {
}
```
+
+**The protocol shape is wider than what the data engine executes.**
+
+`QuerySchema` validates the whole structure above, but `IDataEngine.find()` plus the
+shipped drivers run a subset. `SqlDriver.find()` builds only `where` / `orderBy` /
+`limit` / `offset` / `fields` (`packages/plugins/driver-sql/src/sql-driver.ts`), and
+`expand` is resolved afterwards by the engine as a batched `$in` read
+(`packages/objectql/src/engine.ts`). These members validate but are **not executed**
+on the `find()` path:
+
+| Member | Status |
+|:-------|:-------|
+| `joins` | No driver reads it — there is no `query.joins` / `ast.joins` consumer anywhere in `packages/` |
+| `having` | Never read: `engine.aggregate()` forwards only `object`/`where`/`groupBy`/`aggregations`, and the in-memory fallback has no HAVING stage |
+| `cursor` | Accepted by `EngineQueryOptions`, but no driver implements keyset pagination |
+| `distinct` | Not applied by `find()`; the SQL and in-memory drivers expose a separate `distinct(object, field, filters?)` method instead |
+| `windowFunctions` | Not applied by `find()`; `SqlDriver.findWithWindowFunctions()` is a separate method that is not on the `IDataDriver` contract |
+| Object `FieldNode`s (`{ field, fields }`) | The engine drops non-string entries from `fields` — use `expand` to project related records |
+| `search.fuzzy` / `boost` / `operator` / `minScore` / `language` / `highlight` | Parsed by the schema but ignored: only `query` and `fields` drive the expansion |
+
+`top` is the exception that *is* honored — the engine normalises it to `limit`.
+
+
### Key Types
```typescript
@@ -93,16 +118,23 @@ interface AggregationNode {
| 'count_distinct' | 'array_agg' | 'string_agg';
field?: string; // optional for COUNT(*)
alias: string; // result column alias
- distinct?: boolean; // DISTINCT before aggregation
- filter?: FilterCondition; // FILTER WHERE clause
+ distinct?: boolean; // DISTINCT before aggregation — in-memory path only
+ filter?: FilterCondition; // FILTER WHERE clause — protocol only, never applied
}
// FieldNode — field selection
type FieldNode = string | {
field: string;
- fields?: FieldNode[]; // nested select
+ fields?: FieldNode[]; // nested select — protocol only, dropped by the engine
alias?: string;
};
+
+// GroupByNode — GROUP BY target
+type GroupByNode = string | {
+ field: string;
+ dateGranularity?: 'day' | 'week' | 'month' | 'quarter' | 'year';
+ alias?: string; // defaults to `field`
+};
```
---
@@ -202,7 +234,7 @@ const query: QueryAST = {
| `$notContains` | String does not contain | `{ name: { $notContains: 'test' } }` |
| `$startsWith` | String starts with | `{ email: { $startsWith: 'admin' } }` |
| `$endsWith` | String ends with | `{ domain: { $endsWith: '.com' } }` |
-| `$between` | Range (inclusive) | `{ created_at: { $between: ['2024-01-01', '2024-12-31'] } }` |
+| `$between` | Range (inclusive) | `{ close_date: { $between: ['2024-01-01', '2024-12-31'] } }` |
| `$null` | Null check | `{ manager_id: { $null: true } }` / `{ phone: { $null: false } }` |
| `$exists` | Field exists (NoSQL) | `{ metadata: { $exists: true } }` |
@@ -279,7 +311,7 @@ const query: QueryAST = {
const query: QueryAST = {
object: 'opportunity',
where: {
- 'account.industry': 'tech', // AND (industry = tech)
+ type: 'new_business', // AND (type = new_business)
$or: [ // AND (
{ amount: { $gt: 100000 } }, // amount > 100000
{ is_strategic: true }, // OR is_strategic = true
@@ -287,23 +319,48 @@ const query: QueryAST = {
},
};
-// SQL: WHERE account.industry = 'tech'
+// SQL: WHERE type = 'new_business'
// AND (amount > 100000 OR is_strategic = true)
```
-### Date Filters
+### Date, Datetime, and Time Filters
+
+Before a comparison is built, the driver puts the comparand into the **same canonical
+form the column is stored in** (`SqlDriver.coerceFilterValue`) — the identical function
+the write path uses, on every dialect, so the two sides of a comparison can never be
+decided by their shapes disagreeing:
+
+| Field type | Canonical form | Meaning |
+|:-----------|:---------------|:--------|
+| `date` | `YYYY-MM-DD` | Timezone-naive calendar day |
+| `datetime` | `YYYY-MM-DDTHH:MM:SS.sssZ` | A UTC instant |
+| `time` | `HH:MM:SS` — `.fff` only when the milliseconds are non-zero | Timezone-naive wall-clock time of day |
{/* os:check */}
```typescript
-// Specific date
-where: { created_at: '2024-01-15' }
+import type { FilterCondition } from '@objectstack/spec/data';
+
+// `date` field — a bare calendar day matches that day
+const onDay: FilterCondition = { close_date: '2024-01-15' };
+
+// `date` range — $between is inclusive on both ends
+const inYear: FilterCondition = {
+ close_date: { $between: ['2024-01-01', '2024-12-31'] },
+};
+
+// `datetime` field — a bare `YYYY-MM-DD` is completed to midnight UTC
+// (`2024-01-15T00:00:00.000Z`), so this is an exact-instant match, not "that day"
+const atMidnight: FilterCondition = { created_at: '2024-01-15' };
-// Date range with $between
-where: { created_at: { $between: ['2024-01-01', '2024-12-31'] } }
+// A whole UTC day on a `datetime` needs a half-open range
+const duringDay: FilterCondition = {
+ created_at: { $gte: '2024-01-15', $lt: '2024-01-16' },
+};
-// Comparison operators on dates
-where: { due_date: { $lt: '2024-06-01' } }
-where: { created_at: { $gte: '2024-01-01' } }
+// `time` field — `'09:00'` is completed to `'09:00:00'`
+const businessHours: FilterCondition = {
+ start_time: { $gte: '09:00', $lte: '18:00' },
+};
```
A bare `YYYY-MM-DD` bound is a **calendar day**. As a lower bound (`$gte`) it
@@ -327,20 +384,29 @@ where: { phone: { $null: false } }
where: { metadata: { $exists: true } }
```
-### Nested Relation Filters
+### Filtering Across Relationships
+
+
+**Relation traversal inside `where` is not supported.** Neither the nested form
+(`where: { account: { industry: 'tech' } }`) nor a dotted path
+(`where: { 'account.industry': 'tech' }`) is resolved. `SqlDriver.applyFilters()` only
+recognises a nested object as an operator map when its keys start with `$`; anything
+else is compiled as a comparison against a single column of the queried table, and a
+dotted key is emitted verbatim, so Knex renders it as `"account"."industry"` against a
+table that was never joined.
+
-Filter through relationships without explicit joins:
+Filter on the local foreign key, or run two queries:
```typescript
-const query: QueryAST = {
- object: 'opportunity',
- where: {
- account: {
- industry: 'tech',
- annual_revenue: { $gt: 1000000 },
- },
- },
-};
+const techAccounts = await engine.find('account', {
+ where: { industry: 'tech', annual_revenue: { $gt: 1000000 } },
+ fields: ['id'],
+});
+
+const opportunities = await engine.find('opportunity', {
+ where: { account_id: { $in: techAccounts.map((a) => a.id) } },
+});
```
---
@@ -374,22 +440,22 @@ const query: QueryAST = {
// SQL: ORDER BY priority DESC, created_at ASC
```
-### Sort on Related Fields
+### Sorting on Related Fields
-```typescript
-const query: QueryAST = {
- object: 'contact',
- orderBy: [{ field: 'account.company_name', order: 'asc' }],
-};
-
-// SQL: ORDER BY account.company_name ASC
-```
+
+`orderBy` only reaches columns of the queried table. A dotted path
+(`account.company_name`) is handed to Knex verbatim and renders as
+`"account"."company_name"`, which the database rejects as an unknown column;
+`SqlDriver.find()` then retries **without the sort**, so the rows come back unordered
+rather than sorted — and no error surfaces. Denormalise the value onto the queried
+object (for example with a formula or rollup field) when you need to sort by it.
+
---
## 4. Relationships (Expand)
-The `expand` property enables **recursive loading of related records** through lookup and master_detail fields. Each key is a relationship field name; the value is a nested `QueryAST`.
+The `expand` property enables **recursive loading of related records** through `lookup`, `master_detail`, and `user` fields. Each key is a relationship field name; the value is a nested `QueryAST`.
### Basic Expand
@@ -452,9 +518,9 @@ The engine resolves `expand` via batch `$in` queries (driver-agnostic) with a de
### Filtered Expand
-Expansion follows **`lookup`** and **`master_detail`** fields — i.e. the foreign key
-lives on the object you are querying. The nested `QueryAST` can **filter** (`where`) and
-**select** (`fields`) the related records:
+Expansion follows **`lookup`**, **`master_detail`**, and **`user`** fields — i.e. the
+foreign key lives on the object you are querying. The nested `QueryAST` can **filter**
+(`where`) and **select** (`fields`) the related records:
```typescript
const query: QueryAST = {
@@ -549,7 +615,14 @@ const query: QueryAST = {
// { count: 100, total: 5000000, average: 50000, min_amount: 10000, max_amount: 500000 }
```
-**Supported functions:** `count`, `sum`, `avg`, `min`, `max`, `count_distinct`, `array_agg`, `string_agg`
+**Schema enum:** `count`, `sum`, `avg`, `min`, `max`, `count_distinct`, `array_agg`, `string_agg`.
+
+
+Only `count`, `sum`, `avg`, `min`, and `max` are portable. `SqlDriver.mapAggregateFunc()`
+throws `Unsupported aggregate function: ` for `count_distinct`, `array_agg`, and
+`string_agg`; those three are implemented by the MongoDB driver and by the engine's
+in-memory aggregation fallback, but not by the SQL drivers.
+
### Group By Multiple Fields
@@ -565,64 +638,106 @@ const query: QueryAST = {
};
```
-### HAVING Clause
+### HAVING Clause (protocol only)
-Filter groups after aggregation using the `having` property:
+`QuerySchema` defines a `having` property, but **nothing executes it**:
+`engine.aggregate()` forwards only `object` / `where` / `groupBy` / `aggregations` to
+the driver, no driver reads `query.having`, and the in-memory aggregation fallback has
+no HAVING stage. Filter the aggregated rows in application code:
```typescript
-const query: QueryAST = {
- object: 'opportunity',
+const rows = await engine.aggregate('opportunity', {
groupBy: ['account_id'],
aggregations: [
{ function: 'sum', field: 'amount', alias: 'total' },
],
- having: {
- total: { $gt: 1000000 }, // Only accounts with > $1M pipeline
- },
-};
+});
+
+// Only accounts with > $1M pipeline
+const bigAccounts = rows.filter((r) => r.total > 1_000_000);
+```
+
+### Date Bucketing
+
+A `groupBy` entry may be an object carrying `dateGranularity`, which buckets a
+`date`/`datetime` column into uniform periods. The bucketed value is projected under
+the field name (or `alias`, on the in-memory path):
-// SQL: HAVING SUM(amount) > 1000000
+```typescript
+const revenueByMonth = await engine.aggregate('order', {
+ where: { status: 'completed' },
+ groupBy: [{ field: 'created_at', dateGranularity: 'month' }],
+ aggregations: [
+ { function: 'sum', field: 'total_amount', alias: 'revenue' },
+ ],
+});
```
+**Granularities:** `day`, `week`, `month`, `quarter`, `year`. The engine pushes the
+bucket down to the driver only when it advertises native support for that granularity
+(`supports.queryDateGranularity`); otherwise it falls back to in-memory bucketing over
+the driver's raw rows.
+
---
## 6. Advanced Queries
### Distinct
-```typescript
-const query: QueryAST = {
- object: 'account',
- fields: ['industry'],
- distinct: true,
-};
+The `distinct` flag on `QueryAST` is **not applied by `find()`**. Distinct values come
+from the driver's own `distinct()` method (implemented by the SQL and in-memory drivers;
+it is not part of the `IDataDriver` contract):
-// SQL: SELECT DISTINCT industry FROM account
+```typescript
+const industries = await driver.distinct('account', 'industry');
```
+`SqlDriver.distinct()` presents each value exactly the way `find()` presents that
+column — a `date` as `YYYY-MM-DD` and a `time` as `HH:MM:SS[.fff]` on every dialect, a
+`datetime` folded to canonical UTC ISO on SQLite (the one dialect where storage differs
+from presentation; Postgres and MySQL hand back their own native temporal value) — and
+then re-deduplicates the presented values, because SQL `DISTINCT` compares the *stored*
+form.
+
### Full-Text Search
-The `search` parameter configures full-text search:
+The `search` parameter does **not** reach a full-text index. The engine expands it into
+an `$or` of `$contains` predicates across the object's server-resolved searchable fields
+(ADR-0061) and deletes `search` from the AST before the driver sees it — every driver
+already runs `$or`/`$contains`, so no driver support is needed (`SqlDriver` reports
+`supports.fullTextSearch: false`).
```typescript
const query: QueryAST = {
object: 'article',
search: {
query: 'ObjectStack tutorial',
- fields: ['title', 'content', 'tags'],
- fuzzy: true,
- boost: { title: 2.0 },
+ fields: ['title', 'content'],
},
limit: 10,
};
-
-// The engine expands `search` into an `$or` of `$contains` across the object's
-// searchable fields (ADR-0061); every driver runs it as native $or/$contains.
```
-### Joins
+Field resolution is server-side and never client-trusted: `search.fields` is
+**intersected** with the object's declared `searchableFields` (or, absent those, an
+auto-default of the name field plus short-text/enum fields), so naming a field outside
+that set does not widen the search. Multiple whitespace-separated terms are AND-ed and
+fields are OR-ed. Case sensitivity is the **driver's**, not the expansion's: the
+expansion emits a plain `$contains`, which `SqlDriver` compiles to a parameterised
+`LIKE '%…%'` with no case folding — so the dialect's own `LIKE`/collation rules decide —
+while the in-memory driver matches with a case-insensitive regex. Only `select` /
+`status` option *labels* are matched case-insensitively by the expansion itself.
+`fuzzy`, `boost`, `operator`, `minScore`, `language`, and `highlight` are accepted by
+the schema but ignored by the expansion.
+
+### Joins (protocol only)
-For cross-object queries beyond `expand`, use explicit joins:
+`QuerySchema` defines a `joins` array, but **no driver reads it** — there is no
+`query.joins` consumer anywhere in `packages/`, so a query carrying `joins` silently
+runs as a single-table query. Use `expand` (§4) for relationship loading, or two
+queries joined in application code.
+
+For reference, the protocol shape is:
```typescript
const query: QueryAST = {
@@ -637,19 +752,18 @@ const query: QueryAST = {
},
],
};
-
-// SQL: SELECT o.id, o.amount FROM orders o
-// INNER JOIN customers c ON o.customer_id = c.id
```
**Join types:** `inner`, `left`, `right`, `full`
### Window Functions
+`windowFunctions` on a `QueryAST` is ignored by `find()`. The SQL drivers implement
+window functions through a separate `findWithWindowFunctions()` method, which is not on
+the `IDataDriver` contract and is not surfaced by `IDataEngine`:
+
```typescript
-const query: QueryAST = {
- object: 'order',
- fields: ['id', 'customer_id', 'amount'],
+const ranked = await driver.findWithWindowFunctions('order', {
windowFunctions: [
{
function: 'row_number',
@@ -660,12 +774,15 @@ const query: QueryAST = {
},
},
],
-};
+});
// SQL: SELECT *, ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY amount DESC) AS rank
// FROM orders
```
+That method always selects `*` plus the window columns — it does not honor a `fields`
+projection.
+
---
## 7. Pagination
@@ -688,20 +805,30 @@ const page2 = await engine.find('customer', {
**Drawback:** Slow for large offsets (database still scans all skipped rows).
-### Cursor-Based Pagination
+### Keyset Pagination
+
+
+`cursor` is accepted by `QuerySchema` and `EngineQueryOptions`, but **no driver
+implements keyset pagination** — passing it has no effect. Express the keyset yourself
+as an ordinary `where` predicate on the sort key:
+
```typescript
// First page
-const result = await engine.find('customer', {
+const page = await engine.find('customer', {
limit: 10,
- orderBy: [{ field: 'id', order: 'asc' }],
+ orderBy: [{ field: 'created_at', order: 'asc' }],
});
-// Next page (use cursor)
-const nextResult = await engine.find('customer', {
- cursor: { id: result[result.length - 1].id },
+// Next page — seek past the last row instead of offsetting.
+// The comparand is canonicalised by the same function that wrote the column
+// (`coerceFilterValue` → `storageDatetimeValue`), so the range compare is an
+// ordinary indexable comparison on every dialect: canonical UTC ISO text on
+// SQLite, `timestamptz` on Postgres, `DATETIME(3)` on MySQL.
+const next = await engine.find('customer', {
+ where: { created_at: { $gt: page[page.length - 1].created_at } },
limit: 10,
- orderBy: [{ field: 'id', order: 'asc' }],
+ orderBy: [{ field: 'created_at', order: 'asc' }],
});
```
@@ -740,7 +867,6 @@ const products = await engine.find('product', {
search: {
query: searchTerm,
fields: ['name', 'description'],
- fuzzy: true,
},
orderBy: [{ field: 'popularity_score', order: 'desc' }],
limit: 20,
@@ -749,21 +875,29 @@ const products = await engine.find('product', {
### Analytics: Revenue by Month
+`month` is not a column — bucket the `created_at` instant with `dateGranularity`. Note
+that `engine.aggregate()` accepts only `where` / `groupBy` / `aggregations` (plus a
+`timezone` for bucketing): there is no `orderBy` or `limit` on this path, so sort the
+returned rows yourself.
+
```typescript
-const monthlyRevenue: QueryAST = {
- object: 'order',
+const monthlyRevenue = await engine.aggregate('order', {
where: {
status: 'completed',
+ // `created_at` is a datetime — a bare date is read as midnight UTC
created_at: { $gte: '2024-01-01' },
},
- groupBy: ['month'],
+ groupBy: [{ field: 'created_at', dateGranularity: 'month' }],
aggregations: [
{ function: 'sum', field: 'total_amount', alias: 'revenue' },
{ function: 'count', alias: 'order_count' },
{ function: 'avg', field: 'total_amount', alias: 'avg_order' },
],
- orderBy: [{ field: 'month', order: 'asc' }],
-};
+});
+
+const sorted = monthlyRevenue.sort((a, b) =>
+ String(a.created_at).localeCompare(String(b.created_at)),
+);
```
---
@@ -785,13 +919,19 @@ const rows = await engine.find('customer', {
### Security Violations
+Row-level scoping is applied by *narrowing* the query — the security middleware
+AND-merges its read filter into `where`, so an over-broad filter returns fewer rows
+rather than throwing. What throws is an operation the caller is not permitted to run at
+all, or a predicate that references a field the caller cannot read:
+
```typescript
try {
- await engine.find('account', {
- where: { owner_id: { $ne: currentUser.id } },
- });
+ await engine.find('account', { where: { owner_id: currentUser.id } });
} catch (error) {
- // PermissionDeniedError: [Security] Access denied to object 'account'
+ // PermissionDeniedError:
+ // [Security] Access denied: operation 'find' on object 'account' ...
+ // [Security] Access denied: query on 'account' references field(s) not
+ // readable by the caller: ...
}
```
diff --git a/content/docs/protocol/objectql/types.mdx b/content/docs/protocol/objectql/types.mdx
index 0f96207cd3..64b0b2957a 100644
--- a/content/docs/protocol/objectql/types.mdx
+++ b/content/docs/protocol/objectql/types.mdx
@@ -77,9 +77,9 @@ company_name:
```
**Database mapping:**
-- PostgreSQL: `VARCHAR(maxLength)` or `TEXT`
+- SQL driver: `TEXT` on every dialect. `maxLength` is enforced by record
+ validation, not by the column type — the DDL does not read it.
- MongoDB: `String`
-- Redis: `String`
**UI rendering:**
```html
@@ -104,7 +104,7 @@ description:
```
**Database mapping:**
-- PostgreSQL: `TEXT`
+- SQL driver: `TEXT`
- MongoDB: `String`
**UI rendering:**
@@ -129,7 +129,7 @@ bio:
```
**Database mapping:**
-- PostgreSQL: `TEXT`
+- SQL driver: `TEXT`
- MongoDB: `String`
**UI rendering:**
@@ -156,13 +156,13 @@ email:
unique: true
```
-**Validation:**
-- RFC 5322 compliant regex
-- Lowercase normalization
-- Domain validation (optional DNS check)
+**Validation:** a deliberately permissive, ReDoS-safe shape check — a local part,
+an `@`, and a dotted domain (`invalid_email` otherwise). It is **not** an RFC 5322
+parser, the value is **not** lowercased, and no DNS/MX lookup is performed. Stricter
+rules belong in a validation rule or custom validator.
**Database mapping:**
-- PostgreSQL: `VARCHAR(255)`
+- SQL driver: `VARCHAR(255)`
- MongoDB: `String`
**Use cases:**
@@ -181,10 +181,11 @@ website:
label: Website
```
-**Validation:**
-- Must include protocol (`http://`, `https://`)
-- Valid domain format
-- Optional reachability check
+**Validation:** accepts any `scheme://…` (not just `http`/`https` — `libsql://`,
+`postgres://`, `s3://`, `file://` all pass), plus root-/protocol-relative refs
+(`/path`, `//host/path`) and `data:` / `blob:` URIs. A bare scheme-less string
+with no leading `/` is rejected (`invalid_url`). There is **no** domain-format
+check and **no** reachability check.
**Use cases:**
- Company websites
@@ -200,17 +201,15 @@ Phone number with international format.
phone:
type: phone
label: Phone Number
- format: international # E.164 format
```
-**Storage format:** E.164 (`+12025551234`)
+**Storage format:** the string as entered — a `VARCHAR(255)` column. The engine
+does **not** normalize to E.164 and does **not** reformat for display.
-**Display format:** `(202) 555-1234`
-
-**Validation:**
-- Valid phone number format
-- Country code verification
-- Optional SMS capability check
+**Validation:** a shape check only — at least 5 characters drawn from digits and
+`+ ( ) - . ` and whitespace (`invalid_phone` otherwise). There is no country-code
+verification and no SMS-capability check. If you need E.164 at rest, normalize in
+a before-save trigger or a custom validator.
---
@@ -235,8 +234,10 @@ quantity:
- `min`/`max`: Range validation
**Database mapping:**
-- PostgreSQL: `NUMERIC(precision, scale)`
-- MongoDB: `Number` or `Decimal128`
+- SQL driver: a floating-point column (`REAL` on PostgreSQL/SQLite, `FLOAT` on
+ MySQL). `precision`/`scale` are validation and display metadata — the DDL does
+ **not** emit `NUMERIC(precision, scale)`.
+- MongoDB: `Number`
**Use cases:**
- Quantities, counts
@@ -258,23 +259,30 @@ annual_revenue:
defaultCurrency: USD
```
-**Storage:**
+**Storage:** a **bare number** — the same value shape as `number`.
+
```json
-{
- "value": 1234.56,
- "currency": "USD"
-}
+1234.56
```
+
+A currency value is **not** a `{ value, currency }` object. The currency *code*
+lives once on the field definition (`currencyConfig.defaultCurrency`), not on
+every stored value. The old per-value object shape (`CurrencyValueSchema`) was
+never consumed by the validator, the driver, or import coercion and is
+deprecated in the spec.
+
+
**Features:**
-- Multi-currency support
-- Automatic formatting ($1,234.56)
-- Exchange rate conversion (optional)
-- ISO 4217 currency codes
+- `currencyMode: fixed | dynamic` and a `defaultCurrency` code on the field
+- Codes are validated by **length only** (3 characters), so ISO 4217 (`USD`,
+ `EUR`, `CNY`) and non-ISO codes (`BTC`, `ETH`) both pass
+- `precision` (0–10, default 2) for decimal places
**Database mapping:**
-- PostgreSQL: `NUMERIC(18,2)` + `VARCHAR(3)` or `JSONB`
-- MongoDB: `{ value: Decimal128, currency: String }`
+- SQL driver: a floating-point column (`REAL` / `FLOAT`) — one column, no
+ companion currency column and no JSON blob
+- MongoDB: `Number`
---
@@ -292,7 +300,15 @@ discount_rate:
**Display:** `25.5%` (automatically adds % symbol)
-**Storage:** As decimal (0.255 for 25.5%)
+**Storage:** the percentage **number itself** — `25.5` means 25.5%, matching the
+`min: 0` / `max: 100` bounds above. It is *not* rescaled to a 0–1 ratio on write.
+Physically it is the same floating-point column as `number`.
+
+
+The separate `percent` **template filter** (`{{ record.rate | percent }}`) does
+take a 0–1 ratio and render it as `42%`. That is a formatting choice at render
+time, not the field type's storage convention — don't mix the two.
+
**Use cases:**
- Discounts, margins
@@ -312,11 +328,23 @@ birth_date:
label: Date of Birth
```
-**Storage format:** ISO 8601 (`2024-01-15`)
+**Storage format:** a **timezone-naive calendar day** — the `YYYY-MM-DD` string
+`2024-01-15`, never an instant (ADR-0053). A `Date` collapses to its **UTC**
+calendar day; a longer ISO string is truncated to its leading 10 characters. The
+same normalization is applied on write, on read, and to every filter comparand,
+so the two sides of a comparison can never disagree about what a date *is*.
+
+
+A `date` is never converted to a timestamp and never timezone-shifted. Storing
+UTC-midnight instants is exactly the "date-as-instant" mistake ADR-0053 removed —
+it renders as the *previous* day for any viewer west of UTC. If a value genuinely
+depends on a timezone, it is a `datetime`, not a `date`.
+
**Database mapping:**
-- PostgreSQL: `DATE`
-- MongoDB: `Date` (time set to 00:00:00 UTC)
+- SQL driver: `DATE` on PostgreSQL/MySQL, `TEXT` on SQLite — holding
+ `YYYY-MM-DD` on every dialect
+- MongoDB: no DDL (schemaless); the driver stores the value it is given
**Use cases:**
- Birthdays, anniversaries
@@ -326,7 +354,7 @@ birth_date:
---
#### `datetime`
-Date and time with timezone.
+A UTC instant.
```yaml
meeting_time:
@@ -334,11 +362,29 @@ meeting_time:
label: Meeting Time
```
-**Storage format:** ISO 8601 with timezone (`2024-01-15T14:30:00Z`)
+**Storage format:** the canonical, fixed-width, zone-explicit UTC instant
+`YYYY-MM-DDTHH:MM:SS.sssZ` — e.g. `2024-01-15T14:30:00.000Z`. Milliseconds are
+always present and the zone is always `Z`; an offset-bearing input such as
+`…T22:30:00+08:00` is rewritten to the equivalent `…Z` instant so text ordering
+stays chronological ordering.
**Database mapping:**
-- PostgreSQL: `TIMESTAMP WITH TIME ZONE`
-- MongoDB: `Date`
+- PostgreSQL: `timestamptz`
+- MySQL: `DATETIME(3)` — deliberately **not** `TIMESTAMP`, which is a 32-bit
+ epoch (a 2038 ceiling on the column every list view sorts by), drops the
+ milliseconds, and converts on read/write using the session timezone. The
+ canonical instant is bound as a MySQL literal (`YYYY-MM-DD HH:MM:SS.sss`,
+ no `T`/`Z`) because MySQL rejects ISO-8601 in a datetime literal.
+- SQLite: `TEXT` holding the canonical string — fixed width plus UTC means
+ lexicographic order is chronological order, so range filters use the index
+- MongoDB: no DDL (schemaless)
+
+
+Filter comparands go through the **same** canonicalization function as writes, on
+**every** dialect. That is what makes `$gte`/`$lt` windows and `$eq` behave
+identically on SQLite, PostgreSQL, and MySQL instead of depending on the shape
+the caller happened to pass.
+
---
@@ -352,7 +398,23 @@ business_hours_start:
defaultValue: "09:00:00"
```
-**Storage format:** `HH:MM:SS` — input accepts `HH:MM` or `HH:MM:SS` (with an optional fractional part and `Z`/offset). A `time` is a wall-clock value, not an instant: it is validated as a time-of-day, not parsed as a date.
+**Storage format:** `HH:MM:SS`, gaining a `.fff` millisecond suffix **only** when
+the milliseconds are non-zero (`14:30:00`, but `14:30:00.100`). Input accepts
+`HH:MM` or `HH:MM:SS` (with an optional fractional part and `Z`/offset); `14:30`
+is completed to `14:30:00`, so one wall clock can never split into several stored
+values. A `Date`, an epoch, or a full timestamp folds to its **UTC** time-of-day.
+A `time` is a wall-clock value, not an instant: it is validated as a time-of-day,
+not parsed as a date.
+
+**Database mapping:**
+- PostgreSQL: `time`
+- MySQL: `TIME(3)` — bare `TIME` is zero-precision and *rounds* a fractional
+ literal (`14:30:00.500` → `14:30:01`), which would change the stored wall clock
+- SQLite: `TEXT` holding the canonical string
+- MongoDB: no DDL (schemaless)
+
+As with `date` and `datetime`, filter comparands are canonicalized by the same
+function as writes, so `09:00 <= t <= 18:00` windows compare like against like.
**Use cases:**
- Business hours
@@ -361,6 +423,40 @@ business_hours_start:
---
+#### `defaultValue: 'NOW()'` on temporal fields
+
+`NOW()` is a framework convention meaning "use the database clock at insert
+time". The driver translates it into a dialect-native default that resolves
+against the **UTC** clock on every dialect, for `date`, `datetime`, and `time`
+alike:
+
+```yaml
+opened_at:
+ type: datetime
+ label: Opened At
+ defaultValue: "NOW()"
+```
+
+For `date` and `time` on PostgreSQL and MySQL the driver emits an explicit
+UTC **expression** default rather than a bare `CURRENT_TIMESTAMP`, which resolves
+the calendar day / wall clock in the *server's* timezone on PostgreSQL and the
+*inserting session's* timezone on MySQL — one instant producing three different
+stored values across the three dialects (and MySQL 8.0 rejects a bare
+`CURRENT_TIMESTAMP` default on `DATE`/`TIME` columns outright). On SQLite all
+three types use `strftime(…, 'now')` expressions that emit the canonical form
+directly. `datetime` on PostgreSQL/MySQL keeps the native `now()`, which is
+already UTC — the driver pins every MySQL connection with
+`SET time_zone = '+00:00'`.
+
+
+A DDL default only governs **newly created** columns. A column created before
+this convention keeps its legacy default and can still emit a zone-naive value on
+a defaulted insert; the read path repairs those to canonical form, so `find()`
+stays uniform without a data migration.
+
+
+---
+
### Boolean Types
#### `boolean`
@@ -374,9 +470,9 @@ is_active:
```
**Storage:**
-- PostgreSQL: `BOOLEAN`
+- SQL driver: `BOOLEAN` (SQLite stores `1`/`0`; the driver coerces it back to a
+ real JS boolean on read)
- MongoDB: `Boolean`
-- Redis: `1` or `0`
**UI rendering:**
```html
@@ -432,10 +528,12 @@ priority:
required: true
```
-**Storage:** Stores `value` (not `label`)
+**Storage:** Stores `value` (not `label`). Option `value`s must be lowercase
+machine identifiers — the spec rejects `New`, `In Progress`, or `Closed_Won`.
**Database mapping:**
-- PostgreSQL: `VARCHAR` or `ENUM`
+- SQL driver: `VARCHAR(255)` — the driver never emits a native `ENUM`, so adding
+ an option is a metadata change, not a schema migration
- MongoDB: `String`
**Use cases:**
@@ -460,7 +558,8 @@ tags:
```
**Storage:**
-- PostgreSQL: `TEXT[]` (array) or `JSONB`
+- SQL driver: a `JSON` column holding the serialized array — not a native
+ `TEXT[]`, so the same DDL works on SQLite and MySQL
- MongoDB: `[String]`
**Example value:** `['customer', 'partner']`
@@ -536,8 +635,19 @@ contacts:
```
**Database mapping:**
-- PostgreSQL: `UUID` + Foreign Key constraint
-- MongoDB: `ObjectId` or `String`
+- SQL driver: `VARCHAR(255)` holding the related record id. Not a native `UUID`
+ column — record ids are opaque strings. A `multiple: true` lookup becomes a
+ `JSON` column instead.
+- MongoDB: `String`
+
+
+A relationship field authored with `reference:` gets **no database-level
+`FOREIGN KEY` constraint**. The SQL driver's FK DDL is gated on a `reference_to`
+property that the spec's `reference` never populates, and `master_detail` /
+`tree` do not reach that branch at all. Referential integrity is enforced by the
+**engine** instead: `deleteBehavior` is applied on delete, which is what produces
+the `409 DELETE_RESTRICTED` above.
+
---
@@ -692,15 +802,24 @@ total_opportunity_value:
- Aggregates a child object that references this object (via its `lookup`/`master_detail` field)
- Set `relationshipField` only when the child has more than one reference back to this object
-**Database implementation:**
-- Materialized view (PostgreSQL)
-- Aggregation pipeline (MongoDB)
-- Background job recalculation
+**Optional `filter`:** a `where`-style `FilterCondition` restricting *which* child
+rows are aggregated, ANDed with the parent-FK match. This is what lets several
+summaries roll the same child object into different totals:
-**Performance:**
-- Real-time: Recalculate on child write (slow)
-- Batch: Update every N minutes (stale data)
-- Hybrid: Increment/decrement on simple changes
+```yaml
+total_signups:
+ type: summary
+ summaryOperations:
+ object: engagement
+ function: count
+ filter: { type: signup }
+```
+
+**Implementation:** the summary is a **real numeric column** on the parent,
+recomputed by the ObjectQL engine when a child row is inserted, updated, or
+deleted. There is no materialized view, no MongoDB aggregation pipeline, and no
+batch/hybrid recalculation mode — a child moving in or out of the `filter`
+recomputes the parent on its next write like any other child update.
---
@@ -717,10 +836,12 @@ case_number:
**Example values:** `CASE-0001`, `CASE-0002`, ...
**Format tokens:**
-- `{0000}`: Zero-padded number
-- `{YYYY}`: Year
-- `{MM}`: Month
-- `{DD}`: Day
+- `{0000}`: Zero-padded counter
+- `{YYYY}` / `{MM}` / `{DD}` / `{YYYYMMDD}`: date tokens (business timezone)
+- `{field_name}`: interpolates another field's value
+
+The counter resets **per rendered prefix** — `AD{YYYYMMDD}{0000}` therefore
+restarts at 1 each day.
**Complex formats:**
```yaml
@@ -730,9 +851,10 @@ invoice_number:
# Generates: INV-2024-0001, INV-2024-0002, ...
```
-**Database implementation:**
-- PostgreSQL: `SEQUENCE`
-- MongoDB: Atomic counter collection
+**Database implementation:** a `VARCHAR(255)` column, numbered from an atomic
+counter row in the driver's own `_objectstack_sequences` table (bootstrapped from
+the existing MAX on first use, and scoped per tenant when the object is
+tenant-scoped). The driver does **not** create a native PostgreSQL `SEQUENCE`.
---
@@ -748,7 +870,8 @@ metadata:
```
**Storage:**
-- PostgreSQL: `JSONB`
+- SQL driver: a `JSON` column (`json` on PostgreSQL/MySQL, `TEXT` on SQLite) —
+ the driver uses `json`, not `jsonb`
- MongoDB: Native object
**Query support:**
@@ -783,7 +906,7 @@ There is no generic `array` field type. To store multiple values, use `tags`
**Storage:**
-- PostgreSQL: `TEXT[]` or `JSONB`
+- SQL driver: a `JSON` column holding the serialized string array
- MongoDB: `[String]`
---
@@ -811,7 +934,7 @@ billing_address:
```
**Database mapping:**
-- PostgreSQL: `JSONB` or composite type
+- SQL driver: a `JSON` column (not a composite type)
- MongoDB: Embedded document
---
@@ -828,11 +951,15 @@ office_location:
**Storage:**
```json
{
- "latitude": 37.7749,
- "longitude": -122.4194
+ "lat": 37.7749,
+ "lng": -122.4194
}
```
+`altitude` and `accuracy` (both in metres) are optional additional members. Note
+the keys are `lat`/`lng` — the `{ latitude, longitude }` spelling was never
+consumed by the runtime and has been retired from the value contract.
+
Proximity / radius ("near") search is **not** a built-in filter operator.
ObjectQL's filter language exposes only `$eq`, `$ne`, `$gt`, `$gte`, `$lt`,
@@ -844,8 +971,10 @@ geospatial indexes), which the SQL and in-memory drivers do not provide.
**Database mapping:**
-- PostgreSQL: `POINT` or `GEOGRAPHY`
-- MongoDB: GeoJSON
+- SQL driver: a `JSON` column — the driver does not emit a native `POINT` /
+ `GEOGRAPHY` column, which is the other half of why proximity search is not
+ available
+- MongoDB: an embedded document
---
@@ -859,27 +988,37 @@ avatar:
multiple: false # set true to store an array of file references
```
-**Storage:**
+**Stored value:** an opaque `sys_file` id string. The expanded read form is the
+media metadata object, whose only required member is `url`:
+
```json
{
- "filename": "profile.jpg",
- "content_type": "image/jpeg",
+ "url": "https://cdn.example.com/files/abc123.jpg",
+ "name": "profile.jpg",
"size": 1024000,
- "url": "https://cdn.example.com/files/abc123.jpg"
+ "mimeType": "image/jpeg"
}
```
-**Storage backends:**
+`alt` and `duration` are the other optional members. The keys are `name` and
+`mimeType` — not `filename` / `content_type`.
+
+
+Deployments predating the file-as-reference migration may still hold the inline
+metadata object (or a bare URL) as the *stored* value. The engine warns rather
+than rejects until `os migrate files-to-references --apply` has run, so an
+existing database keeps working while it is backfilled.
+
+
+**Storage backends** (configured on the file-storage connector, not the field):
- `local`: Server filesystem
- `s3`: Amazon S3
- `azure_blob`: Azure Blob Storage
- `gcs`: Google Cloud Storage
-**Features:**
-- Automatic upload handling
-- Content-type validation
-- Virus scanning (optional)
-- CDN integration
+…plus `dropbox`, `box`, `onedrive`, `google_drive`, `sharepoint`, `ftp`, and
+`custom`. Upload-time processing — thumbnail generation, virus scanning — is
+configured there too, under `contentProcessing`.
---
@@ -904,21 +1043,37 @@ the uploaded file reference.
## Type Conversion Matrix
-How types convert between databases:
-
-| ObjectQL Type | PostgreSQL | MongoDB | Redis |
-|---------------|------------|---------|-------|
-| `text` | `VARCHAR` / `TEXT` | `String` | `String` |
-| `number` | `NUMERIC` | `Number` | `String` |
-| `currency` | `JSONB` | `Object` | `String` (JSON) |
-| `date` | `DATE` | `Date` | `String` (ISO) |
-| `datetime` | `TIMESTAMP` | `Date` | `String` (ISO) |
-| `boolean` | `BOOLEAN` | `Boolean` | `0` / `1` |
-| `select` | `VARCHAR` / `ENUM` | `String` | `String` |
-| `lookup` | `UUID` + FK | `ObjectId` | `String` |
-| `formula` | `GENERATED` | Virtual | Computed |
-| `json` | `JSONB` | `Object` | `String` (JSON) |
-| `location` | `POINT` | `GeoJSON` | `String` (JSON) |
+The column each type gets from the SQL driver, per dialect:
+
+| ObjectQL Type | PostgreSQL | MySQL | SQLite |
+|---------------|------------|-------|--------|
+| `text` | `TEXT` | `TEXT` | `TEXT` |
+| `email` / `url` / `phone` | `VARCHAR(255)` | `VARCHAR(255)` | `VARCHAR(255)` |
+| `number` / `currency` / `percent` | `REAL` | `FLOAT` | `REAL` |
+| `date` | `DATE` | `DATE` | `TEXT` (`YYYY-MM-DD`) |
+| `datetime` | `TIMESTAMPTZ` | `DATETIME(3)` | `TEXT` (canonical `…Z`) |
+| `time` | `TIME` | `TIME(3)` | `TEXT` (`HH:MM:SS[.fff]`) |
+| `boolean` / `toggle` | `BOOLEAN` | `BOOLEAN` | `INTEGER` `0`/`1` |
+| `select` / `radio` | `VARCHAR(255)` | `VARCHAR(255)` | `VARCHAR(255)` |
+| `multiselect` / `tags` | `JSON` | `JSON` | `TEXT` (JSON) |
+| `lookup` / `master_detail` / `tree` | `VARCHAR(255)` | `VARCHAR(255)` | `VARCHAR(255)` |
+| `summary` | `REAL` | `FLOAT` | `REAL` |
+| `autonumber` | `VARCHAR(255)` | `VARCHAR(255)` | `VARCHAR(255)` |
+| `formula` | *(no column — virtual)* | *(no column)* | *(no column)* |
+| `json` / `location` / `address` | `JSON` | `JSON` | `TEXT` (JSON) |
+
+Any field flagged `multiple: true` becomes a `JSON` column regardless of its
+type. Relationship columns are plain id strings with no database `FOREIGN KEY`
+constraint (see `lookup` above). The MongoDB driver is schemaless — it issues no
+DDL and stores the value it is given.
+
+
+There is no Redis persistence backend. ObjectQL's data drivers are SQL
+(PostgreSQL / MySQL / SQLite, plus a SQLite-WASM build of the same driver for the
+browser), MongoDB, and in-memory. Redis appears in the platform only as an
+optional *cluster-primitives* driver (pub/sub, locks, KV, counters), never as a
+place records are stored.
+
## Sensitive Data: Masking & Encryption