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
36 changes: 36 additions & 0 deletions .changeset/25808-now-anchored-comparand.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
---
'@objectstack/spec': minor
'@objectstack/core': patch
'@objectstack/objectql': patch
'@objectstack/driver-sql': patch
'@objectstack/driver-mongodb': patch
'@objectstack/formula': patch
'@objectstack/service-analytics': patch
---

feat(spec,core,objectql,driver-sql,formula,driver-mongodb,service-analytics): a filter compares a datetime column against now shifted by whole days held on the record (`{ $now: true, addDays }`)

Clause-②: yes (widening)
Metadata-protocol: added
Ruling-ref: 6415710193

An analytics dataset could not say "opened longer ago than this ticket's own SLA": the comparand grammar had a `{ $field, addDays }` pair for two columns of the record and `{N_days_ago}` placeholders for a fixed number of days, but nothing that anchored at the current instant and read the number of days off the row. Authors fell back to a literal day count, which drifts from every record whose SLA differs.

`FilterCondition` gains a now-anchored comparand, legal exactly where a `{ $field }` reference is — as the whole comparand of `$eq` / `$ne` / `$gt` / `$gte` / `$lt` / `$lte`, on a `date` or `datetime` column:

```ts
// opened more than review_sla_days_offset days ago — the column holds NEGATIVE days
{ opened_at: { $lt: { $now: true, addDays: { $field: 'review_sla_days_offset' } } } }
// on or after thirty days ago
{ closed_on: { $gte: { $now: true, addDays: -30 } } }
```

- **`@objectstack/spec`.** `NowAnchorSchema` (`data/filter.zod.ts`): `$now` is the literal `true`, `addDays` is REQUIRED — an integer of any sign or a `{ $field }` reference to a numeric column of the same record, the `addDays` grammar `FieldReferenceSchema` already carries; there is no `subDays`, and the unshifted instant stays the `{now}` placeholder. The `FilterCondition` door refuses a malformed anchor in the anchor's own words, and an anchor as an `$in` / `$nin` member or a `$between` endpoint by name, as it refuses a reference there. `filter.mdx` and the authorable surface document the shape.
- **`@objectstack/core`.** `resolveFilterTokens` pins `$now` to the tree's one instant (the `now` every `{N_days_ago}` in the tree resolves from), as an ISO string, so every executor sees `{ $now: '<ISO>', addDays }`; an anchor without `addDays` is `INVALID_FILTER` / 400 there.
- **`@objectstack/driver-sql`.** SQL push-down compiles the anchor on SQLite, PostgreSQL and MySQL through the `addDays` arithmetic (`crossFieldOffsetExpr`), with the instant bound in the target column's storage form: a `datetime` column keeps the time of day, a `date` column meets the instant's UTC calendar day. A NULL offset column is zero days (`coalesce`). The predicate is total: an ordering and `$eq` need the target to have a value, `$ne` holds when it has none or differs, so `$not` takes exactly the complement. The offset column is held to the same-table rules the `addDays` pair's is (declared, numeric, same table, never the tenant-isolation column); a non-temporal target, a malformed anchor and every other position are refused with the operands withheld from the message and the diagnostic in the server log.
- **`@objectstack/formula`.** `matchesFilterCondition` resolves the anchor in the form of the stored value it meets (a calendar day stays a calendar day), so the memory path answers the SQL path's rows; `$now: true` reads the evaluator's clock for a caller with no resolver seam.
- **`@objectstack/objectql`.** `aggregations[i].filter` and `having` judge the anchor by `where`'s rules (temporal target, declared numeric offset) before any row is read, and evaluate it per row through the formula evaluator.
- **`@objectstack/service-analytics`.** `NativeSQLStrategy` declines a query carrying an anchor, as it declines a `{ $field }` comparand, so a dataset filter or measure filter routes to the engine path where the driver compiles it; a dataset's authored `$now: true` is pinned by the service's own token seam.
- **`@objectstack/driver-mongodb`.** Refuses the anchor in every position (`INVALID_FILTER` / 400) rather than translating it as a literal sub-document.

