Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .changeset/query-cursor-removed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
---
"@objectstack/spec": major
"@objectstack/client": major
---

refactor(data)!: `query.cursor` is removed — no driver ever implemented keyset pagination (#4286 step 4)

`cursor` promised keyset pagination and nothing served it: the key was accepted
and ignored, so every page came back identical — a caller looping "until
`hasMore` is false" never terminated. It was Tier A of the #4286 inventory: a
shipped public producer (`QueryBuilder.cursor()`) minting a key no executor
read.

**FROM → TO**

| Was | Now |
| :--- | :--- |
| `cursor: { created_at: last.created_at }` | `where: { created_at: { $gt: last.created_at } }` + the matching `orderBy` |
| `QueryBuilder.cursor({...})` | `.where({ created_at: { $gt: ... } }).orderBy('created_at')` |

The one-line fix: **delete the key and seek with `where` on your sort key** —
every driver already executes that, with canonicalised temporal comparands.

Mechanics: `retiredKey()` tombstones on both declaration sites
(`QuerySchema.cursor` and `EngineQueryOptionsSchema.cursor`, one shared
prescription), so authoring the key fails `tsc` and a query still carrying it
fails to parse with the fix. `QueryBuilder.cursor()` is deleted. Registered as
the protocol-18 semantic migration `query-cursor-retired` (request surface —
nothing stored to rewrite). The caller-built `Record<string, unknown>` shape
would not survive a real keyset design anyway: a first-class cursor, if ever
built, will be a response-minted opaque token (the pattern the
metadata-revision / flow-run / notification list endpoints already use — those
`cursor` params are unrelated and unchanged).
37 changes: 37 additions & 0 deletions .changeset/query-distinct-removed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
---
"@objectstack/spec": major
"@objectstack/client": major
"@objectstack/metadata-protocol": minor
---

refactor(data)!: `query.distinct` is removed, and with it the mis-wired REST count suppression (#4286 step 4)

`distinct` promised `SELECT DISTINCT` and no driver ever rendered it — but it
was **mis-wired rather than merely dead** (#4286 finding 2, the harsher
ADR-0078 class): its only observable effect platform-wide was that the REST
list path treated a distinct query as *not countable*, silently degrading
`total`/`hasMore` to a page-local estimate while still returning duplicate
rows. A caller — or a self-verifying agent — saw the response change and
concluded the flag worked. It had a shipped public producer
(`QueryBuilder.distinct()`).

**FROM → TO**

| Was | Now |
| :--- | :--- |
| `distinct: true` for unique combinations | `groupBy: ['category']` |
| `distinct: true` + count | `aggregations: [{ function: 'count_distinct', field: 'category', alias: '...' }]` |
| one column's distinct values | the SQL/memory drivers' `distinct(object, field)` door (driver-level) |

The one-line fix: **delete the key**; deduplicate with `groupBy` /
`count_distinct`.

Mechanics: `retiredKey()` tombstones on both declaration sites
(`QuerySchema.distinct` and `EngineQueryOptionsSchema.distinct`, one shared
prescription); `QueryBuilder.distinct()` is deleted; registered as the
protocol-18 semantic migration `query-distinct-retired`. **Observable REST
change (`@objectstack/metadata-protocol`):** the count-suppression branch is
deleted — a list request that used to carry `distinct` now gets a real
`total`/`hasMore` again (that restoration is the point, not a side effect).
The per-aggregation `distinct` flag (`AggregationNode.distinct`) is a
different, live member and is untouched.
39 changes: 39 additions & 0 deletions .changeset/query-having-enforced.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
---
"@objectstack/spec": minor
"@objectstack/objectql": minor
"@objectstack/metadata-protocol": minor
---

feat(objectql)!: `query.having` is enforced — the engine applies it after aggregation (#4286 step 3, ADR-0049 resolved to enforce)

`having` had been declared on the request surface since AST v2 and executed by
nothing. #4286 finding 1 showed the gap was structural: `engine.aggregate()`
rebuilt the driver AST with exactly `object`/`where`/`groupBy`/`aggregations`,
so even a driver that *did* implement HAVING could never have received it, and
the one wire path (`findData`'s aggregate branch) dropped the clause too. It
was the strongest enforce candidate of the #4286 set — the clause every
SQL-literate author (human or model) expects to work next to
`groupBy`/`aggregations` — and it is now live end to end:

- **Engine-owned, both paths.** `applyHaving()`
(`packages/objectql/src/having-filter.ts`) runs AFTER aggregation on the
native-driver path and the in-memory fallback alike — the same
correct-first / optimize-later two-tier shape date bucketing uses. Native
SQL `HAVING` pushdown can come later behind a driver capability flag without
changing semantics.
- **Namespace: the aggregated row's own columns** — aggregation aliases
(`order_count`, `total`) and groupBy projections — with the ordinary
FilterCondition operators plus `$and`/`$or`/`$not`.
- **An unknown operator rejects loudly.** Ignoring one (as tolerant matchers
do) would silently return unfiltered aggregates — the exact ADR-0078
silently-inert failure enforcement exists to end.
- **The wire path forwards it.** `findData`'s aggregate branch passes
`having` through, and `EngineAggregateOptionsSchema` now declares it.
- The FLS predicate guard already walked `having` references
(`predicate-guard.ts`), which is what made enforcement safe to turn on.

No migration needed: queries that carried `having` before were silently
returning every group; they now filter as written. A caller who depended on
the clause being *ignored* (sending `having` and expecting unfiltered
results) sees the corrected behavior — that is the enforcement, not a
regression.
70 changes: 37 additions & 33 deletions content/docs/data-modeling/queries.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -237,23 +237,24 @@ sort. Internal callers reaching `engine.find()` directly are unaffected.
}
```

### Cursor-Based Pagination (Keyset) — not implemented
### Keyset Pagination — a `where` predicate on the sort key

`query.cursor` was **removed in `@objectstack/spec` 18** (#4286): nothing on the server
ever read it, so a cursor query silently returned the same first page every time. The
key is tombstoned and `QueryBuilder.cursor()` was removed with it. Express the keyset
directly — seek past the last row instead of offsetting:

```typescript
// Next page after `last` — every driver executes this, with canonicalised comparands
{
limit: 20,
cursor: { id: 100 } // Opaque keyset cursor (e.g. last seen sort-key values)
where: { created_at: { $gt: last.created_at } },
orderBy: [{ field: 'created_at', order: 'asc' }],
limit: 20
}
```

<Callout type="warn">
`cursor` is declared on `QuerySchema` as an opaque record (`Record<string, unknown>`)
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.)
</Callout>
(A first-class cursor, if ever designed, will be a response-minted opaque token — the
pattern the metadata-revision / flow-run / notification list endpoints already use.)

---

Expand Down Expand Up @@ -376,13 +377,14 @@ silently returns `null` for them. Avoid these three on SQL- or memory-backed obj
}
```

<Callout type="warn">
`having` is accepted by `QuerySchema` but is not enforced by the query engine today.
`EngineAggregateOptions` (the type `ObjectQL.aggregate()` actually takes) has no `having`
field, and both the REST `findData()` dispatcher and `ObjectQL.aggregate()` build their
driver-facing query from only `where` / `groupBy` / `aggregations` — a `having` clause is
silently dropped before it reaches any driver. No driver (SQL, in-memory, MongoDB) filters
on it either. Post-filter grouped results on the client until this is wired up.
<Callout type="info">
`having` is **enforced since #4286** (ADR-0049 enforce-or-remove, resolved to enforce): the
engine applies it itself AFTER aggregation, identically on the native-driver path and the
in-memory fallback, and the REST `findData()` aggregate branch forwards it. Its namespace is
the **aggregated row's own columns** — aggregation aliases (`order_count`) and groupBy
projections — with the ordinary FilterCondition operators and `$and`/`$or`/`$not`. An
unknown operator is rejected loudly rather than ignored. Native SQL `HAVING` pushdown can
come later behind a driver capability flag without changing these semantics.
</Callout>

---
Expand Down Expand Up @@ -505,22 +507,24 @@ report/dashboard metadata.

## Distinct & Group By

### Distinct Records
### Distinct Records — removed flag, three live spellings

The top-level `query.distinct` flag was **removed in `@objectstack/spec` 18** (#4286):
no driver's `find()` ever applied it, and its only observable effect was mis-wired —
it silently suppressed the REST list count while still returning duplicate rows (the
count is truthful again). The key is tombstoned and `QueryBuilder.distinct()` was
removed with it. What actually deduplicates:

```typescript
{
object: 'task',
fields: ['category'],
distinct: true
}
// Unique combinations → groupBy
{ object: 'task', groupBy: ['category'] }

// Deduplicated count → count_distinct
{ object: 'task', aggregations: [{ function: 'count_distinct', field: 'category', alias: 'categories' }] }
```

<Callout type="warn">
The top-level `distinct: true` flag is defined in `QuerySchema` but isn't applied by any
driver's `find()` (SQL, in-memory, and MongoDB all ignore it). A separate
`driver.distinct(object, field)` method exists on the SQL and in-memory drivers, but it isn't
called by `ObjectQL.find()`/`.aggregate()`, so it isn't reachable through a normal query.
</Callout>
A separate `driver.distinct(object, field)` method also exists on the SQL and
in-memory drivers (driver-level; not called by `ObjectQL.find()`/`.aggregate()`).

### Group By with Having

Expand All @@ -536,9 +540,9 @@ called by `ObjectQL.find()`/`.aggregate()`, so it isn't reachable through a norm
}
```

<Callout type="warn">
As noted under [Aggregations](#aggregations) above, `having` is not currently enforced —
it's dropped before reaching the aggregation engine or any driver.
<Callout type="info">
As noted under [Aggregations](#aggregations) above, `having` filters the aggregated rows
engine-side — `total_spent` here is the aggregation alias it references.
</Callout>

### Date Bucketing in `groupBy`
Expand Down
15 changes: 10 additions & 5 deletions content/docs/deployment/troubleshooting.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -278,17 +278,22 @@ console.log(field.maxLength?.toString() ?? 'no limit');
2. **Limit fields** — Only request fields you need (`fields: ['id', 'name']`)
3. **Use pagination** — Always set `limit` to avoid returning all records
4. **Avoid deep nesting** — Limit nested `$and`/`$or` depth
5. **Use cursor pagination** — For large datasets, cursor-based is faster than offset
5. **Use keyset pagination** — For large datasets, seeking past the last row is
faster than a deep `offset`. Express the keyset as a `where` predicate on the
sort key (the `cursor` query property was removed in `@objectstack/spec` 18,
#4286 — nothing ever read it)

```typescript
// Optimized query
// Optimized query — next page seeks past the last row instead of offsetting
{
object: 'activity',
fields: ['id', 'type', 'created_at'], // Only needed fields
where: { type: { $eq: 'login' } }, // On indexed field
fields: ['id', 'type', 'created_at'], // Only needed fields
where: {
type: { $eq: 'login' }, // On indexed field
created_at: { $lt: lastSeenCreatedAt }, // Keyset: past the last row
},
orderBy: [{ field: 'created_at', order: 'desc' }],
limit: 25,
cursor: { id: lastSeenId } // Keyset/cursor pagination (field → last-seen value)
}
```

Expand Down
68 changes: 41 additions & 27 deletions content/docs/protocol/objectql/query-syntax.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -69,11 +69,9 @@ interface QueryAST {
limit?: number; // Max records (LIMIT)
offset?: number; // Skip records (OFFSET)
top?: number; // Alias for limit (OData compat)
cursor?: Record<string, unknown>; // Keyset pagination cursor
aggregations?: AggregationNode[]; // Aggregation functions
groupBy?: GroupByNode[]; // GROUP BY targets (string | object)
having?: FilterCondition; // HAVING clause
distinct?: boolean; // SELECT DISTINCT
having?: FilterCondition; // HAVING — engine-enforced after aggregation
expand?: Record<string, QueryAST>; // Recursive relation loading
}
```
Expand All @@ -90,22 +88,21 @@ on the `find()` path:

| Member | Status |
|:-------|:-------|
| `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 |
| `aggregations[].filter` | `[EXPERIMENTAL — not enforced]` — a SQL `FILTER (WHERE …)` affordance neither the SQL builders nor the in-memory fallback applies |
| `search.fuzzy` / `boost` / `operator` / `minScore` / `language` / `highlight` | `[EXPERIMENTAL — not enforced]` — only `query` and `fields` drive the expansion |

`top` is the exception that *is* honored — the engine normalises it to `limit`.

Two members left this table by being **removed** (#4286, ADR-0049 enforce-or-remove):
`joins` and `windowFunctions` are tombstoned in `@objectstack/spec` 18 — a query
carrying either fails to parse with the upgrade prescription, and authoring one is a
`tsc` error. Related records are read through `expand`; window functions remain a
SQL-driver door (`SqlDriver.findWithWindowFunctions()`). The inert members that
*remain* declared (`having`, `cursor`, `distinct`, and the experimental flags above)
are tracked in the liveness ledger (`packages/spec/liveness/query.json`) pending
their #4286 dispositions.
The #4286 sweep (ADR-0049 enforce-or-remove) settled every other declared-but-inert
member. **Removed** — tombstoned in `@objectstack/spec` 18, so a query carrying one
fails to parse with the upgrade prescription and authoring it is a `tsc` error:
`joins` (related records are read through `expand`), `windowFunctions` (a SQL-driver
door remains: `SqlDriver.findWithWindowFunctions()`), `cursor` (express the keyset as
a `where` predicate on the sort key — §7), and `distinct` (unique values via
`groupBy` / `count_distinct` / the drivers' `distinct()` door; its only observable
effect was suppressing the REST list count, which is truthful again). **Enforced**:
`having` (§5). The experimental flags above are tracked in the liveness ledger
(`packages/spec/liveness/query.json`).
</Callout>

### Key Types
Expand Down Expand Up @@ -659,23 +656,30 @@ const query: QueryAST = {
};
```

### HAVING Clause (protocol only)
### HAVING Clause

`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:
**Enforced since #4286** (ADR-0049, resolved to enforce). The engine applies `having`
itself, AFTER aggregation, identically on the native-driver path and the in-memory
fallback (`packages/objectql/src/having-filter.ts`) — the same correct-first /
optimize-later two-tier shape date bucketing uses; native SQL `HAVING` pushdown can come
later behind a driver capability flag without changing these semantics. The REST
`findData()` aggregate branch forwards the clause.

`having` references the **aggregated row's own columns** — aggregation aliases and
groupBy projections — with the ordinary FilterCondition operators and
`$and` / `$or` / `$not`. An unknown operator rejects the query loudly rather than being
ignored (an ignored operator would silently return unfiltered aggregates — the ADR-0078
failure mode enforcement exists to end).

```typescript
// Only accounts with > $1M pipeline
const rows = await engine.aggregate('opportunity', {
groupBy: ['account_id'],
aggregations: [
{ function: 'sum', field: 'amount', alias: 'total' },
],
having: { total: { $gt: 1_000_000 } },
});

// Only accounts with > $1M pipeline
const bigAccounts = rows.filter((r) => r.total > 1_000_000);
```

### Date Bucketing
Expand Down Expand Up @@ -705,8 +709,14 @@ the driver's raw rows.

### Distinct

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;
`query.distinct` was **removed in `@objectstack/spec` 18** (#4286): no driver ever
rendered `SELECT DISTINCT`, and the flag's only observable effect was mis-wired — it
silently suppressed the REST list count (`total`/`hasMore` degraded to a page-local
estimate) while still returning duplicate rows. The key is tombstoned and
`QueryBuilder.distinct()` was removed with it; the count suppression is gone, so
`total` is truthful again. Unique *combinations* come from `groupBy`, deduplicated
counts from the `count_distinct` aggregation, and one column's distinct values from
the driver's own `distinct()` method (implemented by the SQL and in-memory drivers;
it is not part of the `IDataDriver` contract):

```typescript
Expand Down Expand Up @@ -823,9 +833,13 @@ const page2 = await engine.find('customer', {
### Keyset Pagination

<Callout type="warn">
`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:
`query.cursor` was **removed in `@objectstack/spec` 18** (#4286): no driver ever
implemented keyset pagination, so a cursor was accepted and ignored and every page came
back identical — a caller looping "until `hasMore` is false" never terminated. The key
is tombstoned (on `EngineQueryOptions` too) and `QueryBuilder.cursor()` was removed
with it. Express the keyset as an ordinary `where` predicate on the sort key — the
pattern below is the supported one; a first-class cursor, if ever designed, will be a
response-minted opaque token:
</Callout>

```typescript
Expand Down
Loading
Loading