Pinned on the drivers' cross-field conformance corpus (`CROSS_FIELD_NOW_CASES` / `CROSS_FIELD_NOW_REFUSALS`, exported from `@objectstack/driver-sql`) on both execution paths, and on the analytics dataset shape the ruling was made for.
61 changes: 61 additions & 0 deletions content/docs/protocol/objectql/query-syntax.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -526,6 +526,67 @@ is in the dataset's `include`; measured and pinned in
(#15103). The spelling that answers on both paths is the same-table one — every column the
reference names, offset included, is a column of the object the filter runs over.

### Comparing Against Now, Shifted by Days Held on the Record

A comparand can be **anchored at the current instant** — `{ $now: true, addDays: <offset> }` —
as the *whole* comparand of one of the six scalar comparison operators, on a `date` or
`datetime` column. `addDays` is required and takes the same two spellings the field
reference's does: an integer literal of any sign, or a nested `{ $field }` reference to a
numeric column of the **same record**. There is no `subDays`: a column that means "days
before now" holds negative values. For the unshifted instant, write the `{now}` placeholder.

{/* os:check */}
```typescript
import type { FilterCondition } from '@objectstack/spec/data';

// opened more than review_sla_days_offset days ago — the column holds NEGATIVE days
const pastSla: FilterCondition = {
opened_at: { $lt: { $now: true, addDays: { $field: 'review_sla_days_offset' } } },
};

// closed on or after thirty days ago
const recentlyClosed: FilterCondition = {
closed_on: { $gte: { $now: true, addDays: -30 } },
};

// due within the next seven days (and not yet past)
const dueSoon: FilterCondition = {
$and: [
{ due_on: { $gte: { $now: true, addDays: 0 } } },
{ due_on: { $lte: { $now: true, addDays: 7 } } },
],
};
```

The engine's token resolver pins `$now` to the one instant every `{N_days_ago}` placeholder
in the same query resolves from, so a driver sees `{ $now: '<ISO instant>', addDays }`; a
caller that reaches a driver or the memory evaluator directly, with no resolver in front of
it, gets that executor's own clock. The target column's class decides the form the instant
is compared in, exactly as a `{now}` literal is: a `datetime` column keeps the time of day;
a `date` column meets the instant's **UTC calendar day** (the time of day is dropped, never
rounded). The anchor itself is never NULL, so the only NULL on the table is the target's:

| Case | Reads as |
|:-----|:---------|
| The offset column is NULL | zero days — `now + NULL` is `now` |
| The target column is NULL | fails the orderings and `$eq`, satisfies `$ne` — its ordinary reading against a literal, so `$not` takes exactly the complement |
| The target column is not `date` / `datetime` | refused with `INVALID_FILTER` — an instant has no meaning on a number, a wall-clock `time` or text |

Both execution paths answer it: SQL push-down (`driver-sql`, `driver-sqlite-wasm`) binds the
instant in the column's storage form and shifts it with the same per-dialect day arithmetic
the `addDays` pair uses (SQLite, PostgreSQL, MySQL), and the in-memory evaluator shifts it in
the form of the stored value it meets, so the two return the same rows. The offset column is
held to every rule the `addDays` pair's offset is held to — a declared, numeric column of the
same table, never the tenant-isolation column, no dotted path — and the analytics native-SQL
strategy declines a query carrying an anchor so that the driver compiles it, as it does for a
`{ $field }` comparand. The MongoDB driver refuses the anchor rather than translating it.

**The SLA held on a related record.** The shape that compiles is the same-table one: the SLA
(or its negative) propagated onto the row, or the dataset's base object carrying the column.
A dotted offset (`addDays: { $field: 'queue.sla_days' }`) validates, is resolved by the
memory evaluator, and is refused by SQL push-down with `INVALID_FILTER`, exactly as a dotted
`$field` is — a dataset's `include` does not widen it (see above).

### Date, Datetime, and Time Filters

Before a comparison is built, the driver puts the comparand into the **same canonical
Expand Down
Loading
Loading