From 9594f3737bf2cf0f4e2dd815372fe13d85e724bd Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 17:32:21 +0000 Subject: [PATCH] feat(spec,core,objectql,driver-sql,formula,driver-mongodb,service-analytics): now-anchored filter comparand shifted by days held on the record MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A filter comparand `{ $now: true, addDays: }`, the maintainer's letter A on #25808 (ruling comment 6415710193): legal exactly where a `{ $field }` reference is, as the whole comparand of $eq / $ne / $gt / $gte / $lt / $lte on a date or datetime column. `addDays` is required and shares `FieldReferenceSchema`'s grammar (there is no subDays; "now minus the SLA" is a column holding negative days). The unshifted instant stays the `{now}` placeholder. - spec: `NowAnchorSchema` in the ordering and equality comparand unions; refused by name as a set member or `$between` endpoint; the comparand type door and the lowering face step around a well-formed anchor and refuse a malformed one in its own words; authorable surface, json-schema manifest, api surface and the filter reference page regenerated. - core: `resolveFilterTokens` pins `$now` to the tree's one instant as an ISO string; a missing or malformed addDays is INVALID_FILTER / 400. - objectql: the temporal door skips the anchor; `aggregations[i].filter` and `having` judge it by `where`'s rules and compare per row through the formula evaluator. - driver-sql: same-table push-down on SQLite, PostgreSQL and MySQL through `crossFieldOffsetExpr` (NULL offset = 0 days), the instant bound in the target column's storage form; NULL-total predicates so `$not` is the exact complement; every other position refused with operands withheld. - formula: `matchesFilterCondition` resolves the anchor in the stored value's form, so the memory path answers the SQL path's rows. - driver-mongodb: refuses the anchor in every position (INVALID_FILTER). - service-analytics: NativeSQLStrategy declines the anchor as it declines a `{ $field }` comparand, so dataset filters route to the engine path. Pinned on the drivers' cross-field conformance corpus (CROSS_FIELD_NOW_CASES / CROSS_FIELD_NOW_REFUSALS) on both execution paths and on the analytics dataset shape; changeset minor on @objectstack/spec. Clause-②: yes (widening) Metadata-protocol: added Ruling-ref: 6415710193 Claude-Session: https://claude.ai/code/session_019YLF9PyrRqUX4N7C2pzSoH Co-authored-by: Claude --- .changeset/25808-now-anchored-comparand.md | 36 ++ .../docs/protocol/objectql/query-syntax.mdx | 61 +++ content/docs/references/data/filter.mdx | 202 +++++++--- content/docs/references/index.mdx | 10 +- .../data.md | 12 +- packages/core/src/utils/filter-tokens.test.ts | 47 +++ packages/core/src/utils/filter-tokens.ts | 74 ++++ .../mongodb-field-reference-refusal.test.ts | 32 ++ .../driver-mongodb/src/mongodb-filter.ts | 49 +++ .../src/cross-field-conformance-cases.ts | 259 +++++++++++++ packages/drivers/driver-sql/src/index.ts | 4 + .../sql-driver-compile-refusal-seam.test.ts | 14 + ...sql-driver-cross-field-conformance.test.ts | 87 +++++ packages/drivers/driver-sql/src/sql-driver.ts | 279 ++++++++++++++ ...qlite-wasm-cross-field-conformance.test.ts | 18 +- .../src/matches-filter-now-anchor.test.ts | 170 +++++++++ packages/formula/src/matches-filter.ts | 64 +++- .../lint/src/validate-filter-tokens.test.ts | 15 + .../objectql/src/engine-now-anchor.test.ts | 259 +++++++++++++ packages/objectql/src/having-filter.ts | 209 +++++++++- .../objectql/src/temporal-comparand-door.ts | 18 +- .../src/__tests__/now-anchor-dataset.test.ts | 360 ++++++++++++++++++ .../service-analytics/src/comparand-shape.ts | 22 ++ .../src/strategies/native-sql-strategy.ts | 6 +- .../src/strategies/objectql-strategy.ts | 7 +- packages/spec/api-surface/data.json | 2 + packages/spec/authorable-surface/data.json | 2 + packages/spec/declaration-map/data.json | 2 + packages/spec/export-origins/data.json | 2 + packages/spec/json-schema.manifest/data.json | 1 + .../spec/src/data/filter-comparand-type.ts | 49 ++- packages/spec/src/data/filter-lowering.ts | 15 +- packages/spec/src/data/filter.test.ts | 87 +++++ packages/spec/src/data/filter.zod.ts | 195 +++++++++- .../src/type-alias-convention.pin.test.ts | 13 +- 35 files changed, 2587 insertions(+), 95 deletions(-) create mode 100644 .changeset/25808-now-anchored-comparand.md create mode 100644 packages/formula/src/matches-filter-now-anchor.test.ts create mode 100644 packages/objectql/src/engine-now-anchor.test.ts create mode 100644 packages/services/service-analytics/src/__tests__/now-anchor-dataset.test.ts diff --git a/.changeset/25808-now-anchored-comparand.md b/.changeset/25808-now-anchored-comparand.md new file mode 100644 index 00000000000..7841dcad06a --- /dev/null +++ b/.changeset/25808-now-anchored-comparand.md @@ -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: '', 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. diff --git a/content/docs/protocol/objectql/query-syntax.mdx b/content/docs/protocol/objectql/query-syntax.mdx index ca79cdfdeb8..3f85feed77c 100644 --- a/content/docs/protocol/objectql/query-syntax.mdx +++ b/content/docs/protocol/objectql/query-syntax.mdx @@ -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: }` — +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: '', 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 diff --git a/content/docs/references/data/filter.mdx b/content/docs/references/data/filter.mdx index 068f9d790a5..6b2eb2e3606 100644 --- a/content/docs/references/data/filter.mdx +++ b/content/docs/references/data/filter.mdx @@ -1,7 +1,7 @@ --- title: Filter schema — Data Protocol reference navTitle: Filter -description: "Unified Query DSL Specification. Reference for ComparisonOperator, EqualityOperator, FieldOperators and 9 more: every property with its type and default." +description: "Unified Query DSL Specification. Reference for ComparisonOperator, EqualityOperator, FieldOperators and 10 more: every property with its type and default." --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -33,8 +33,8 @@ Design Principles: ## TypeScript Usage ```typescript -import { ComparisonOperatorSchema, EqualityOperatorSchema, FieldOperatorsSchema, FieldReferenceSchema, FilterArraySchema, FilterConditionSchema, NormalizedFilterSchema, QueryFilterSchema, RangeOperatorSchema, SetOperatorSchema, SpecialOperatorSchema, StringOperatorSchema } from '@objectstack/spec/data'; -import type { FieldOperators, FieldReference, FilterArray, FilterCondition, NormalizedFilter, QueryFilter } from '@objectstack/spec/data'; +import { ComparisonOperatorSchema, EqualityOperatorSchema, FieldOperatorsSchema, FieldReferenceSchema, FilterArraySchema, FilterConditionSchema, NormalizedFilterSchema, NowAnchorSchema, QueryFilterSchema, RangeOperatorSchema, SetOperatorSchema, SpecialOperatorSchema, StringOperatorSchema } from '@objectstack/spec/data'; +import type { FieldOperators, FieldReference, FilterArray, FilterCondition, NormalizedFilter, NowAnchor, QueryFilter } from '@objectstack/spec/data'; // Validate data const result = ComparisonOperatorSchema.parse(data); @@ -48,39 +48,75 @@ const result = ComparisonOperatorSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **$gt** | `number \| string \| { $field: string; addDays?: integer \| object }` | optional | Greater than. Comparand is a number, a Date, a string, or a `{ $field }` reference (optionally carrying a whole-day addDays offset). STRING is the form the platform itself produces: the date-macro resolver returns only strings ("`{current_year_start}`" -> "2026-01-01"), and the guaranteed spellings are an ISO calendar day (YYYY-MM-DD), a UTC ISO-8601 instant, or a wall-clock time of day (HH:MM[:SS[.fff]]) for a Field.time column. Those are ASCII and fixed-width, so lexicographic order IS chronological order and every backend agrees; the driver reconciles the comparand with the column (a bare calendar day used as an upper bound becomes the half-open next-day boundary). Ordering NON-temporal text is permitted but NOT promised: the order is the backend collation's (byte-wise on SQLite, the database locale on Postgres, UTF-16 code units in the JS matchers), and those coincide only for ASCII. null is NOT a comparand: null is not ordered — state absence with the null predicate instead ($eq: null is "has no value", $ne: null is "has a value"). | -| **$gte** | `number \| string \| { $field: string; addDays?: integer \| object }` | optional | Greater than or equal to. Comparand is a number, a Date, a string, or a `{ $field }` reference (optionally carrying a whole-day addDays offset). STRING is the form the platform itself produces: the date-macro resolver returns only strings ("`{current_year_start}`" -> "2026-01-01"), and the guaranteed spellings are an ISO calendar day (YYYY-MM-DD), a UTC ISO-8601 instant, or a wall-clock time of day (HH:MM[:SS[.fff]]) for a Field.time column. Those are ASCII and fixed-width, so lexicographic order IS chronological order and every backend agrees; the driver reconciles the comparand with the column (a bare calendar day used as an upper bound becomes the half-open next-day boundary). Ordering NON-temporal text is permitted but NOT promised: the order is the backend collation's (byte-wise on SQLite, the database locale on Postgres, UTF-16 code units in the JS matchers), and those coincide only for ASCII. null is NOT a comparand: null is not ordered — state absence with the null predicate instead ($eq: null is "has no value", $ne: null is "has a value"). | -| **$lt** | `number \| string \| { $field: string; addDays?: integer \| object }` | optional | Less than. Comparand is a number, a Date, a string, or a `{ $field }` reference (optionally carrying a whole-day addDays offset). STRING is the form the platform itself produces: the date-macro resolver returns only strings ("`{current_year_start}`" -> "2026-01-01"), and the guaranteed spellings are an ISO calendar day (YYYY-MM-DD), a UTC ISO-8601 instant, or a wall-clock time of day (HH:MM[:SS[.fff]]) for a Field.time column. Those are ASCII and fixed-width, so lexicographic order IS chronological order and every backend agrees; the driver reconciles the comparand with the column (a bare calendar day used as an upper bound becomes the half-open next-day boundary). Ordering NON-temporal text is permitted but NOT promised: the order is the backend collation's (byte-wise on SQLite, the database locale on Postgres, UTF-16 code units in the JS matchers), and those coincide only for ASCII. null is NOT a comparand: null is not ordered — state absence with the null predicate instead ($eq: null is "has no value", $ne: null is "has a value"). | -| **$lte** | `number \| string \| { $field: string; addDays?: integer \| object }` | optional | Less than or equal to. Comparand is a number, a Date, a string, or a `{ $field }` reference (optionally carrying a whole-day addDays offset). STRING is the form the platform itself produces: the date-macro resolver returns only strings ("`{current_year_start}`" -> "2026-01-01"), and the guaranteed spellings are an ISO calendar day (YYYY-MM-DD), a UTC ISO-8601 instant, or a wall-clock time of day (HH:MM[:SS[.fff]]) for a Field.time column. Those are ASCII and fixed-width, so lexicographic order IS chronological order and every backend agrees; the driver reconciles the comparand with the column (a bare calendar day used as an upper bound becomes the half-open next-day boundary). Ordering NON-temporal text is permitted but NOT promised: the order is the backend collation's (byte-wise on SQLite, the database locale on Postgres, UTF-16 code units in the JS matchers), and those coincide only for ASCII. null is NOT a comparand: null is not ordered — state absence with the null predicate instead ($eq: null is "has no value", $ne: null is "has a value"). | +| **$gt** | `number \| string \| { $field: string; addDays?: integer \| object } \| { $now: true; addDays: integer \| object }` | optional | Greater than. Comparand is a number, a Date, a string, a `{ $field }` reference (optionally carrying a whole-day addDays offset), or a `{ $now: true, addDays }` anchor (the current instant shifted by whole days, the offset a literal or a numeric column of the same record). STRING is the form the platform itself produces: the date-macro resolver returns only strings ("`{current_year_start}`" -> "2026-01-01"), and the guaranteed spellings are an ISO calendar day (YYYY-MM-DD), a UTC ISO-8601 instant, or a wall-clock time of day (HH:MM[:SS[.fff]]) for a Field.time column. Those are ASCII and fixed-width, so lexicographic order IS chronological order and every backend agrees; the driver reconciles the comparand with the column (a bare calendar day used as an upper bound becomes the half-open next-day boundary). Ordering NON-temporal text is permitted but NOT promised: the order is the backend collation's (byte-wise on SQLite, the database locale on Postgres, UTF-16 code units in the JS matchers), and those coincide only for ASCII. null is NOT a comparand: null is not ordered — state absence with the null predicate instead ($eq: null is "has no value", $ne: null is "has a value"). | +| **$gte** | `number \| string \| { $field: string; addDays?: integer \| object } \| { $now: true; addDays: integer \| object }` | optional | Greater than or equal to. Comparand is a number, a Date, a string, a `{ $field }` reference (optionally carrying a whole-day addDays offset), or a `{ $now: true, addDays }` anchor (the current instant shifted by whole days, the offset a literal or a numeric column of the same record). STRING is the form the platform itself produces: the date-macro resolver returns only strings ("`{current_year_start}`" -> "2026-01-01"), and the guaranteed spellings are an ISO calendar day (YYYY-MM-DD), a UTC ISO-8601 instant, or a wall-clock time of day (HH:MM[:SS[.fff]]) for a Field.time column. Those are ASCII and fixed-width, so lexicographic order IS chronological order and every backend agrees; the driver reconciles the comparand with the column (a bare calendar day used as an upper bound becomes the half-open next-day boundary). Ordering NON-temporal text is permitted but NOT promised: the order is the backend collation's (byte-wise on SQLite, the database locale on Postgres, UTF-16 code units in the JS matchers), and those coincide only for ASCII. null is NOT a comparand: null is not ordered — state absence with the null predicate instead ($eq: null is "has no value", $ne: null is "has a value"). | +| **$lt** | `number \| string \| { $field: string; addDays?: integer \| object } \| { $now: true; addDays: integer \| object }` | optional | Less than. Comparand is a number, a Date, a string, a `{ $field }` reference (optionally carrying a whole-day addDays offset), or a `{ $now: true, addDays }` anchor (the current instant shifted by whole days, the offset a literal or a numeric column of the same record). STRING is the form the platform itself produces: the date-macro resolver returns only strings ("`{current_year_start}`" -> "2026-01-01"), and the guaranteed spellings are an ISO calendar day (YYYY-MM-DD), a UTC ISO-8601 instant, or a wall-clock time of day (HH:MM[:SS[.fff]]) for a Field.time column. Those are ASCII and fixed-width, so lexicographic order IS chronological order and every backend agrees; the driver reconciles the comparand with the column (a bare calendar day used as an upper bound becomes the half-open next-day boundary). Ordering NON-temporal text is permitted but NOT promised: the order is the backend collation's (byte-wise on SQLite, the database locale on Postgres, UTF-16 code units in the JS matchers), and those coincide only for ASCII. null is NOT a comparand: null is not ordered — state absence with the null predicate instead ($eq: null is "has no value", $ne: null is "has a value"). | +| **$lte** | `number \| string \| { $field: string; addDays?: integer \| object } \| { $now: true; addDays: integer \| object }` | optional | Less than or equal to. Comparand is a number, a Date, a string, a `{ $field }` reference (optionally carrying a whole-day addDays offset), or a `{ $now: true, addDays }` anchor (the current instant shifted by whole days, the offset a literal or a numeric column of the same record). STRING is the form the platform itself produces: the date-macro resolver returns only strings ("`{current_year_start}`" -> "2026-01-01"), and the guaranteed spellings are an ISO calendar day (YYYY-MM-DD), a UTC ISO-8601 instant, or a wall-clock time of day (HH:MM[:SS[.fff]]) for a Field.time column. Those are ASCII and fixed-width, so lexicographic order IS chronological order and every backend agrees; the driver reconciles the comparand with the column (a bare calendar day used as an upper bound becomes the half-open next-day boundary). Ordering NON-temporal text is permitted but NOT promised: the order is the backend collation's (byte-wise on SQLite, the database locale on Postgres, UTF-16 code units in the JS matchers), and those coincide only for ASCII. null is NOT a comparand: null is not ordered — state absence with the null predicate instead ($eq: null is "has no value", $ne: null is "has a value"). | -### Nested Shape: `ComparisonOperator.$gt` +### Nested Shape: `ComparisonOperator.$gt[option 3]` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **$field** | `string` | ✅ | Field Reference/Column Name | | **addDays** | `integer \| { $field: string }` | optional | Whole-day offset added to the referenced column before comparing: an integer literal of any sign (negative subtracts; whole days only), or a `{ $field }` reference to a numeric column. A NULL offset column contributes zero days; a NULL referenced column makes the comparison false rather than NULL, so it stays false under $not. Compiles on SQL push-down between two temporal columns of the same class (date/date, datetime/datetime) and evaluates identically in memory. | -### Nested Shape: `ComparisonOperator.$gte` +### Nested Shape: `ComparisonOperator.$gt[option 4]` + +A now-anchored comparand: `{ $now: true, addDays: }` — the current instant shifted by whole days, the offset read from a numeric column of the same record or written as a literal. Legal exactly where a `{ $field }` reference is. + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **$now** | `true` | ✅ | The anchor flag: the instant the query runs at. Always the literal true. | +| **addDays** | `integer \| { $field: string }` | ✅ | Whole-day offset added to the current instant before comparing — REQUIRED: an integer literal of any sign (negative subtracts; whole days only), or a `{ $field }` reference to a numeric column of the same record. A NULL offset column contributes zero days. Against a datetime column the shifted instant keeps its time of day; against a date column it is read as its UTC calendar day. Compiles on SQL push-down on every dialect, with the offset column held to the same-table, declared, non-tenant, numeric rules; evaluates identically in memory. | + +### Nested Shape: `ComparisonOperator.$gte[option 3]` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **$field** | `string` | ✅ | Field Reference/Column Name | | **addDays** | `integer \| { $field: string }` | optional | Whole-day offset added to the referenced column before comparing: an integer literal of any sign (negative subtracts; whole days only), or a `{ $field }` reference to a numeric column. A NULL offset column contributes zero days; a NULL referenced column makes the comparison false rather than NULL, so it stays false under $not. Compiles on SQL push-down between two temporal columns of the same class (date/date, datetime/datetime) and evaluates identically in memory. | -### Nested Shape: `ComparisonOperator.$lt` +### Nested Shape: `ComparisonOperator.$gte[option 4]` + +A now-anchored comparand: `{ $now: true, addDays: }` — the current instant shifted by whole days, the offset read from a numeric column of the same record or written as a literal. Legal exactly where a `{ $field }` reference is. + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **$now** | `true` | ✅ | The anchor flag: the instant the query runs at. Always the literal true. | +| **addDays** | `integer \| { $field: string }` | ✅ | Whole-day offset added to the current instant before comparing — REQUIRED: an integer literal of any sign (negative subtracts; whole days only), or a `{ $field }` reference to a numeric column of the same record. A NULL offset column contributes zero days. Against a datetime column the shifted instant keeps its time of day; against a date column it is read as its UTC calendar day. Compiles on SQL push-down on every dialect, with the offset column held to the same-table, declared, non-tenant, numeric rules; evaluates identically in memory. | + +### Nested Shape: `ComparisonOperator.$lt[option 3]` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **$field** | `string` | ✅ | Field Reference/Column Name | | **addDays** | `integer \| { $field: string }` | optional | Whole-day offset added to the referenced column before comparing: an integer literal of any sign (negative subtracts; whole days only), or a `{ $field }` reference to a numeric column. A NULL offset column contributes zero days; a NULL referenced column makes the comparison false rather than NULL, so it stays false under $not. Compiles on SQL push-down between two temporal columns of the same class (date/date, datetime/datetime) and evaluates identically in memory. | -### Nested Shape: `ComparisonOperator.$lte` +### Nested Shape: `ComparisonOperator.$lt[option 4]` + +A now-anchored comparand: `{ $now: true, addDays: }` — the current instant shifted by whole days, the offset read from a numeric column of the same record or written as a literal. Legal exactly where a `{ $field }` reference is. + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **$now** | `true` | ✅ | The anchor flag: the instant the query runs at. Always the literal true. | +| **addDays** | `integer \| { $field: string }` | ✅ | Whole-day offset added to the current instant before comparing — REQUIRED: an integer literal of any sign (negative subtracts; whole days only), or a `{ $field }` reference to a numeric column of the same record. A NULL offset column contributes zero days. Against a datetime column the shifted instant keeps its time of day; against a date column it is read as its UTC calendar day. Compiles on SQL push-down on every dialect, with the offset column held to the same-table, declared, non-tenant, numeric rules; evaluates identically in memory. | + +### Nested Shape: `ComparisonOperator.$lte[option 3]` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **$field** | `string` | ✅ | Field Reference/Column Name | | **addDays** | `integer \| { $field: string }` | optional | Whole-day offset added to the referenced column before comparing: an integer literal of any sign (negative subtracts; whole days only), or a `{ $field }` reference to a numeric column. A NULL offset column contributes zero days; a NULL referenced column makes the comparison false rather than NULL, so it stays false under $not. Compiles on SQL push-down between two temporal columns of the same class (date/date, datetime/datetime) and evaluates identically in memory. | +### Nested Shape: `ComparisonOperator.$lte[option 4]` + +A now-anchored comparand: `{ $now: true, addDays: }` — the current instant shifted by whole days, the offset read from a numeric column of the same record or written as a literal. Legal exactly where a `{ $field }` reference is. + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **$now** | `true` | ✅ | The anchor flag: the instant the query runs at. Always the literal true. | +| **addDays** | `integer \| { $field: string }` | ✅ | Whole-day offset added to the current instant before comparing — REQUIRED: an integer literal of any sign (negative subtracts; whole days only), or a `{ $field }` reference to a numeric column of the same record. A NULL offset column contributes zero days. Against a datetime column the shifted instant keeps its time of day; against a date column it is read as its UTC calendar day. Compiles on SQL push-down on every dialect, with the offset column held to the same-table, declared, non-tenant, numeric rules; evaluates identically in memory. | + --- @@ -90,8 +126,8 @@ const result = ComparisonOperatorSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **$eq** | `any` | optional | Equal to — the DEFAULT operator: a bare value written against a field key is the same condition as this one. Lowered to `=` on the SQL family and to `$eq` on MongoDB. The comparand is a literal, or a `{ $field }` reference to another column of the same table ($eq is one of the six scalar comparisons a reference may be the whole comparand of). `{ "$eq": null }` is the has-NO-value predicate — the same question `{ "$null": true }` asks, and it is about the VALUE, never about whether a key is present. | -| **$ne** | `any` | optional | Not equal to. Lowered to `<>` / `!=` on the SQL family and to `$ne` on MongoDB. The comparand is a literal, or a `{ $field }` reference to another column of the same table ($ne is one of the six scalar comparisons a reference may be the whole comparand of). `{ "$ne": null }` is the HAS-A-VALUE predicate — the same question `{ "$exists": true }` asks: it matches rows whose field holds a value, never rows that merely carry the key. | +| **$eq** | `any` | optional | Equal to — the DEFAULT operator: a bare value written against a field key is the same condition as this one. Lowered to `=` on the SQL family and to `$eq` on MongoDB. The comparand is a literal, a `{ $field }` reference to another column of the same table, or a `{ $now: true, addDays }` anchor ($eq is one of the six scalar comparisons a reference or an anchor may be the whole comparand of). `{ "$eq": null }` is the has-NO-value predicate — the same question `{ "$null": true }` asks, and it is about the VALUE, never about whether a key is present. | +| **$ne** | `any` | optional | Not equal to. Lowered to `<>` / `!=` on the SQL family and to `$ne` on MongoDB. The comparand is a literal, a `{ $field }` reference to another column of the same table, or a `{ $now: true, addDays }` anchor ($ne is one of the six scalar comparisons a reference or an anchor may be the whole comparand of). `{ "$ne": null }` is the HAS-A-VALUE predicate — the same question `{ "$exists": true }` asks: it matches rows whose field holds a value, never rows that merely carry the key. | --- @@ -102,14 +138,14 @@ const result = ComparisonOperatorSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **$eq** | `any` | optional | Equal to — the DEFAULT operator: a bare value written against a field key is the same condition as this one. Lowered to `=` on the SQL family and to `$eq` on MongoDB. The comparand is a literal, or a `{ $field }` reference to another column of the same table ($eq is one of the six scalar comparisons a reference may be the whole comparand of). `{ "$eq": null }` is the has-NO-value predicate — the same question `{ "$null": true }` asks, and it is about the VALUE, never about whether a key is present. | -| **$ne** | `any` | optional | Not equal to. Lowered to `<>` / `!=` on the SQL family and to `$ne` on MongoDB. The comparand is a literal, or a `{ $field }` reference to another column of the same table ($ne is one of the six scalar comparisons a reference may be the whole comparand of). `{ "$ne": null }` is the HAS-A-VALUE predicate — the same question `{ "$exists": true }` asks: it matches rows whose field holds a value, never rows that merely carry the key. | -| **$gt** | `number \| string \| { $field: string; addDays?: integer \| object }` | optional | Greater than. Comparand is a number, a Date, a string, or a `{ $field }` reference (optionally carrying a whole-day addDays offset). STRING is the form the platform itself produces: the date-macro resolver returns only strings ("`{current_year_start}`" -> "2026-01-01"), and the guaranteed spellings are an ISO calendar day (YYYY-MM-DD), a UTC ISO-8601 instant, or a wall-clock time of day (HH:MM[:SS[.fff]]) for a Field.time column. Those are ASCII and fixed-width, so lexicographic order IS chronological order and every backend agrees; the driver reconciles the comparand with the column (a bare calendar day used as an upper bound becomes the half-open next-day boundary). Ordering NON-temporal text is permitted but NOT promised: the order is the backend collation's (byte-wise on SQLite, the database locale on Postgres, UTF-16 code units in the JS matchers), and those coincide only for ASCII. null is NOT a comparand: null is not ordered — state absence with the null predicate instead ($eq: null is "has no value", $ne: null is "has a value"). | -| **$gte** | `number \| string \| { $field: string; addDays?: integer \| object }` | optional | Greater than or equal to. Comparand is a number, a Date, a string, or a `{ $field }` reference (optionally carrying a whole-day addDays offset). STRING is the form the platform itself produces: the date-macro resolver returns only strings ("`{current_year_start}`" -> "2026-01-01"), and the guaranteed spellings are an ISO calendar day (YYYY-MM-DD), a UTC ISO-8601 instant, or a wall-clock time of day (HH:MM[:SS[.fff]]) for a Field.time column. Those are ASCII and fixed-width, so lexicographic order IS chronological order and every backend agrees; the driver reconciles the comparand with the column (a bare calendar day used as an upper bound becomes the half-open next-day boundary). Ordering NON-temporal text is permitted but NOT promised: the order is the backend collation's (byte-wise on SQLite, the database locale on Postgres, UTF-16 code units in the JS matchers), and those coincide only for ASCII. null is NOT a comparand: null is not ordered — state absence with the null predicate instead ($eq: null is "has no value", $ne: null is "has a value"). | -| **$lt** | `number \| string \| { $field: string; addDays?: integer \| object }` | optional | Less than. Comparand is a number, a Date, a string, or a `{ $field }` reference (optionally carrying a whole-day addDays offset). STRING is the form the platform itself produces: the date-macro resolver returns only strings ("`{current_year_start}`" -> "2026-01-01"), and the guaranteed spellings are an ISO calendar day (YYYY-MM-DD), a UTC ISO-8601 instant, or a wall-clock time of day (HH:MM[:SS[.fff]]) for a Field.time column. Those are ASCII and fixed-width, so lexicographic order IS chronological order and every backend agrees; the driver reconciles the comparand with the column (a bare calendar day used as an upper bound becomes the half-open next-day boundary). Ordering NON-temporal text is permitted but NOT promised: the order is the backend collation's (byte-wise on SQLite, the database locale on Postgres, UTF-16 code units in the JS matchers), and those coincide only for ASCII. null is NOT a comparand: null is not ordered — state absence with the null predicate instead ($eq: null is "has no value", $ne: null is "has a value"). | -| **$lte** | `number \| string \| { $field: string; addDays?: integer \| object }` | optional | Less than or equal to. Comparand is a number, a Date, a string, or a `{ $field }` reference (optionally carrying a whole-day addDays offset). STRING is the form the platform itself produces: the date-macro resolver returns only strings ("`{current_year_start}`" -> "2026-01-01"), and the guaranteed spellings are an ISO calendar day (YYYY-MM-DD), a UTC ISO-8601 instant, or a wall-clock time of day (HH:MM[:SS[.fff]]) for a Field.time column. Those are ASCII and fixed-width, so lexicographic order IS chronological order and every backend agrees; the driver reconciles the comparand with the column (a bare calendar day used as an upper bound becomes the half-open next-day boundary). Ordering NON-temporal text is permitted but NOT promised: the order is the backend collation's (byte-wise on SQLite, the database locale on Postgres, UTF-16 code units in the JS matchers), and those coincide only for ASCII. null is NOT a comparand: null is not ordered — state absence with the null predicate instead ($eq: null is "has no value", $ne: null is "has a value"). | -| **$in** | `any[]` | optional | Membership list. Members are literal values of any type the column stores. A `{ $field }` reference is NOT a member shape: no backend resolves one inside a list — put it in a scalar comparison ($eq/$ne/$gt/$gte/$lt/$lte) instead. null is NOT a member shape either: state absence explicitly with the null predicate — "one of […] OR has no value" is `{ "$or": [{ "$in": […] }, { "$null": true }] }`. | -| **$nin** | `any[]` | optional | Membership list. Members are literal values of any type the column stores. A `{ $field }` reference is NOT a member shape: no backend resolves one inside a list — put it in a scalar comparison ($eq/$ne/$gt/$gte/$lt/$lte) instead. null is NOT a member shape either: state absence explicitly with the null predicate — "one of […] OR has no value" is `{ "$or": [{ "$in": […] }, { "$null": true }] }`. | +| **$eq** | `any` | optional | Equal to — the DEFAULT operator: a bare value written against a field key is the same condition as this one. Lowered to `=` on the SQL family and to `$eq` on MongoDB. The comparand is a literal, a `{ $field }` reference to another column of the same table, or a `{ $now: true, addDays }` anchor ($eq is one of the six scalar comparisons a reference or an anchor may be the whole comparand of). `{ "$eq": null }` is the has-NO-value predicate — the same question `{ "$null": true }` asks, and it is about the VALUE, never about whether a key is present. | +| **$ne** | `any` | optional | Not equal to. Lowered to `<>` / `!=` on the SQL family and to `$ne` on MongoDB. The comparand is a literal, a `{ $field }` reference to another column of the same table, or a `{ $now: true, addDays }` anchor ($ne is one of the six scalar comparisons a reference or an anchor may be the whole comparand of). `{ "$ne": null }` is the HAS-A-VALUE predicate — the same question `{ "$exists": true }` asks: it matches rows whose field holds a value, never rows that merely carry the key. | +| **$gt** | `number \| string \| { $field: string; addDays?: integer \| object } \| { $now: true; addDays: integer \| object }` | optional | Greater than. Comparand is a number, a Date, a string, a `{ $field }` reference (optionally carrying a whole-day addDays offset), or a `{ $now: true, addDays }` anchor (the current instant shifted by whole days, the offset a literal or a numeric column of the same record). STRING is the form the platform itself produces: the date-macro resolver returns only strings ("`{current_year_start}`" -> "2026-01-01"), and the guaranteed spellings are an ISO calendar day (YYYY-MM-DD), a UTC ISO-8601 instant, or a wall-clock time of day (HH:MM[:SS[.fff]]) for a Field.time column. Those are ASCII and fixed-width, so lexicographic order IS chronological order and every backend agrees; the driver reconciles the comparand with the column (a bare calendar day used as an upper bound becomes the half-open next-day boundary). Ordering NON-temporal text is permitted but NOT promised: the order is the backend collation's (byte-wise on SQLite, the database locale on Postgres, UTF-16 code units in the JS matchers), and those coincide only for ASCII. null is NOT a comparand: null is not ordered — state absence with the null predicate instead ($eq: null is "has no value", $ne: null is "has a value"). | +| **$gte** | `number \| string \| { $field: string; addDays?: integer \| object } \| { $now: true; addDays: integer \| object }` | optional | Greater than or equal to. Comparand is a number, a Date, a string, a `{ $field }` reference (optionally carrying a whole-day addDays offset), or a `{ $now: true, addDays }` anchor (the current instant shifted by whole days, the offset a literal or a numeric column of the same record). STRING is the form the platform itself produces: the date-macro resolver returns only strings ("`{current_year_start}`" -> "2026-01-01"), and the guaranteed spellings are an ISO calendar day (YYYY-MM-DD), a UTC ISO-8601 instant, or a wall-clock time of day (HH:MM[:SS[.fff]]) for a Field.time column. Those are ASCII and fixed-width, so lexicographic order IS chronological order and every backend agrees; the driver reconciles the comparand with the column (a bare calendar day used as an upper bound becomes the half-open next-day boundary). Ordering NON-temporal text is permitted but NOT promised: the order is the backend collation's (byte-wise on SQLite, the database locale on Postgres, UTF-16 code units in the JS matchers), and those coincide only for ASCII. null is NOT a comparand: null is not ordered — state absence with the null predicate instead ($eq: null is "has no value", $ne: null is "has a value"). | +| **$lt** | `number \| string \| { $field: string; addDays?: integer \| object } \| { $now: true; addDays: integer \| object }` | optional | Less than. Comparand is a number, a Date, a string, a `{ $field }` reference (optionally carrying a whole-day addDays offset), or a `{ $now: true, addDays }` anchor (the current instant shifted by whole days, the offset a literal or a numeric column of the same record). STRING is the form the platform itself produces: the date-macro resolver returns only strings ("`{current_year_start}`" -> "2026-01-01"), and the guaranteed spellings are an ISO calendar day (YYYY-MM-DD), a UTC ISO-8601 instant, or a wall-clock time of day (HH:MM[:SS[.fff]]) for a Field.time column. Those are ASCII and fixed-width, so lexicographic order IS chronological order and every backend agrees; the driver reconciles the comparand with the column (a bare calendar day used as an upper bound becomes the half-open next-day boundary). Ordering NON-temporal text is permitted but NOT promised: the order is the backend collation's (byte-wise on SQLite, the database locale on Postgres, UTF-16 code units in the JS matchers), and those coincide only for ASCII. null is NOT a comparand: null is not ordered — state absence with the null predicate instead ($eq: null is "has no value", $ne: null is "has a value"). | +| **$lte** | `number \| string \| { $field: string; addDays?: integer \| object } \| { $now: true; addDays: integer \| object }` | optional | Less than or equal to. Comparand is a number, a Date, a string, a `{ $field }` reference (optionally carrying a whole-day addDays offset), or a `{ $now: true, addDays }` anchor (the current instant shifted by whole days, the offset a literal or a numeric column of the same record). STRING is the form the platform itself produces: the date-macro resolver returns only strings ("`{current_year_start}`" -> "2026-01-01"), and the guaranteed spellings are an ISO calendar day (YYYY-MM-DD), a UTC ISO-8601 instant, or a wall-clock time of day (HH:MM[:SS[.fff]]) for a Field.time column. Those are ASCII and fixed-width, so lexicographic order IS chronological order and every backend agrees; the driver reconciles the comparand with the column (a bare calendar day used as an upper bound becomes the half-open next-day boundary). Ordering NON-temporal text is permitted but NOT promised: the order is the backend collation's (byte-wise on SQLite, the database locale on Postgres, UTF-16 code units in the JS matchers), and those coincide only for ASCII. null is NOT a comparand: null is not ordered — state absence with the null predicate instead ($eq: null is "has no value", $ne: null is "has a value"). | +| **$in** | `any[]` | optional | Membership list. Members are literal values of any type the column stores. A `{ $field }` reference is NOT a member shape, nor is a `{ $now: true, addDays }` anchor: no backend resolves either inside a list — put it in a scalar comparison ($eq/$ne/$gt/$gte/$lt/$lte) instead. null is NOT a member shape either: state absence explicitly with the null predicate — "one of […] OR has no value" is `{ "$or": [{ "$in": […] }, { "$null": true }] }`. | +| **$nin** | `any[]` | optional | Membership list. Members are literal values of any type the column stores. A `{ $field }` reference is NOT a member shape, nor is a `{ $now: true, addDays }` anchor: no backend resolves either inside a list — put it in a scalar comparison ($eq/$ne/$gt/$gte/$lt/$lte) instead. null is NOT a member shape either: state absence explicitly with the null predicate — "one of […] OR has no value" is `{ "$or": [{ "$in": […] }, { "$null": true }] }`. | | **$between** | `[number \| string, number \| string]` | optional | Between (inclusive). Closed interval [min, max]. Each endpoint is a number, a Date, or a string, and BOTH are required NON-BLANK: an empty string, null and undefined are refused, and the refusal names the blank side. A range bounded on ONE side only is not a $between at all — write the side you have as a scalar comparison ($gte for a lower bound, $lte for an upper one). A `{ $field }` reference is NOT an endpoint shape: no backend resolves one inside a list — put it in a scalar comparison ($gt/$gte/$lt/$lte), which does compile to a column-to-column bound. STRING is the form the platform itself produces: the date-macro resolver walks INTO arrays, so `{ $between: ["{current_year_start}", "{current_year_end}"] }` resolves to two strings. The guaranteed spellings are an ISO calendar day (YYYY-MM-DD), a UTC ISO-8601 instant, or a wall-clock time of day (HH:MM[:SS[.fff]]) for a Field.time column; those are ASCII and fixed-width, so lexicographic order IS chronological order and every backend agrees. The driver reconciles each endpoint with the column independently (a bare calendar day used as the MAX becomes the half-open next-day boundary). Ranging over NON-temporal text is permitted but NOT promised: the order is the backend collation's, and those coincide only for ASCII. | | **$contains** | `string` | optional | Contains substring, CASE-SENSITIVELY — "acme" does NOT match "ACME". Lowered to `LIKE '%?%'` (case-exact) on the SQL family, and answered case-exactly on every JS evaluation face the platform ships. The comparand is matched LITERALLY: "%", "_" and regex metacharacters are ordinary characters, because this family escapes and anchors the comparand on the caller's behalf. To bind the wildcards yourself, write $like (case-exact) or $ilike (ASCII-folded). Case-INSENSITIVE containment is $icontains, which folds ASCII case only. | | **$notContains** | `string` | optional | Does not contain substring, CASE-SENSITIVELY — the negation of $contains, on the same comparand contract. Lowered to `NOT LIKE '%?%'` (case-exact) on the SQL family. The comparand is matched LITERALLY: "%", "_" and regex metacharacters are ordinary characters, because this family escapes and anchors the comparand on the caller's behalf. To bind the wildcards yourself, write $like (case-exact) or $ilike (ASCII-folded). | @@ -122,34 +158,70 @@ const result = ComparisonOperatorSchema.parse(data); | **$exists** | `boolean` | optional | Has-a-value check — the exact inverse of `$null`. `true` matches rows where the field holds a value (`!= null`), `false` matches rows where it holds none. Portable across every backend the platform ships: lowered to `IS NOT NULL` (true) / `IS NULL` (false) on the SQL family and to `{ $ne: null }` (true) / `{ $eq: null }` (false) on MongoDB. | | **$empty** | `boolean` | optional | Is-empty check by the field's DECLARED type. `true` matches rows whose field is empty, `false` is its exact complement. What counts as empty: text-like types (text, textarea, email, url, phone, password, secret, markdown, html, richtext, code, color, signature, qrcode) = null or '' (the empty string); multi-value types (multiselect, checkboxes, tags, and select, radio, lookup, user, file or image with multiple: true) = null or [] (the empty list); every other type = null only. A face that holds no field declaration judges by the value: null, '' and [] are empty. A face that answers by the declared type refuses the operator on a column whose declaration it does not hold (the built-in id, for one) rather than guess a row; use $null there for "has no value". The view operators is_empty / is_not_empty lower to this operator. | -### Nested Shape: `FieldOperators.$gt` +### Nested Shape: `FieldOperators.$gt[option 3]` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **$field** | `string` | ✅ | Field Reference/Column Name | | **addDays** | `integer \| { $field: string }` | optional | Whole-day offset added to the referenced column before comparing: an integer literal of any sign (negative subtracts; whole days only), or a `{ $field }` reference to a numeric column. A NULL offset column contributes zero days; a NULL referenced column makes the comparison false rather than NULL, so it stays false under $not. Compiles on SQL push-down between two temporal columns of the same class (date/date, datetime/datetime) and evaluates identically in memory. | -### Nested Shape: `FieldOperators.$gte` +### Nested Shape: `FieldOperators.$gt[option 4]` + +A now-anchored comparand: `{ $now: true, addDays: }` — the current instant shifted by whole days, the offset read from a numeric column of the same record or written as a literal. Legal exactly where a `{ $field }` reference is. + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **$now** | `true` | ✅ | The anchor flag: the instant the query runs at. Always the literal true. | +| **addDays** | `integer \| { $field: string }` | ✅ | Whole-day offset added to the current instant before comparing — REQUIRED: an integer literal of any sign (negative subtracts; whole days only), or a `{ $field }` reference to a numeric column of the same record. A NULL offset column contributes zero days. Against a datetime column the shifted instant keeps its time of day; against a date column it is read as its UTC calendar day. Compiles on SQL push-down on every dialect, with the offset column held to the same-table, declared, non-tenant, numeric rules; evaluates identically in memory. | + +### Nested Shape: `FieldOperators.$gte[option 3]` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **$field** | `string` | ✅ | Field Reference/Column Name | | **addDays** | `integer \| { $field: string }` | optional | Whole-day offset added to the referenced column before comparing: an integer literal of any sign (negative subtracts; whole days only), or a `{ $field }` reference to a numeric column. A NULL offset column contributes zero days; a NULL referenced column makes the comparison false rather than NULL, so it stays false under $not. Compiles on SQL push-down between two temporal columns of the same class (date/date, datetime/datetime) and evaluates identically in memory. | -### Nested Shape: `FieldOperators.$lt` +### Nested Shape: `FieldOperators.$gte[option 4]` + +A now-anchored comparand: `{ $now: true, addDays: }` — the current instant shifted by whole days, the offset read from a numeric column of the same record or written as a literal. Legal exactly where a `{ $field }` reference is. + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **$now** | `true` | ✅ | The anchor flag: the instant the query runs at. Always the literal true. | +| **addDays** | `integer \| { $field: string }` | ✅ | Whole-day offset added to the current instant before comparing — REQUIRED: an integer literal of any sign (negative subtracts; whole days only), or a `{ $field }` reference to a numeric column of the same record. A NULL offset column contributes zero days. Against a datetime column the shifted instant keeps its time of day; against a date column it is read as its UTC calendar day. Compiles on SQL push-down on every dialect, with the offset column held to the same-table, declared, non-tenant, numeric rules; evaluates identically in memory. | + +### Nested Shape: `FieldOperators.$lt[option 3]` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **$field** | `string` | ✅ | Field Reference/Column Name | | **addDays** | `integer \| { $field: string }` | optional | Whole-day offset added to the referenced column before comparing: an integer literal of any sign (negative subtracts; whole days only), or a `{ $field }` reference to a numeric column. A NULL offset column contributes zero days; a NULL referenced column makes the comparison false rather than NULL, so it stays false under $not. Compiles on SQL push-down between two temporal columns of the same class (date/date, datetime/datetime) and evaluates identically in memory. | -### Nested Shape: `FieldOperators.$lte` +### Nested Shape: `FieldOperators.$lt[option 4]` + +A now-anchored comparand: `{ $now: true, addDays: }` — the current instant shifted by whole days, the offset read from a numeric column of the same record or written as a literal. Legal exactly where a `{ $field }` reference is. + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **$now** | `true` | ✅ | The anchor flag: the instant the query runs at. Always the literal true. | +| **addDays** | `integer \| { $field: string }` | ✅ | Whole-day offset added to the current instant before comparing — REQUIRED: an integer literal of any sign (negative subtracts; whole days only), or a `{ $field }` reference to a numeric column of the same record. A NULL offset column contributes zero days. Against a datetime column the shifted instant keeps its time of day; against a date column it is read as its UTC calendar day. Compiles on SQL push-down on every dialect, with the offset column held to the same-table, declared, non-tenant, numeric rules; evaluates identically in memory. | + +### Nested Shape: `FieldOperators.$lte[option 3]` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **$field** | `string` | ✅ | Field Reference/Column Name | | **addDays** | `integer \| { $field: string }` | optional | Whole-day offset added to the referenced column before comparing: an integer literal of any sign (negative subtracts; whole days only), or a `{ $field }` reference to a numeric column. A NULL offset column contributes zero days; a NULL referenced column makes the comparison false rather than NULL, so it stays false under $not. Compiles on SQL push-down between two temporal columns of the same class (date/date, datetime/datetime) and evaluates identically in memory. | +### Nested Shape: `FieldOperators.$lte[option 4]` + +A now-anchored comparand: `{ $now: true, addDays: }` — the current instant shifted by whole days, the offset read from a numeric column of the same record or written as a literal. Legal exactly where a `{ $field }` reference is. + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **$now** | `true` | ✅ | The anchor flag: the instant the query runs at. Always the literal true. | +| **addDays** | `integer \| { $field: string }` | ✅ | Whole-day offset added to the current instant before comparing — REQUIRED: an integer literal of any sign (negative subtracts; whole days only), or a `{ $field }` reference to a numeric column of the same record. A NULL offset column contributes zero days. Against a datetime column the shifted instant keeps its time of day; against a date column it is read as its UTC calendar day. Compiles on SQL push-down on every dialect, with the offset column held to the same-table, declared, non-tenant, numeric rules; evaluates identically in memory. | + --- @@ -221,22 +293,22 @@ Type: `[FilterArray](#filterarray)[]` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **$and** | `(Record \| [NormalizedFilter](#normalizedfilter))[]` | optional | | -| **$or** | `(Record \| [NormalizedFilter](#normalizedfilter))[]` | optional | | -| **$not** | `Record \| [NormalizedFilter](#normalizedfilter)` | optional | | +| **$and** | `(Record \| [NormalizedFilter](#normalizedfilter))[]` | optional | | +| **$or** | `(Record \| [NormalizedFilter](#normalizedfilter))[]` | optional | | +| **$not** | `Record \| [NormalizedFilter](#normalizedfilter)` | optional | | ### Nested Shape: `NormalizedFilter.$and[number][string]` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **$eq** | `any` | optional | Equal to — the DEFAULT operator: a bare value written against a field key is the same condition as this one. Lowered to `=` on the SQL family and to `$eq` on MongoDB. The comparand is a literal, or a `{ $field }` reference to another column of the same table ($eq is one of the six scalar comparisons a reference may be the whole comparand of). `{ "$eq": null }` is the has-NO-value predicate — the same question `{ "$null": true }` asks, and it is about the VALUE, never about whether a key is present. | -| **$ne** | `any` | optional | Not equal to. Lowered to `<>` / `!=` on the SQL family and to `$ne` on MongoDB. The comparand is a literal, or a `{ $field }` reference to another column of the same table ($ne is one of the six scalar comparisons a reference may be the whole comparand of). `{ "$ne": null }` is the HAS-A-VALUE predicate — the same question `{ "$exists": true }` asks: it matches rows whose field holds a value, never rows that merely carry the key. | -| **$gt** | `number \| string \| { $field: string; addDays?: integer \| object }` | optional | Greater than. Comparand is a number, a Date, a string, or a `{ $field }` reference (optionally carrying a whole-day addDays offset). STRING is the form the platform itself produces: the date-macro resolver returns only strings ("`{current_year_start}`" -> "2026-01-01"), and the guaranteed spellings are an ISO calendar day (YYYY-MM-DD), a UTC ISO-8601 instant, or a wall-clock time of day (HH:MM[:SS[.fff]]) for a Field.time column. Those are ASCII and fixed-width, so lexicographic order IS chronological order and every backend agrees; the driver reconciles the comparand with the column (a bare calendar day used as an upper bound becomes the half-open next-day boundary). Ordering NON-temporal text is permitted but NOT promised: the order is the backend collation's (byte-wise on SQLite, the database locale on Postgres, UTF-16 code units in the JS matchers), and those coincide only for ASCII. null is NOT a comparand: null is not ordered — state absence with the null predicate instead ($eq: null is "has no value", $ne: null is "has a value"). | -| **$gte** | `number \| string \| { $field: string; addDays?: integer \| object }` | optional | Greater than or equal to. Comparand is a number, a Date, a string, or a `{ $field }` reference (optionally carrying a whole-day addDays offset). STRING is the form the platform itself produces: the date-macro resolver returns only strings ("`{current_year_start}`" -> "2026-01-01"), and the guaranteed spellings are an ISO calendar day (YYYY-MM-DD), a UTC ISO-8601 instant, or a wall-clock time of day (HH:MM[:SS[.fff]]) for a Field.time column. Those are ASCII and fixed-width, so lexicographic order IS chronological order and every backend agrees; the driver reconciles the comparand with the column (a bare calendar day used as an upper bound becomes the half-open next-day boundary). Ordering NON-temporal text is permitted but NOT promised: the order is the backend collation's (byte-wise on SQLite, the database locale on Postgres, UTF-16 code units in the JS matchers), and those coincide only for ASCII. null is NOT a comparand: null is not ordered — state absence with the null predicate instead ($eq: null is "has no value", $ne: null is "has a value"). | -| **$lt** | `number \| string \| { $field: string; addDays?: integer \| object }` | optional | Less than. Comparand is a number, a Date, a string, or a `{ $field }` reference (optionally carrying a whole-day addDays offset). STRING is the form the platform itself produces: the date-macro resolver returns only strings ("`{current_year_start}`" -> "2026-01-01"), and the guaranteed spellings are an ISO calendar day (YYYY-MM-DD), a UTC ISO-8601 instant, or a wall-clock time of day (HH:MM[:SS[.fff]]) for a Field.time column. Those are ASCII and fixed-width, so lexicographic order IS chronological order and every backend agrees; the driver reconciles the comparand with the column (a bare calendar day used as an upper bound becomes the half-open next-day boundary). Ordering NON-temporal text is permitted but NOT promised: the order is the backend collation's (byte-wise on SQLite, the database locale on Postgres, UTF-16 code units in the JS matchers), and those coincide only for ASCII. null is NOT a comparand: null is not ordered — state absence with the null predicate instead ($eq: null is "has no value", $ne: null is "has a value"). | -| **$lte** | `number \| string \| { $field: string; addDays?: integer \| object }` | optional | Less than or equal to. Comparand is a number, a Date, a string, or a `{ $field }` reference (optionally carrying a whole-day addDays offset). STRING is the form the platform itself produces: the date-macro resolver returns only strings ("`{current_year_start}`" -> "2026-01-01"), and the guaranteed spellings are an ISO calendar day (YYYY-MM-DD), a UTC ISO-8601 instant, or a wall-clock time of day (HH:MM[:SS[.fff]]) for a Field.time column. Those are ASCII and fixed-width, so lexicographic order IS chronological order and every backend agrees; the driver reconciles the comparand with the column (a bare calendar day used as an upper bound becomes the half-open next-day boundary). Ordering NON-temporal text is permitted but NOT promised: the order is the backend collation's (byte-wise on SQLite, the database locale on Postgres, UTF-16 code units in the JS matchers), and those coincide only for ASCII. null is NOT a comparand: null is not ordered — state absence with the null predicate instead ($eq: null is "has no value", $ne: null is "has a value"). | -| **$in** | `any[]` | optional | Membership list. Members are literal values of any type the column stores. A `{ $field }` reference is NOT a member shape: no backend resolves one inside a list — put it in a scalar comparison ($eq/$ne/$gt/$gte/$lt/$lte) instead. null is NOT a member shape either: state absence explicitly with the null predicate — "one of […] OR has no value" is `{ "$or": [{ "$in": […] }, { "$null": true }] }`. | -| **$nin** | `any[]` | optional | Membership list. Members are literal values of any type the column stores. A `{ $field }` reference is NOT a member shape: no backend resolves one inside a list — put it in a scalar comparison ($eq/$ne/$gt/$gte/$lt/$lte) instead. null is NOT a member shape either: state absence explicitly with the null predicate — "one of […] OR has no value" is `{ "$or": [{ "$in": […] }, { "$null": true }] }`. | +| **$eq** | `any` | optional | Equal to — the DEFAULT operator: a bare value written against a field key is the same condition as this one. Lowered to `=` on the SQL family and to `$eq` on MongoDB. The comparand is a literal, a `{ $field }` reference to another column of the same table, or a `{ $now: true, addDays }` anchor ($eq is one of the six scalar comparisons a reference or an anchor may be the whole comparand of). `{ "$eq": null }` is the has-NO-value predicate — the same question `{ "$null": true }` asks, and it is about the VALUE, never about whether a key is present. | +| **$ne** | `any` | optional | Not equal to. Lowered to `<>` / `!=` on the SQL family and to `$ne` on MongoDB. The comparand is a literal, a `{ $field }` reference to another column of the same table, or a `{ $now: true, addDays }` anchor ($ne is one of the six scalar comparisons a reference or an anchor may be the whole comparand of). `{ "$ne": null }` is the HAS-A-VALUE predicate — the same question `{ "$exists": true }` asks: it matches rows whose field holds a value, never rows that merely carry the key. | +| **$gt** | `number \| string \| { $field: string; addDays?: integer \| object } \| { $now: true; addDays: integer \| object }` | optional | Greater than. Comparand is a number, a Date, a string, a `{ $field }` reference (optionally carrying a whole-day addDays offset), or a `{ $now: true, addDays }` anchor (the current instant shifted by whole days, the offset a literal or a numeric column of the same record). STRING is the form the platform itself produces: the date-macro resolver returns only strings ("`{current_year_start}`" -> "2026-01-01"), and the guaranteed spellings are an ISO calendar day (YYYY-MM-DD), a UTC ISO-8601 instant, or a wall-clock time of day (HH:MM[:SS[.fff]]) for a Field.time column. Those are ASCII and fixed-width, so lexicographic order IS chronological order and every backend agrees; the driver reconciles the comparand with the column (a bare calendar day used as an upper bound becomes the half-open next-day boundary). Ordering NON-temporal text is permitted but NOT promised: the order is the backend collation's (byte-wise on SQLite, the database locale on Postgres, UTF-16 code units in the JS matchers), and those coincide only for ASCII. null is NOT a comparand: null is not ordered — state absence with the null predicate instead ($eq: null is "has no value", $ne: null is "has a value"). | +| **$gte** | `number \| string \| { $field: string; addDays?: integer \| object } \| { $now: true; addDays: integer \| object }` | optional | Greater than or equal to. Comparand is a number, a Date, a string, a `{ $field }` reference (optionally carrying a whole-day addDays offset), or a `{ $now: true, addDays }` anchor (the current instant shifted by whole days, the offset a literal or a numeric column of the same record). STRING is the form the platform itself produces: the date-macro resolver returns only strings ("`{current_year_start}`" -> "2026-01-01"), and the guaranteed spellings are an ISO calendar day (YYYY-MM-DD), a UTC ISO-8601 instant, or a wall-clock time of day (HH:MM[:SS[.fff]]) for a Field.time column. Those are ASCII and fixed-width, so lexicographic order IS chronological order and every backend agrees; the driver reconciles the comparand with the column (a bare calendar day used as an upper bound becomes the half-open next-day boundary). Ordering NON-temporal text is permitted but NOT promised: the order is the backend collation's (byte-wise on SQLite, the database locale on Postgres, UTF-16 code units in the JS matchers), and those coincide only for ASCII. null is NOT a comparand: null is not ordered — state absence with the null predicate instead ($eq: null is "has no value", $ne: null is "has a value"). | +| **$lt** | `number \| string \| { $field: string; addDays?: integer \| object } \| { $now: true; addDays: integer \| object }` | optional | Less than. Comparand is a number, a Date, a string, a `{ $field }` reference (optionally carrying a whole-day addDays offset), or a `{ $now: true, addDays }` anchor (the current instant shifted by whole days, the offset a literal or a numeric column of the same record). STRING is the form the platform itself produces: the date-macro resolver returns only strings ("`{current_year_start}`" -> "2026-01-01"), and the guaranteed spellings are an ISO calendar day (YYYY-MM-DD), a UTC ISO-8601 instant, or a wall-clock time of day (HH:MM[:SS[.fff]]) for a Field.time column. Those are ASCII and fixed-width, so lexicographic order IS chronological order and every backend agrees; the driver reconciles the comparand with the column (a bare calendar day used as an upper bound becomes the half-open next-day boundary). Ordering NON-temporal text is permitted but NOT promised: the order is the backend collation's (byte-wise on SQLite, the database locale on Postgres, UTF-16 code units in the JS matchers), and those coincide only for ASCII. null is NOT a comparand: null is not ordered — state absence with the null predicate instead ($eq: null is "has no value", $ne: null is "has a value"). | +| **$lte** | `number \| string \| { $field: string; addDays?: integer \| object } \| { $now: true; addDays: integer \| object }` | optional | Less than or equal to. Comparand is a number, a Date, a string, a `{ $field }` reference (optionally carrying a whole-day addDays offset), or a `{ $now: true, addDays }` anchor (the current instant shifted by whole days, the offset a literal or a numeric column of the same record). STRING is the form the platform itself produces: the date-macro resolver returns only strings ("`{current_year_start}`" -> "2026-01-01"), and the guaranteed spellings are an ISO calendar day (YYYY-MM-DD), a UTC ISO-8601 instant, or a wall-clock time of day (HH:MM[:SS[.fff]]) for a Field.time column. Those are ASCII and fixed-width, so lexicographic order IS chronological order and every backend agrees; the driver reconciles the comparand with the column (a bare calendar day used as an upper bound becomes the half-open next-day boundary). Ordering NON-temporal text is permitted but NOT promised: the order is the backend collation's (byte-wise on SQLite, the database locale on Postgres, UTF-16 code units in the JS matchers), and those coincide only for ASCII. null is NOT a comparand: null is not ordered — state absence with the null predicate instead ($eq: null is "has no value", $ne: null is "has a value"). | +| **$in** | `any[]` | optional | Membership list. Members are literal values of any type the column stores. A `{ $field }` reference is NOT a member shape, nor is a `{ $now: true, addDays }` anchor: no backend resolves either inside a list — put it in a scalar comparison ($eq/$ne/$gt/$gte/$lt/$lte) instead. null is NOT a member shape either: state absence explicitly with the null predicate — "one of […] OR has no value" is `{ "$or": [{ "$in": […] }, { "$null": true }] }`. | +| **$nin** | `any[]` | optional | Membership list. Members are literal values of any type the column stores. A `{ $field }` reference is NOT a member shape, nor is a `{ $now: true, addDays }` anchor: no backend resolves either inside a list — put it in a scalar comparison ($eq/$ne/$gt/$gte/$lt/$lte) instead. null is NOT a member shape either: state absence explicitly with the null predicate — "one of […] OR has no value" is `{ "$or": [{ "$in": […] }, { "$null": true }] }`. | | **$between** | `[number \| string, number \| string]` | optional | Between (inclusive). Closed interval [min, max]. Each endpoint is a number, a Date, or a string, and BOTH are required NON-BLANK: an empty string, null and undefined are refused, and the refusal names the blank side. A range bounded on ONE side only is not a $between at all — write the side you have as a scalar comparison ($gte for a lower bound, $lte for an upper one). A `{ $field }` reference is NOT an endpoint shape: no backend resolves one inside a list — put it in a scalar comparison ($gt/$gte/$lt/$lte), which does compile to a column-to-column bound. STRING is the form the platform itself produces: the date-macro resolver walks INTO arrays, so `{ $between: ["{current_year_start}", "{current_year_end}"] }` resolves to two strings. The guaranteed spellings are an ISO calendar day (YYYY-MM-DD), a UTC ISO-8601 instant, or a wall-clock time of day (HH:MM[:SS[.fff]]) for a Field.time column; those are ASCII and fixed-width, so lexicographic order IS chronological order and every backend agrees. The driver reconciles each endpoint with the column independently (a bare calendar day used as the MAX becomes the half-open next-day boundary). Ranging over NON-temporal text is permitted but NOT promised: the order is the backend collation's, and those coincide only for ASCII. | | **$contains** | `string` | optional | Contains substring, CASE-SENSITIVELY — "acme" does NOT match "ACME". Lowered to `LIKE '%?%'` (case-exact) on the SQL family, and answered case-exactly on every JS evaluation face the platform ships. The comparand is matched LITERALLY: "%", "_" and regex metacharacters are ordinary characters, because this family escapes and anchors the comparand on the caller's behalf. To bind the wildcards yourself, write $like (case-exact) or $ilike (ASCII-folded). Case-INSENSITIVE containment is $icontains, which folds ASCII case only. | | **$notContains** | `string` | optional | Does not contain substring, CASE-SENSITIVELY — the negation of $contains, on the same comparand contract. Lowered to `NOT LIKE '%?%'` (case-exact) on the SQL family. The comparand is matched LITERALLY: "%", "_" and regex metacharacters are ordinary characters, because this family escapes and anchors the comparand on the caller's behalf. To bind the wildcards yourself, write $like (case-exact) or $ilike (ASCII-folded). | @@ -253,14 +325,14 @@ Type: `[FilterArray](#filterarray)[]` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **$eq** | `any` | optional | Equal to — the DEFAULT operator: a bare value written against a field key is the same condition as this one. Lowered to `=` on the SQL family and to `$eq` on MongoDB. The comparand is a literal, or a `{ $field }` reference to another column of the same table ($eq is one of the six scalar comparisons a reference may be the whole comparand of). `{ "$eq": null }` is the has-NO-value predicate — the same question `{ "$null": true }` asks, and it is about the VALUE, never about whether a key is present. | -| **$ne** | `any` | optional | Not equal to. Lowered to `<>` / `!=` on the SQL family and to `$ne` on MongoDB. The comparand is a literal, or a `{ $field }` reference to another column of the same table ($ne is one of the six scalar comparisons a reference may be the whole comparand of). `{ "$ne": null }` is the HAS-A-VALUE predicate — the same question `{ "$exists": true }` asks: it matches rows whose field holds a value, never rows that merely carry the key. | -| **$gt** | `number \| string \| { $field: string; addDays?: integer \| object }` | optional | Greater than. Comparand is a number, a Date, a string, or a `{ $field }` reference (optionally carrying a whole-day addDays offset). STRING is the form the platform itself produces: the date-macro resolver returns only strings ("`{current_year_start}`" -> "2026-01-01"), and the guaranteed spellings are an ISO calendar day (YYYY-MM-DD), a UTC ISO-8601 instant, or a wall-clock time of day (HH:MM[:SS[.fff]]) for a Field.time column. Those are ASCII and fixed-width, so lexicographic order IS chronological order and every backend agrees; the driver reconciles the comparand with the column (a bare calendar day used as an upper bound becomes the half-open next-day boundary). Ordering NON-temporal text is permitted but NOT promised: the order is the backend collation's (byte-wise on SQLite, the database locale on Postgres, UTF-16 code units in the JS matchers), and those coincide only for ASCII. null is NOT a comparand: null is not ordered — state absence with the null predicate instead ($eq: null is "has no value", $ne: null is "has a value"). | -| **$gte** | `number \| string \| { $field: string; addDays?: integer \| object }` | optional | Greater than or equal to. Comparand is a number, a Date, a string, or a `{ $field }` reference (optionally carrying a whole-day addDays offset). STRING is the form the platform itself produces: the date-macro resolver returns only strings ("`{current_year_start}`" -> "2026-01-01"), and the guaranteed spellings are an ISO calendar day (YYYY-MM-DD), a UTC ISO-8601 instant, or a wall-clock time of day (HH:MM[:SS[.fff]]) for a Field.time column. Those are ASCII and fixed-width, so lexicographic order IS chronological order and every backend agrees; the driver reconciles the comparand with the column (a bare calendar day used as an upper bound becomes the half-open next-day boundary). Ordering NON-temporal text is permitted but NOT promised: the order is the backend collation's (byte-wise on SQLite, the database locale on Postgres, UTF-16 code units in the JS matchers), and those coincide only for ASCII. null is NOT a comparand: null is not ordered — state absence with the null predicate instead ($eq: null is "has no value", $ne: null is "has a value"). | -| **$lt** | `number \| string \| { $field: string; addDays?: integer \| object }` | optional | Less than. Comparand is a number, a Date, a string, or a `{ $field }` reference (optionally carrying a whole-day addDays offset). STRING is the form the platform itself produces: the date-macro resolver returns only strings ("`{current_year_start}`" -> "2026-01-01"), and the guaranteed spellings are an ISO calendar day (YYYY-MM-DD), a UTC ISO-8601 instant, or a wall-clock time of day (HH:MM[:SS[.fff]]) for a Field.time column. Those are ASCII and fixed-width, so lexicographic order IS chronological order and every backend agrees; the driver reconciles the comparand with the column (a bare calendar day used as an upper bound becomes the half-open next-day boundary). Ordering NON-temporal text is permitted but NOT promised: the order is the backend collation's (byte-wise on SQLite, the database locale on Postgres, UTF-16 code units in the JS matchers), and those coincide only for ASCII. null is NOT a comparand: null is not ordered — state absence with the null predicate instead ($eq: null is "has no value", $ne: null is "has a value"). | -| **$lte** | `number \| string \| { $field: string; addDays?: integer \| object }` | optional | Less than or equal to. Comparand is a number, a Date, a string, or a `{ $field }` reference (optionally carrying a whole-day addDays offset). STRING is the form the platform itself produces: the date-macro resolver returns only strings ("`{current_year_start}`" -> "2026-01-01"), and the guaranteed spellings are an ISO calendar day (YYYY-MM-DD), a UTC ISO-8601 instant, or a wall-clock time of day (HH:MM[:SS[.fff]]) for a Field.time column. Those are ASCII and fixed-width, so lexicographic order IS chronological order and every backend agrees; the driver reconciles the comparand with the column (a bare calendar day used as an upper bound becomes the half-open next-day boundary). Ordering NON-temporal text is permitted but NOT promised: the order is the backend collation's (byte-wise on SQLite, the database locale on Postgres, UTF-16 code units in the JS matchers), and those coincide only for ASCII. null is NOT a comparand: null is not ordered — state absence with the null predicate instead ($eq: null is "has no value", $ne: null is "has a value"). | -| **$in** | `any[]` | optional | Membership list. Members are literal values of any type the column stores. A `{ $field }` reference is NOT a member shape: no backend resolves one inside a list — put it in a scalar comparison ($eq/$ne/$gt/$gte/$lt/$lte) instead. null is NOT a member shape either: state absence explicitly with the null predicate — "one of […] OR has no value" is `{ "$or": [{ "$in": […] }, { "$null": true }] }`. | -| **$nin** | `any[]` | optional | Membership list. Members are literal values of any type the column stores. A `{ $field }` reference is NOT a member shape: no backend resolves one inside a list — put it in a scalar comparison ($eq/$ne/$gt/$gte/$lt/$lte) instead. null is NOT a member shape either: state absence explicitly with the null predicate — "one of […] OR has no value" is `{ "$or": [{ "$in": […] }, { "$null": true }] }`. | +| **$eq** | `any` | optional | Equal to — the DEFAULT operator: a bare value written against a field key is the same condition as this one. Lowered to `=` on the SQL family and to `$eq` on MongoDB. The comparand is a literal, a `{ $field }` reference to another column of the same table, or a `{ $now: true, addDays }` anchor ($eq is one of the six scalar comparisons a reference or an anchor may be the whole comparand of). `{ "$eq": null }` is the has-NO-value predicate — the same question `{ "$null": true }` asks, and it is about the VALUE, never about whether a key is present. | +| **$ne** | `any` | optional | Not equal to. Lowered to `<>` / `!=` on the SQL family and to `$ne` on MongoDB. The comparand is a literal, a `{ $field }` reference to another column of the same table, or a `{ $now: true, addDays }` anchor ($ne is one of the six scalar comparisons a reference or an anchor may be the whole comparand of). `{ "$ne": null }` is the HAS-A-VALUE predicate — the same question `{ "$exists": true }` asks: it matches rows whose field holds a value, never rows that merely carry the key. | +| **$gt** | `number \| string \| { $field: string; addDays?: integer \| object } \| { $now: true; addDays: integer \| object }` | optional | Greater than. Comparand is a number, a Date, a string, a `{ $field }` reference (optionally carrying a whole-day addDays offset), or a `{ $now: true, addDays }` anchor (the current instant shifted by whole days, the offset a literal or a numeric column of the same record). STRING is the form the platform itself produces: the date-macro resolver returns only strings ("`{current_year_start}`" -> "2026-01-01"), and the guaranteed spellings are an ISO calendar day (YYYY-MM-DD), a UTC ISO-8601 instant, or a wall-clock time of day (HH:MM[:SS[.fff]]) for a Field.time column. Those are ASCII and fixed-width, so lexicographic order IS chronological order and every backend agrees; the driver reconciles the comparand with the column (a bare calendar day used as an upper bound becomes the half-open next-day boundary). Ordering NON-temporal text is permitted but NOT promised: the order is the backend collation's (byte-wise on SQLite, the database locale on Postgres, UTF-16 code units in the JS matchers), and those coincide only for ASCII. null is NOT a comparand: null is not ordered — state absence with the null predicate instead ($eq: null is "has no value", $ne: null is "has a value"). | +| **$gte** | `number \| string \| { $field: string; addDays?: integer \| object } \| { $now: true; addDays: integer \| object }` | optional | Greater than or equal to. Comparand is a number, a Date, a string, a `{ $field }` reference (optionally carrying a whole-day addDays offset), or a `{ $now: true, addDays }` anchor (the current instant shifted by whole days, the offset a literal or a numeric column of the same record). STRING is the form the platform itself produces: the date-macro resolver returns only strings ("`{current_year_start}`" -> "2026-01-01"), and the guaranteed spellings are an ISO calendar day (YYYY-MM-DD), a UTC ISO-8601 instant, or a wall-clock time of day (HH:MM[:SS[.fff]]) for a Field.time column. Those are ASCII and fixed-width, so lexicographic order IS chronological order and every backend agrees; the driver reconciles the comparand with the column (a bare calendar day used as an upper bound becomes the half-open next-day boundary). Ordering NON-temporal text is permitted but NOT promised: the order is the backend collation's (byte-wise on SQLite, the database locale on Postgres, UTF-16 code units in the JS matchers), and those coincide only for ASCII. null is NOT a comparand: null is not ordered — state absence with the null predicate instead ($eq: null is "has no value", $ne: null is "has a value"). | +| **$lt** | `number \| string \| { $field: string; addDays?: integer \| object } \| { $now: true; addDays: integer \| object }` | optional | Less than. Comparand is a number, a Date, a string, a `{ $field }` reference (optionally carrying a whole-day addDays offset), or a `{ $now: true, addDays }` anchor (the current instant shifted by whole days, the offset a literal or a numeric column of the same record). STRING is the form the platform itself produces: the date-macro resolver returns only strings ("`{current_year_start}`" -> "2026-01-01"), and the guaranteed spellings are an ISO calendar day (YYYY-MM-DD), a UTC ISO-8601 instant, or a wall-clock time of day (HH:MM[:SS[.fff]]) for a Field.time column. Those are ASCII and fixed-width, so lexicographic order IS chronological order and every backend agrees; the driver reconciles the comparand with the column (a bare calendar day used as an upper bound becomes the half-open next-day boundary). Ordering NON-temporal text is permitted but NOT promised: the order is the backend collation's (byte-wise on SQLite, the database locale on Postgres, UTF-16 code units in the JS matchers), and those coincide only for ASCII. null is NOT a comparand: null is not ordered — state absence with the null predicate instead ($eq: null is "has no value", $ne: null is "has a value"). | +| **$lte** | `number \| string \| { $field: string; addDays?: integer \| object } \| { $now: true; addDays: integer \| object }` | optional | Less than or equal to. Comparand is a number, a Date, a string, a `{ $field }` reference (optionally carrying a whole-day addDays offset), or a `{ $now: true, addDays }` anchor (the current instant shifted by whole days, the offset a literal or a numeric column of the same record). STRING is the form the platform itself produces: the date-macro resolver returns only strings ("`{current_year_start}`" -> "2026-01-01"), and the guaranteed spellings are an ISO calendar day (YYYY-MM-DD), a UTC ISO-8601 instant, or a wall-clock time of day (HH:MM[:SS[.fff]]) for a Field.time column. Those are ASCII and fixed-width, so lexicographic order IS chronological order and every backend agrees; the driver reconciles the comparand with the column (a bare calendar day used as an upper bound becomes the half-open next-day boundary). Ordering NON-temporal text is permitted but NOT promised: the order is the backend collation's (byte-wise on SQLite, the database locale on Postgres, UTF-16 code units in the JS matchers), and those coincide only for ASCII. null is NOT a comparand: null is not ordered — state absence with the null predicate instead ($eq: null is "has no value", $ne: null is "has a value"). | +| **$in** | `any[]` | optional | Membership list. Members are literal values of any type the column stores. A `{ $field }` reference is NOT a member shape, nor is a `{ $now: true, addDays }` anchor: no backend resolves either inside a list — put it in a scalar comparison ($eq/$ne/$gt/$gte/$lt/$lte) instead. null is NOT a member shape either: state absence explicitly with the null predicate — "one of […] OR has no value" is `{ "$or": [{ "$in": […] }, { "$null": true }] }`. | +| **$nin** | `any[]` | optional | Membership list. Members are literal values of any type the column stores. A `{ $field }` reference is NOT a member shape, nor is a `{ $now: true, addDays }` anchor: no backend resolves either inside a list — put it in a scalar comparison ($eq/$ne/$gt/$gte/$lt/$lte) instead. null is NOT a member shape either: state absence explicitly with the null predicate — "one of […] OR has no value" is `{ "$or": [{ "$in": […] }, { "$null": true }] }`. | | **$between** | `[number \| string, number \| string]` | optional | Between (inclusive). Closed interval [min, max]. Each endpoint is a number, a Date, or a string, and BOTH are required NON-BLANK: an empty string, null and undefined are refused, and the refusal names the blank side. A range bounded on ONE side only is not a $between at all — write the side you have as a scalar comparison ($gte for a lower bound, $lte for an upper one). A `{ $field }` reference is NOT an endpoint shape: no backend resolves one inside a list — put it in a scalar comparison ($gt/$gte/$lt/$lte), which does compile to a column-to-column bound. STRING is the form the platform itself produces: the date-macro resolver walks INTO arrays, so `{ $between: ["{current_year_start}", "{current_year_end}"] }` resolves to two strings. The guaranteed spellings are an ISO calendar day (YYYY-MM-DD), a UTC ISO-8601 instant, or a wall-clock time of day (HH:MM[:SS[.fff]]) for a Field.time column; those are ASCII and fixed-width, so lexicographic order IS chronological order and every backend agrees. The driver reconciles each endpoint with the column independently (a bare calendar day used as the MAX becomes the half-open next-day boundary). Ranging over NON-temporal text is permitted but NOT promised: the order is the backend collation's, and those coincide only for ASCII. | | **$contains** | `string` | optional | Contains substring, CASE-SENSITIVELY — "acme" does NOT match "ACME". Lowered to `LIKE '%?%'` (case-exact) on the SQL family, and answered case-exactly on every JS evaluation face the platform ships. The comparand is matched LITERALLY: "%", "_" and regex metacharacters are ordinary characters, because this family escapes and anchors the comparand on the caller's behalf. To bind the wildcards yourself, write $like (case-exact) or $ilike (ASCII-folded). Case-INSENSITIVE containment is $icontains, which folds ASCII case only. | | **$notContains** | `string` | optional | Does not contain substring, CASE-SENSITIVELY — the negation of $contains, on the same comparand contract. Lowered to `NOT LIKE '%?%'` (case-exact) on the SQL family. The comparand is matched LITERALLY: "%", "_" and regex metacharacters are ordinary characters, because this family escapes and anchors the comparand on the caller's behalf. To bind the wildcards yourself, write $like (case-exact) or $ilike (ASCII-folded). | @@ -277,14 +349,14 @@ Type: `[FilterArray](#filterarray)[]` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **$eq** | `any` | optional | Equal to — the DEFAULT operator: a bare value written against a field key is the same condition as this one. Lowered to `=` on the SQL family and to `$eq` on MongoDB. The comparand is a literal, or a `{ $field }` reference to another column of the same table ($eq is one of the six scalar comparisons a reference may be the whole comparand of). `{ "$eq": null }` is the has-NO-value predicate — the same question `{ "$null": true }` asks, and it is about the VALUE, never about whether a key is present. | -| **$ne** | `any` | optional | Not equal to. Lowered to `<>` / `!=` on the SQL family and to `$ne` on MongoDB. The comparand is a literal, or a `{ $field }` reference to another column of the same table ($ne is one of the six scalar comparisons a reference may be the whole comparand of). `{ "$ne": null }` is the HAS-A-VALUE predicate — the same question `{ "$exists": true }` asks: it matches rows whose field holds a value, never rows that merely carry the key. | -| **$gt** | `number \| string \| { $field: string; addDays?: integer \| object }` | optional | Greater than. Comparand is a number, a Date, a string, or a `{ $field }` reference (optionally carrying a whole-day addDays offset). STRING is the form the platform itself produces: the date-macro resolver returns only strings ("`{current_year_start}`" -> "2026-01-01"), and the guaranteed spellings are an ISO calendar day (YYYY-MM-DD), a UTC ISO-8601 instant, or a wall-clock time of day (HH:MM[:SS[.fff]]) for a Field.time column. Those are ASCII and fixed-width, so lexicographic order IS chronological order and every backend agrees; the driver reconciles the comparand with the column (a bare calendar day used as an upper bound becomes the half-open next-day boundary). Ordering NON-temporal text is permitted but NOT promised: the order is the backend collation's (byte-wise on SQLite, the database locale on Postgres, UTF-16 code units in the JS matchers), and those coincide only for ASCII. null is NOT a comparand: null is not ordered — state absence with the null predicate instead ($eq: null is "has no value", $ne: null is "has a value"). | -| **$gte** | `number \| string \| { $field: string; addDays?: integer \| object }` | optional | Greater than or equal to. Comparand is a number, a Date, a string, or a `{ $field }` reference (optionally carrying a whole-day addDays offset). STRING is the form the platform itself produces: the date-macro resolver returns only strings ("`{current_year_start}`" -> "2026-01-01"), and the guaranteed spellings are an ISO calendar day (YYYY-MM-DD), a UTC ISO-8601 instant, or a wall-clock time of day (HH:MM[:SS[.fff]]) for a Field.time column. Those are ASCII and fixed-width, so lexicographic order IS chronological order and every backend agrees; the driver reconciles the comparand with the column (a bare calendar day used as an upper bound becomes the half-open next-day boundary). Ordering NON-temporal text is permitted but NOT promised: the order is the backend collation's (byte-wise on SQLite, the database locale on Postgres, UTF-16 code units in the JS matchers), and those coincide only for ASCII. null is NOT a comparand: null is not ordered — state absence with the null predicate instead ($eq: null is "has no value", $ne: null is "has a value"). | -| **$lt** | `number \| string \| { $field: string; addDays?: integer \| object }` | optional | Less than. Comparand is a number, a Date, a string, or a `{ $field }` reference (optionally carrying a whole-day addDays offset). STRING is the form the platform itself produces: the date-macro resolver returns only strings ("`{current_year_start}`" -> "2026-01-01"), and the guaranteed spellings are an ISO calendar day (YYYY-MM-DD), a UTC ISO-8601 instant, or a wall-clock time of day (HH:MM[:SS[.fff]]) for a Field.time column. Those are ASCII and fixed-width, so lexicographic order IS chronological order and every backend agrees; the driver reconciles the comparand with the column (a bare calendar day used as an upper bound becomes the half-open next-day boundary). Ordering NON-temporal text is permitted but NOT promised: the order is the backend collation's (byte-wise on SQLite, the database locale on Postgres, UTF-16 code units in the JS matchers), and those coincide only for ASCII. null is NOT a comparand: null is not ordered — state absence with the null predicate instead ($eq: null is "has no value", $ne: null is "has a value"). | -| **$lte** | `number \| string \| { $field: string; addDays?: integer \| object }` | optional | Less than or equal to. Comparand is a number, a Date, a string, or a `{ $field }` reference (optionally carrying a whole-day addDays offset). STRING is the form the platform itself produces: the date-macro resolver returns only strings ("`{current_year_start}`" -> "2026-01-01"), and the guaranteed spellings are an ISO calendar day (YYYY-MM-DD), a UTC ISO-8601 instant, or a wall-clock time of day (HH:MM[:SS[.fff]]) for a Field.time column. Those are ASCII and fixed-width, so lexicographic order IS chronological order and every backend agrees; the driver reconciles the comparand with the column (a bare calendar day used as an upper bound becomes the half-open next-day boundary). Ordering NON-temporal text is permitted but NOT promised: the order is the backend collation's (byte-wise on SQLite, the database locale on Postgres, UTF-16 code units in the JS matchers), and those coincide only for ASCII. null is NOT a comparand: null is not ordered — state absence with the null predicate instead ($eq: null is "has no value", $ne: null is "has a value"). | -| **$in** | `any[]` | optional | Membership list. Members are literal values of any type the column stores. A `{ $field }` reference is NOT a member shape: no backend resolves one inside a list — put it in a scalar comparison ($eq/$ne/$gt/$gte/$lt/$lte) instead. null is NOT a member shape either: state absence explicitly with the null predicate — "one of […] OR has no value" is `{ "$or": [{ "$in": […] }, { "$null": true }] }`. | -| **$nin** | `any[]` | optional | Membership list. Members are literal values of any type the column stores. A `{ $field }` reference is NOT a member shape: no backend resolves one inside a list — put it in a scalar comparison ($eq/$ne/$gt/$gte/$lt/$lte) instead. null is NOT a member shape either: state absence explicitly with the null predicate — "one of […] OR has no value" is `{ "$or": [{ "$in": […] }, { "$null": true }] }`. | +| **$eq** | `any` | optional | Equal to — the DEFAULT operator: a bare value written against a field key is the same condition as this one. Lowered to `=` on the SQL family and to `$eq` on MongoDB. The comparand is a literal, a `{ $field }` reference to another column of the same table, or a `{ $now: true, addDays }` anchor ($eq is one of the six scalar comparisons a reference or an anchor may be the whole comparand of). `{ "$eq": null }` is the has-NO-value predicate — the same question `{ "$null": true }` asks, and it is about the VALUE, never about whether a key is present. | +| **$ne** | `any` | optional | Not equal to. Lowered to `<>` / `!=` on the SQL family and to `$ne` on MongoDB. The comparand is a literal, a `{ $field }` reference to another column of the same table, or a `{ $now: true, addDays }` anchor ($ne is one of the six scalar comparisons a reference or an anchor may be the whole comparand of). `{ "$ne": null }` is the HAS-A-VALUE predicate — the same question `{ "$exists": true }` asks: it matches rows whose field holds a value, never rows that merely carry the key. | +| **$gt** | `number \| string \| { $field: string; addDays?: integer \| object } \| { $now: true; addDays: integer \| object }` | optional | Greater than. Comparand is a number, a Date, a string, a `{ $field }` reference (optionally carrying a whole-day addDays offset), or a `{ $now: true, addDays }` anchor (the current instant shifted by whole days, the offset a literal or a numeric column of the same record). STRING is the form the platform itself produces: the date-macro resolver returns only strings ("`{current_year_start}`" -> "2026-01-01"), and the guaranteed spellings are an ISO calendar day (YYYY-MM-DD), a UTC ISO-8601 instant, or a wall-clock time of day (HH:MM[:SS[.fff]]) for a Field.time column. Those are ASCII and fixed-width, so lexicographic order IS chronological order and every backend agrees; the driver reconciles the comparand with the column (a bare calendar day used as an upper bound becomes the half-open next-day boundary). Ordering NON-temporal text is permitted but NOT promised: the order is the backend collation's (byte-wise on SQLite, the database locale on Postgres, UTF-16 code units in the JS matchers), and those coincide only for ASCII. null is NOT a comparand: null is not ordered — state absence with the null predicate instead ($eq: null is "has no value", $ne: null is "has a value"). | +| **$gte** | `number \| string \| { $field: string; addDays?: integer \| object } \| { $now: true; addDays: integer \| object }` | optional | Greater than or equal to. Comparand is a number, a Date, a string, a `{ $field }` reference (optionally carrying a whole-day addDays offset), or a `{ $now: true, addDays }` anchor (the current instant shifted by whole days, the offset a literal or a numeric column of the same record). STRING is the form the platform itself produces: the date-macro resolver returns only strings ("`{current_year_start}`" -> "2026-01-01"), and the guaranteed spellings are an ISO calendar day (YYYY-MM-DD), a UTC ISO-8601 instant, or a wall-clock time of day (HH:MM[:SS[.fff]]) for a Field.time column. Those are ASCII and fixed-width, so lexicographic order IS chronological order and every backend agrees; the driver reconciles the comparand with the column (a bare calendar day used as an upper bound becomes the half-open next-day boundary). Ordering NON-temporal text is permitted but NOT promised: the order is the backend collation's (byte-wise on SQLite, the database locale on Postgres, UTF-16 code units in the JS matchers), and those coincide only for ASCII. null is NOT a comparand: null is not ordered — state absence with the null predicate instead ($eq: null is "has no value", $ne: null is "has a value"). | +| **$lt** | `number \| string \| { $field: string; addDays?: integer \| object } \| { $now: true; addDays: integer \| object }` | optional | Less than. Comparand is a number, a Date, a string, a `{ $field }` reference (optionally carrying a whole-day addDays offset), or a `{ $now: true, addDays }` anchor (the current instant shifted by whole days, the offset a literal or a numeric column of the same record). STRING is the form the platform itself produces: the date-macro resolver returns only strings ("`{current_year_start}`" -> "2026-01-01"), and the guaranteed spellings are an ISO calendar day (YYYY-MM-DD), a UTC ISO-8601 instant, or a wall-clock time of day (HH:MM[:SS[.fff]]) for a Field.time column. Those are ASCII and fixed-width, so lexicographic order IS chronological order and every backend agrees; the driver reconciles the comparand with the column (a bare calendar day used as an upper bound becomes the half-open next-day boundary). Ordering NON-temporal text is permitted but NOT promised: the order is the backend collation's (byte-wise on SQLite, the database locale on Postgres, UTF-16 code units in the JS matchers), and those coincide only for ASCII. null is NOT a comparand: null is not ordered — state absence with the null predicate instead ($eq: null is "has no value", $ne: null is "has a value"). | +| **$lte** | `number \| string \| { $field: string; addDays?: integer \| object } \| { $now: true; addDays: integer \| object }` | optional | Less than or equal to. Comparand is a number, a Date, a string, a `{ $field }` reference (optionally carrying a whole-day addDays offset), or a `{ $now: true, addDays }` anchor (the current instant shifted by whole days, the offset a literal or a numeric column of the same record). STRING is the form the platform itself produces: the date-macro resolver returns only strings ("`{current_year_start}`" -> "2026-01-01"), and the guaranteed spellings are an ISO calendar day (YYYY-MM-DD), a UTC ISO-8601 instant, or a wall-clock time of day (HH:MM[:SS[.fff]]) for a Field.time column. Those are ASCII and fixed-width, so lexicographic order IS chronological order and every backend agrees; the driver reconciles the comparand with the column (a bare calendar day used as an upper bound becomes the half-open next-day boundary). Ordering NON-temporal text is permitted but NOT promised: the order is the backend collation's (byte-wise on SQLite, the database locale on Postgres, UTF-16 code units in the JS matchers), and those coincide only for ASCII. null is NOT a comparand: null is not ordered — state absence with the null predicate instead ($eq: null is "has no value", $ne: null is "has a value"). | +| **$in** | `any[]` | optional | Membership list. Members are literal values of any type the column stores. A `{ $field }` reference is NOT a member shape, nor is a `{ $now: true, addDays }` anchor: no backend resolves either inside a list — put it in a scalar comparison ($eq/$ne/$gt/$gte/$lt/$lte) instead. null is NOT a member shape either: state absence explicitly with the null predicate — "one of […] OR has no value" is `{ "$or": [{ "$in": […] }, { "$null": true }] }`. | +| **$nin** | `any[]` | optional | Membership list. Members are literal values of any type the column stores. A `{ $field }` reference is NOT a member shape, nor is a `{ $now: true, addDays }` anchor: no backend resolves either inside a list — put it in a scalar comparison ($eq/$ne/$gt/$gte/$lt/$lte) instead. null is NOT a member shape either: state absence explicitly with the null predicate — "one of […] OR has no value" is `{ "$or": [{ "$in": […] }, { "$null": true }] }`. | | **$between** | `[number \| string, number \| string]` | optional | Between (inclusive). Closed interval [min, max]. Each endpoint is a number, a Date, or a string, and BOTH are required NON-BLANK: an empty string, null and undefined are refused, and the refusal names the blank side. A range bounded on ONE side only is not a $between at all — write the side you have as a scalar comparison ($gte for a lower bound, $lte for an upper one). A `{ $field }` reference is NOT an endpoint shape: no backend resolves one inside a list — put it in a scalar comparison ($gt/$gte/$lt/$lte), which does compile to a column-to-column bound. STRING is the form the platform itself produces: the date-macro resolver walks INTO arrays, so `{ $between: ["{current_year_start}", "{current_year_end}"] }` resolves to two strings. The guaranteed spellings are an ISO calendar day (YYYY-MM-DD), a UTC ISO-8601 instant, or a wall-clock time of day (HH:MM[:SS[.fff]]) for a Field.time column; those are ASCII and fixed-width, so lexicographic order IS chronological order and every backend agrees. The driver reconciles each endpoint with the column independently (a bare calendar day used as the MAX becomes the half-open next-day boundary). Ranging over NON-temporal text is permitted but NOT promised: the order is the backend collation's, and those coincide only for ASCII. | | **$contains** | `string` | optional | Contains substring, CASE-SENSITIVELY — "acme" does NOT match "ACME". Lowered to `LIKE '%?%'` (case-exact) on the SQL family, and answered case-exactly on every JS evaluation face the platform ships. The comparand is matched LITERALLY: "%", "_" and regex metacharacters are ordinary characters, because this family escapes and anchors the comparand on the caller's behalf. To bind the wildcards yourself, write $like (case-exact) or $ilike (ASCII-folded). Case-INSENSITIVE containment is $icontains, which folds ASCII case only. | | **$notContains** | `string` | optional | Does not contain substring, CASE-SENSITIVELY — the negation of $contains, on the same comparand contract. Lowered to `NOT LIKE '%?%'` (case-exact) on the SQL family. The comparand is matched LITERALLY: "%", "_" and regex metacharacters are ordinary characters, because this family escapes and anchors the comparand on the caller's behalf. To bind the wildcards yourself, write $like (case-exact) or $ilike (ASCII-folded). | @@ -298,6 +370,28 @@ Type: `[FilterArray](#filterarray)[]` | **$empty** | `boolean` | optional | Is-empty check by the field's DECLARED type. `true` matches rows whose field is empty, `false` is its exact complement. What counts as empty: text-like types (text, textarea, email, url, phone, password, secret, markdown, html, richtext, code, color, signature, qrcode) = null or '' (the empty string); multi-value types (multiselect, checkboxes, tags, and select, radio, lookup, user, file or image with multiple: true) = null or [] (the empty list); every other type = null only. A face that holds no field declaration judges by the value: null, '' and [] are empty. A face that answers by the declared type refuses the operator on a column whose declaration it does not hold (the built-in id, for one) rather than guess a row; use $null there for "has no value". The view operators is_empty / is_not_empty lower to this operator. | +--- + +## NowAnchor + +A now-anchored comparand: `{ $now: true, addDays: }` — the current instant shifted by whole days, the offset read from a numeric column of the same record or written as a literal. Legal exactly where a `{ $field }` reference is. + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **$now** | `true` | ✅ | The anchor flag: the instant the query runs at. Always the literal true. | +| **addDays** | `integer \| { $field: string }` | ✅ | Whole-day offset added to the current instant before comparing — REQUIRED: an integer literal of any sign (negative subtracts; whole days only), or a `{ $field }` reference to a numeric column of the same record. A NULL offset column contributes zero days. Against a datetime column the shifted instant keeps its time of day; against a date column it is read as its UTC calendar day. Compiles on SQL push-down on every dialect, with the offset column held to the same-table, declared, non-tenant, numeric rules; evaluates identically in memory. | + +### Nested Shape: `NowAnchor.addDays` + +A `{ $field }` reference to the numeric column holding the day offset + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **$field** | `string` | ✅ | Numeric column whose value is the number of days to add | + + --- ## QueryFilter @@ -328,8 +422,8 @@ Type: `[FilterArray](#filterarray)[]` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **$in** | `any[]` | optional | Membership list. Members are literal values of any type the column stores. A `{ $field }` reference is NOT a member shape: no backend resolves one inside a list — put it in a scalar comparison ($eq/$ne/$gt/$gte/$lt/$lte) instead. null is NOT a member shape either: state absence explicitly with the null predicate — "one of […] OR has no value" is `{ "$or": [{ "$in": […] }, { "$null": true }] }`. | -| **$nin** | `any[]` | optional | Membership list. Members are literal values of any type the column stores. A `{ $field }` reference is NOT a member shape: no backend resolves one inside a list — put it in a scalar comparison ($eq/$ne/$gt/$gte/$lt/$lte) instead. null is NOT a member shape either: state absence explicitly with the null predicate — "one of […] OR has no value" is `{ "$or": [{ "$in": […] }, { "$null": true }] }`. | +| **$in** | `any[]` | optional | Membership list. Members are literal values of any type the column stores. A `{ $field }` reference is NOT a member shape, nor is a `{ $now: true, addDays }` anchor: no backend resolves either inside a list — put it in a scalar comparison ($eq/$ne/$gt/$gte/$lt/$lte) instead. null is NOT a member shape either: state absence explicitly with the null predicate — "one of […] OR has no value" is `{ "$or": [{ "$in": […] }, { "$null": true }] }`. | +| **$nin** | `any[]` | optional | Membership list. Members are literal values of any type the column stores. A `{ $field }` reference is NOT a member shape, nor is a `{ $now: true, addDays }` anchor: no backend resolves either inside a list — put it in a scalar comparison ($eq/$ne/$gt/$gte/$lt/$lte) instead. null is NOT a member shape either: state absence explicitly with the null predicate — "one of […] OR has no value" is `{ "$or": [{ "$in": […] }, { "$null": true }] }`. | --- diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index 1767273fd9c..40b82fe8c8b 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -1,7 +1,7 @@ --- title: Protocol reference — every schema by module navTitle: Protocol Reference -description: Every schema published by @objectstack/spec — 1544 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1545 schemas across 14 protocol modules --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -23,7 +23,7 @@ counts are sums of the rows they head. Regenerate with | [AI Protocol](/docs/references/ai) | 12 | 68 | Agents, tools, skills, RAG and knowledge sources, model registry, conversations. | | [API Protocol](/docs/references/api) | 32 | 438 | REST contracts, endpoints, routing, realtime, batch, discovery. | | [Automation Protocol](/docs/references/automation) | 14 | 73 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. | -| [Data Protocol](/docs/references/data) | 30 | 180 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. | +| [Data Protocol](/docs/references/data) | 30 | 181 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. | | [Identity Protocol](/docs/references/identity) | 5 | 27 | Users and accounts, organizations, positions, SCIM provisioning. | | [Integration Protocol](/docs/references/integration) | 1 | 12 | The single connector protocol (ADR-0097) — catalog descriptors and provider-bound instances. | | [Kernel Protocol](/docs/references/kernel) | 29 | 151 | Plugin lifecycle and manifests, capabilities and security, metadata loading, service registry. | @@ -34,7 +34,7 @@ counts are sums of the rows they head. Regenerate with | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. | | [System Protocol](/docs/references/system) | 37 | 286 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | | [UI Protocol](/docs/references/ui) | 16 | 171 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **199** | **1544** | 14 protocol modules | +| **Total** | **199** | **1545** | 14 protocol modules | --- @@ -131,7 +131,7 @@ Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execu ## Data Protocol -**Source:** `packages/spec/src/data/` · **Import:** `@objectstack/spec/data` · **30 pages, 180 schemas** +**Source:** `packages/spec/src/data/` · **Import:** `@objectstack/spec/data` · **30 pages, 181 schemas** Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. @@ -157,7 +157,7 @@ Objects, fields, queries, filters, datasources and drivers — the ObjectQL laye | [`feed.zod.ts`](/docs/references/data/feed) | `FeedFilterMode`, `FeedItemType` | | [`field.zod.ts`](/docs/references/data/field) | `CurrencyConfig`, `CurrencyValue`, `Field`, `FieldMaskingKeep`, `FieldMaskingRule`, `FieldType`, `InlineGridColumn`, `LocationCoordinates`, `SelectOption`, `UniqueScope` | | [`field-value.zod.ts`](/docs/references/data/field-value) | `Address`, `AddressValue`, `CalendarDateValue`, `ClockTimeValue`, `FileLikeValue`, `FileReferenceIdValue`, `FileRefusedValue`, `FileValue`, `InstantValue`, `LocationValue`, `ReferenceIdValue` | -| [`filter.zod.ts`](/docs/references/data/filter) | `ComparisonOperator`, `EqualityOperator`, `FieldOperators`, `FieldReference`, `FilterArray`, `FilterCondition`, `NormalizedFilter`, `QueryFilter`, `RangeOperator`, `SetOperator`, `SpecialOperator`, `StringOperator` | +| [`filter.zod.ts`](/docs/references/data/filter) | `ComparisonOperator`, `EqualityOperator`, `FieldOperators`, `FieldReference`, `FilterArray`, `FilterCondition`, `NormalizedFilter`, `NowAnchor`, `QueryFilter`, `RangeOperator`, `SetOperator`, `SpecialOperator`, `StringOperator` | | [`hook.zod.ts`](/docs/references/data/hook) | `Hook`, `HookContext`, `HookEvent` | | [`hook-body.zod.ts`](/docs/references/data/hook-body) | `ExpressionBody`, `HookBody`, `HookBodyCapability`, `ScriptBody` | | [`mapping.zod.ts`](/docs/references/data/mapping) | `ImportFieldMapping`, `Mapping`, `TransformType` | diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/data.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/data.md index 3178141232e..cfd1e3160ef 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/data.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/data.md @@ -21,7 +21,7 @@ The `strict` column is the one the campaign schedules against; it counts both th | Dir | Sites | strict | passthrough | catchall | strip | |---|---|---|---|---|---| -| `data/` | 161 | 84 | 2 | 0 | 75 | +| `data/` | 162 | 85 | 2 | 0 | 75 | ## `data/` — sites @@ -46,7 +46,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit | `external-catalog.zod.ts` | 4 | | `field-value.zod.ts` | 4 | | `field.zod.ts` | 14 | -| `filter.zod.ts` | 12 | +| `filter.zod.ts` | 13 | | `hook-body.zod.ts` | 2 | | `hook.zod.ts` | 7 | | `mapping.zod.ts` | 5 | @@ -56,7 +56,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit | `seed-loader.zod.ts` | 12 | | `seed.zod.ts` | 2 | | `validation.zod.ts` | 6 | -| **total** | **161** | +| **total** | **162** | ## `data/` — open @@ -64,7 +64,7 @@ Per file, how many of its sites still silently discard unknown keys. The `Class` column that decides the bucket split is hand-written in the ledger; the arithmetic over it is here. -**75 strip of 161**, in 10 file(s). +**75 strip of 162**, in 10 file(s). | File | Strip | Sites | |---|---|---| @@ -74,11 +74,11 @@ over it is here. | `driver.zod.ts` | 9 | 9 | | `external-catalog.zod.ts` | 4 | 4 | | `field.zod.ts` | 2 | 14 | -| `filter.zod.ts` | 11 | 12 | +| `filter.zod.ts` | 11 | 13 | | `hook.zod.ts` | 5 | 7 | | `query.zod.ts` | 4 | 5 | | `seed-loader.zod.ts` | 12 | 12 | -| **total** | **75** | **161** | +| **total** | **75** | **162** | | Bucket | Sites | |---|---| diff --git a/packages/core/src/utils/filter-tokens.test.ts b/packages/core/src/utils/filter-tokens.test.ts index 3194306a098..04d07031700 100644 --- a/packages/core/src/utils/filter-tokens.test.ts +++ b/packages/core/src/utils/filter-tokens.test.ts @@ -153,6 +153,53 @@ describe('resolveFilterToken — context tokens', () => { * for (`FILTER_TOKEN_UNRESOLVED` / 400): the spelling is right, the context is * missing, and `token` names which context. */ +describe('[#25808] resolveFilterTokens — the `$now` anchor is pinned to the tree\'s instant', () => { + const ctx = filterTokenContextFrom({ userId: 'u1' } as any, NOW); + + it('`$now: true` becomes the pinned ISO instant; the offset is kept as written', () => { + const where = { opened_at: { $lt: { $now: true, addDays: { $field: 'sla_days' } } }, due_on: { $gte: { $now: true, addDays: -30 } } }; + expect(resolveFilterTokens(where, ctx)).toEqual({ + opened_at: { $lt: { $now: NOW.toISOString(), addDays: { $field: 'sla_days' } } }, + due_on: { $gte: { $now: NOW.toISOString(), addDays: -30 } }, + }); + }); + + it('the anchor and a placeholder in one tree resolve from ONE instant', () => { + const resolved = resolveFilterTokens({ $and: [{ a: { $gt: '{now}' } }, { b: { $lt: { $now: true, addDays: 0 } } }] }, ctx) as any; + expect(resolved.$and[0].a.$gt).toBe(NOW.toISOString()); + expect(resolved.$and[1].b.$lt.$now).toBe(NOW.toISOString()); + }); + + it('reaches an anchor under every combinator and in a list position', () => { + const resolved = resolveFilterTokens({ $or: [{ $not: { a: { $ne: { $now: true, addDays: 1 } } } }, { b: { $in: [{ $now: true, addDays: 2 }] } }] }, ctx) as any; + expect(resolved.$or[0].$not.a.$ne.$now).toBe(NOW.toISOString()); + expect(resolved.$or[1].b.$in[0].$now).toBe(NOW.toISOString()); + }); + + it('an already-pinned anchor is left alone, and a tree with no token returns by reference', () => { + const pinned = { a: { $lt: { $now: '2026-01-01T00:00:00.000Z', addDays: 1 } } }; + expect(resolveFilterTokens(pinned, ctx)).toBe(pinned); + const plain = { a: { $lt: 1 } }; + expect(resolveFilterTokens(plain, ctx)).toBe(plain); + }); + + it('an anchor without addDays is refused — INVALID_FILTER / 400, pointing at `{now}`', () => { + let err: any = null; + try { resolveFilterTokens({ a: { $lt: { $now: true } } }, ctx); } catch (e) { err = e; } + expect(err?.code).toBe('INVALID_FILTER'); + expect(err?.status).toBe(400); + expect(err?.message).toContain('{now}'); + }); + + it('a malformed offset is refused in addDays\' words', () => { + for (const bad of [1.5, '5', { days: 5 }, null]) { + let err: any = null; + try { resolveFilterTokens({ a: { $lt: { $now: true, addDays: bad } } }, ctx); } catch (e) { err = e; } + expect(err?.code, JSON.stringify(bad)).toBe('INVALID_FILTER'); + } + }); +}); + describe('resolveFilterTokens — the record-context token refuses by name', () => { // A FULL context on purpose: the refusal must not depend on what the // request happens to carry. diff --git a/packages/core/src/utils/filter-tokens.ts b/packages/core/src/utils/filter-tokens.ts index 0ffce57465d..7211feb2c60 100644 --- a/packages/core/src/utils/filter-tokens.ts +++ b/packages/core/src/utils/filter-tokens.ts @@ -20,6 +20,15 @@ * { owner: '{current_user_id}' } * → { owner: 'usr_7f3a…' } * + * # The `$now` anchor resolves here too + * + * [#25808] `{ $now: true, addDays: }` (`NowAnchorSchema`) + * is the current instant shifted by whole days, the days read from a numeric + * column of the same record or written as a literal. Its `$now: true` is a + * placeholder like `{now}` and is pinned here to the tree's one instant + * (`$now: '2026-10-26T15:41:25.000Z'`); the offset stays for the executor — + * see {@link isUnresolvedNowAnchor} for why the literal arm is not collapsed. + * * # Output form: ISO strings, not driver-native values * * Date tokens resolve to `YYYY-MM-DD` (or a full ISO timestamp for the @@ -510,11 +519,74 @@ function hasFilterToken(node: unknown): boolean { // is never a placeholder (see `resolveFilterTokens`). if (isReadableIds(node)) return false; if (node && typeof node === 'object' && !(node instanceof Date)) { + // [#25808] An unresolved `$now` anchor is a placeholder by construction. + if (isUnresolvedNowAnchor(node)) return true; return Object.values(node as Record).some(hasFilterToken); } return false; } +/** + * [#25808] `{ $now: true, addDays }` — the now-anchored comparand + * (`NowAnchorSchema`, `@objectstack/spec/data`) as an AUTHOR writes it. It is + * a placeholder for the instant the query runs at, and it resolves HERE, beside + * `{now}`, to the one instant the whole tree shares: `$now: true` becomes the + * pinned ISO instant, and the offset — a literal or a `{ $field }` column + * reference — stays as written for the executor, which adds it in the target + * column's own form (`driver-sql` per dialect, `@objectstack/formula` in + * memory). The anchor is not collapsed to a string even for a literal offset: + * whether "now minus 30 days" is an instant or a calendar day is the TARGET + * column's question, which this field-agnostic resolver cannot answer — the + * executor that reads the column's declaration shifts the instant in that + * column's form, so both paths answer the same rows. A node whose `$now` is + * already a string is resolved and passes through. + * + * `true` only — `$now: 'now'`, `$now: 1` are not anchors: left to the + * comparand-type door's plain-object refusal, where a malformed one belongs. + */ +function isUnresolvedNowAnchor(node: unknown): node is { $now: true; addDays?: unknown } { + return !!node && typeof node === 'object' && !Array.isArray(node) + && (node as Record).$now === true; +} + +/** + * [#25808] A `$now` anchor with no usable offset, met at resolution. The schema + * door refuses it at authoring in the same sentence; this is the runtime twin + * for a `where` no face re-validates (`find()` takes one), raised here rather + * than resolved to an instant the author never asked for. `INVALID_FILTER` / + * 400, the code every comparand door answers with. + */ +class MalformedNowAnchorError extends Error { + readonly status = 400; + readonly code = 'INVALID_FILTER' satisfies StandardErrorCode; + + constructor(detail: string) { + super( + `A { "$now": true } comparand ${detail}. A now-anchored comparand is ` + + '{ "$now": true, "addDays": }: the current instant shifted by whole days, where ' + + 'addDays is an integer literal of any sign (a negative value subtracts) or a nested ' + + '{ "$field": "numeric_column" } reference to a column of the same record. For the ' + + 'unshifted instant write the "{now}" placeholder.', + ); + this.name = 'MalformedNowAnchorError'; + } +} + +/** [#25808] The anchor with its instant pinned — see {@link isUnresolvedNowAnchor}. */ +function resolveNowAnchor(node: { $now: true; addDays?: unknown }, now: Date): Record { + const offset = node.addDays; + if (offset === undefined) throw new MalformedNowAnchorError('carries no addDays offset'); + const isColumn = !!offset && typeof offset === 'object' && !Array.isArray(offset) + && typeof (offset as Record).$field === 'string'; + if (typeof offset === 'number' ? !Number.isInteger(offset) : !isColumn) { + throw new MalformedNowAnchorError( + `carries an addDays offset that is neither an integer nor a { "$field" } reference (${JSON.stringify(offset)})`, + ); + } + // One instant for the whole tree, in the spelling `{now}` resolves to. + return { ...node, $now: now.toISOString() }; +} + /** * Deep-replace every fully-wrapped placeholder in `filter` with its resolved * value, returning a NEW tree (the caller's metadata is never mutated — a view @@ -558,6 +630,8 @@ export function resolveFilterTokens( if (node && typeof node === 'object') { // Dates and other class instances are comparands, not filter structure. if (node instanceof Date) return node; + // [#25808] The now anchor: its instant pinned, its offset kept. + if (isUnresolvedNowAnchor(node)) return resolveNowAnchor(node, pinned.now!); const out: Record = {}; for (const [k, v] of Object.entries(node as Record)) out[k] = walk(v); return out; diff --git a/packages/drivers/driver-mongodb/src/mongodb-field-reference-refusal.test.ts b/packages/drivers/driver-mongodb/src/mongodb-field-reference-refusal.test.ts index bf1acdf4b4a..4918da56fd3 100644 --- a/packages/drivers/driver-mongodb/src/mongodb-field-reference-refusal.test.ts +++ b/packages/drivers/driver-mongodb/src/mongodb-field-reference-refusal.test.ts @@ -143,6 +143,38 @@ describe('[#19949] every position a reference can reach is refused', () => { } }); +describe('[#25808] a now-anchored comparand is refused in every position a reference is', () => { + const NOW = { $now: true, addDays: { $field: 'n' } }; + const PINNED = { $now: '2026-03-03T12:00:00.000Z', addDays: -3 }; + const POSITIONS: Array<[string, Record]> = [ + ['$lt, the ruled shape (a column offset)', { t: { $lt: NOW } }], + ['$gte, pinned by the resolver, with a literal offset', { t: { $gte: PINNED } }], + ['$eq', { t: { $eq: NOW } }], + ['$ne', { t: { $ne: PINNED } }], + ['an $in member', { t: { $in: ['x', NOW] } }], + ['the $between upper endpoint', { t: { $between: ['a', PINNED] } }], + ['the bare form, with no operator', { t: NOW }], + ['a malformed anchor (no addDays)', { t: { $lt: { $now: true } } }], + ['inside $and', { $and: [{ id: 'r1' }, { t: { $lt: NOW } }] }], + ['inside $not (lowered to $nor)', { $not: { t: { $lt: PINNED } } }], + ]; + + for (const [label, where] of POSITIONS) { + it(`${label}: INVALID_FILTER / 400, the anchor refusal`, () => { + const err = refusalOf(where); + expect(err.code).toBe('INVALID_FILTER'); + expect(err.status).toBe(400); + expect(err.message).toContain('does not support a now-anchored comparand'); + // The ADR-0112 withholding: the field and the operator are not in the message. + expect(err.message).not.toMatch(/"t"|\$lt|\$gte/); + }); + } + + it('the pinned date macro the message points at still translates', () => { + expect(translateFilter({ t: { $lt: '2026-03-01T00:00:00.000Z' } })).toEqual({ t: { $lt: '2026-03-01T00:00:00.000Z' } }); + }); +}); + describe('[#19949] every neighbouring literal shape translates exactly as before', () => { it('literal $in / $nin / $between', () => { expect(translateFilter({ t: { $in: ['b'] } })).toEqual({ t: { $in: ['b'] } }); diff --git a/packages/drivers/driver-mongodb/src/mongodb-filter.ts b/packages/drivers/driver-mongodb/src/mongodb-filter.ts index c3b43c14be0..7012523c762 100644 --- a/packages/drivers/driver-mongodb/src/mongodb-filter.ts +++ b/packages/drivers/driver-mongodb/src/mongodb-filter.ts @@ -294,6 +294,9 @@ function classifyFilterKey(key: string, value: unknown, here: string): FilterVer // refusal whichever operator carries it. ⛔ Not implemented as a `$expr` // column comparison: the ruling on this driver is refuse, not lower. if (carriesFieldReference(value)) throw fieldReferenceUnsupportedError(); + // [#25808] …and a now-anchored comparand, for the same reason: translated, + // the anchor object would be a literal sub-document. + if (carriesNowAnchor(value)) throw nowAnchorUnsupportedError(); // [#5347] `$null`'s comparand is a boolean by declaration. Checked on THIS // walk rather than in the emitter's `$null` arm because the emitter is @@ -516,6 +519,33 @@ function holdsFieldReference(comparand: unknown): boolean { return Array.isArray(comparand) && comparand.some(isFieldReferenceShape); } +/** + * [#25808] A `{ $now: true, addDays }` now-anchored comparand (`NowAnchorSchema`, + * maintainer ruling 2026-10-26) — by SHAPE, an object carrying a `$now` key, + * as {@link isFieldReferenceShape} reads a reference; in every position + * {@link carriesFieldReference} reads, for the same reason. The engine's token + * resolver may already have pinned `$now` to an ISO string; the shape is the + * same either way, and this translator has no lowering for "the current + * instant plus a column's worth of days" in any spelling. + */ +function isNowAnchorShape(value: unknown): boolean { + return !!value && typeof value === 'object' && !Array.isArray(value) && '$now' in value; +} + +function carriesNowAnchor(spec: unknown): boolean { + if (holdsNowAnchor(spec)) return true; + if (!isFilterNode(spec)) return false; + for (const [op, comparand] of Object.entries(spec)) { + if (op.startsWith('$') && holdsNowAnchor(comparand)) return true; + } + return false; +} + +function holdsNowAnchor(comparand: unknown): boolean { + if (isNowAnchorShape(comparand)) return true; + return Array.isArray(comparand) && comparand.some(isNowAnchorShape); +} + /** * [#19949] A `{ $field }` cross-field reference reached this translator. * @@ -537,6 +567,25 @@ function fieldReferenceUnsupportedError(): Error { ); } +/** + * [#25808] A `{ $now, addDays }` now-anchored comparand reached this + * translator. Withheld as {@link fieldReferenceUnsupportedError} withholds, + * for its reason; the message names the feature and the remedy. The offset + * may be a column of the record (the ruled shape), which is exactly the + * column-to-value arithmetic this driver has no lowering for. + */ +function nowAnchorUnsupportedError(): Error { + return unsupportedFilterError( + 'The MongoDB driver does not support a now-anchored comparand ({ "$now": true, "addDays": … }): ' + + 'this filter compares a field against the current instant shifted by a number of days that may ' + + 'be held in another field of the same record, and the driver has no lowering for that ' + + 'arithmetic. It is refused rather than translated because MongoDB would read the anchor as a ' + + 'literal sub-document, so "$ne" would match every row and the orderings none. Compare against ' + + 'a literal instant, or the "{N_days_ago}" placeholder, instead. The field and the operator are ' + + 'withheld from this message because the filter may be an access policy the caller did not write.', + ); +} + /** * [#5347] `$null` whose comparand is not a boolean. * diff --git a/packages/drivers/driver-sql/src/cross-field-conformance-cases.ts b/packages/drivers/driver-sql/src/cross-field-conformance-cases.ts index 9b1b38fcfd4..6d90b3ecdc9 100644 --- a/packages/drivers/driver-sql/src/cross-field-conformance-cases.ts +++ b/packages/drivers/driver-sql/src/cross-field-conformance-cases.ts @@ -908,3 +908,262 @@ export const CROSS_FIELD_OFFSET_OPERAND_NAMES: readonly string[] = [ 'duty.grace_days', 'no_such_offset', ]; + +// ═══════════════════════════════════════════════════════════════════════════ +// [#25808] `{ $now: true, addDays }` — the current instant, shifted by whole days +// ═══════════════════════════════════════════════════════════════════════════ +// +// The ruling (2026-10-26, letter A): a now-anchored comparand whose whole-day +// offset is an integer literal or a NUMERIC column of the SAME record, so a +// dataset filter or measure can say "opened more than `review_sla_days` ago" +// where the SLA is a column of the row — `opened_at < now + sla_offset_days` +// (the column holds NEGATIVE days: the sign rules are `addDays`', and there is +// no `subDays`). Same fixture as the offset arm: the target is `completed`, +// the offset is `grace_days`, and `now` is PINNED to a fixed instant so the +// expectations are a table rather than a function of the wall clock. The +// engine's token resolver pins `$now` the same way (to the tree's instant, as +// an ISO string), so a pinned anchor is exactly what a driver sees in +// production; the one `$now: true` case below drives the own-clock arm with an +// offset no clock will reach. +// +// Two temporal pairs, as the offset arm has them, and `NOW` at the fixture's +// own time of day (12:00:00.000Z), so every generated case expects the SAME id +// set on the `date` pair and the `datetime` pair. The cases that pin WHICH form +// each class shifts in — a `date` column meets the instant's UTC calendar day, +// a `datetime` column keeps the time of day — are written out per pair with a +// NOW off the fixture's time of day, after the generated table. + +/** The pinned instant: the fixture's own time of day on 2026-03-03. */ +export const CROSS_FIELD_NOW = '2026-03-03T12:00:00.000Z'; + +/** `{ $now: NOW, addDays: offset }` — the resolved shape, as the engine hands it to a driver. */ +const anchored = (offset: unknown, at: string = CROSS_FIELD_NOW) => ({ $now: at, addDays: offset }); + +/** + * The anchor expectations, per operator and offset spelling, generated for + * BOTH temporal pairs. `NOW` is 03-03; `now + grace_days` per row: + * + * | id | completed | grace | now + grace | reading | + * |----|-----------|-------|-------------|-------------------------------------------| + * | 1 | 03-05 | 2 | 03-05 | on the last day of grace (equality) | + * | 2 | 03-03 | 2 | 03-05 | inside grace; completed today | + * | 3 | 03-01 | 0 | 03-03 | before today; zero grace | + * | 4 | 03-05 | NULL | 03-03 | NULL grace = zero days: after today | + * | 5 | NULL | 2 | 03-05 | never completed | + * | 6 | 03-05 | 2 | 03-05 | on the last day of grace (equality) | + * | 7 | NULL | NULL | 03-03 | nothing at all | + * | 8 | 03-10 | -3 | 02-28 | negative grace pulls the anchor back | + * | 9 | 02-27 | -3 | 02-28 | before the pulled-back anchor | + * + * The anchor is never NULL, so the only NULL on the table is the TARGET's: + * an ordering and `$eq` are false for rows 5 and 7, `$ne` is true for them + * (`$ne` against a value a row lacks holds, as it does against a literal), + * and `$not` of each takes exactly the complement. A NULL offset is zero days + * (row 4) — the `addDays` ruling, inherited. + */ +const NOW_EXPECTATIONS: ReadonlyArray<{ + name: string; + build: (target: string) => unknown; + expected: string[]; + note?: string; +}> = [ + { + name: '$lte with a COLUMN offset — completed <= now + grace_days', + build: (target) => ({ [target]: { $lte: anchored(GRACE) } }), + expected: ['1', '2', '3', '6', '9'], + note: 'Rows 1 and 6 land exactly on now + 2 (equality inside `<=`); row 4 has NULL grace and reads as zero days, so its 03-05 completion is after 03-03; rows 5 and 7 have no value; row 8 (03-10) is after 02-28.', + }, + { + name: '$gt with a COLUMN offset — the complement of the orderings, minus the NULL targets', + build: (target) => ({ [target]: { $gt: anchored(GRACE) } }), + expected: ['4', '8'], + }, + { + name: '$lt with a COLUMN offset — equality rows drop, NULL grace still reads as zero', + build: (target) => ({ [target]: { $lt: anchored(GRACE) } }), + expected: ['2', '3', '9'], + }, + { + name: '$lt with a NEGATIVE literal offset (-3) — the ruling\'s SLA shape, "more than three days ago"', + build: (target) => ({ [target]: { $lt: anchored(-3) } }), + expected: ['9'], + note: 'No `subDays`: a negative integer subtracts. 02-28 is the bound; only row 9 (02-27) is before it. The SLA held in a column is the same test with `{ $field }` naming a column that holds the negative days.', + }, + { + name: '$lte with a NEGATIVE literal offset (-1)', + build: (target) => ({ [target]: { $lte: anchored(-1) } }), + expected: ['3', '9'], + }, + { + name: '$gte with a literal offset of 0 — on or after now', + build: (target) => ({ [target]: { $gte: anchored(0) } }), + expected: ['1', '2', '4', '6', '8'], + note: 'Row 2 is completed at the pinned instant itself (equality inside `>=`).', + }, + { + name: '$eq with a literal offset of 0 — completed at the pinned instant', + build: (target) => ({ [target]: { $eq: anchored(0) } }), + expected: ['2'], + note: 'On the datetime pair this is instant equality (NOW is at the fixture\'s own 12:00:00.000Z); on the date pair it is calendar-day equality. Both pick row 2.', + }, + { + name: '$ne with a literal offset of 0 — every other row, the NULL targets included', + build: (target) => ({ [target]: { $ne: anchored(0) } }), + expected: ['1', '3', '4', '5', '6', '7', '8', '9'], + note: 'A row with no value is NOT equal to the anchor, as it is not equal to a literal (`!looseEq(null, v)` in memory; `(A is null or A <> Bx)` in SQL).', + }, + { + name: '$not of $lte with a COLUMN offset — exactly the complement, NULL targets re-admitted', + build: (target) => ({ $not: { [target]: { $lte: anchored(GRACE) } } }), + expected: ['4', '5', '7', '8'], + note: 'The predicate is total: `$not` re-admits the rows the ordering was false for, the NULL targets among them. If this set and the `$lte` set do not partition the fixture, one path left a NULL as UNKNOWN.', + }, + { + name: '$now: true — the own-clock arm, with an offset no clock reaches', + build: (target) => ({ [target]: { $lte: { $now: true, addDays: 36500 } } }), + expected: ['1', '2', '3', '4', '6', '8', '9'], + note: 'A caller that reaches the driver without the engine\'s resolver (a direct `find`, the RLS evaluator) carries `true`, and each path reads its own clock. A hundred years of offset makes the answer every non-NULL row until 2126; the NULL targets stay out.', + }, + { + name: 'an anchored comparison ANDs with a literal predicate on the offset column', + build: (target) => ({ [target]: { $lte: anchored(GRACE) }, grace_days: { $gt: 0 } }), + expected: ['1', '2', '6'], + }, + { + name: 'an anchored comparison two combinators deep, beside a reference comparison', + build: (target) => ({ + $and: [ + { $or: [{ [target]: { $gt: anchored(GRACE) } }, { [target]: null }] }, + { $not: { grace_days: null } }, + ], + }), + expected: ['5', '8'], + note: 'After now + grace (4, 8) or never completed (5, 7), minus the NULL-grace rows (4, 7).', + }, +]; + +/** + * The generated table, on both pairs — plus the per-class cells: a `NOW` + * at 18:30:00.000Z, OFF the fixture's time of day, so the two classes answer + * DIFFERENT sets, each pinned: the `date` column meets the instant's UTC + * calendar day (03-03), the `datetime` column the instant itself (row 2's + * 12:00:00.000Z is BEFORE it). + */ +export const CROSS_FIELD_NOW_CASES: readonly CrossFieldCase[] = [ + ...OFFSET_PAIRS.flatMap(({ label, target }) => + NOW_EXPECTATIONS.map(({ name, build, expected, note }) => ({ + name: `[$now] ${name} — on the ${label} pair (${target})`, + filter: build(target), + expected, + note, + })), + ), + { + name: '[$now] a `date` column meets the instant\'s UTC CALENDAR DAY — $gte now (18:30Z) + 0 reads as >= 03-03', + filter: { completed_on: { $gte: anchored(0, '2026-03-03T18:30:00.000Z') } }, + expected: ['1', '2', '4', '6', '8'], + note: 'Row 2 (03-03) is on the day, so it is in: the time of day is dropped, never rounded — the reading `{now}` takes on a `date` column.', + }, + { + name: '[$now] a `datetime` column keeps the TIME OF DAY — $gte now (18:30Z) + 0 leaves the 12:00Z row out', + filter: { completed_at: { $gte: anchored(0, '2026-03-03T18:30:00.000Z') } }, + expected: ['1', '4', '6', '8'], + note: 'Row 2 (03-03T12:00:00.000Z) is before 18:30Z, so it is out. The date-pair twin above has it in — that one-row difference IS the class rule.', + }, + { + name: '[$now] a `datetime` column, $lte now (18:30Z) + 0 — the 12:00Z row is in', + filter: { completed_at: { $lte: anchored(0, '2026-03-03T18:30:00.000Z') } }, + expected: ['2', '3', '9'], + }, + { + name: '[$now] a `date` column, $lte now (18:30Z) - 1 — a day arithmetic on the calendar day', + filter: { completed_on: { $lte: anchored(-1, '2026-03-03T18:30:00.000Z') } }, + expected: ['3', '9'], + }, +]; + +/** + * The anchor refusal arm, {@link CROSS_FIELD_OFFSET_REFUSALS}' contract: every + * entry throws `INVALID_FILTER` / 400 on both SQL drivers, operands withheld + * from the caller, the naming half in the server log. The anchor's own rule — + * the TARGET is a `date` or `datetime` column, because the anchor is an + * instant — and the offset column's, which are the `addDays` arm's (declared, + * numeric, same-table, never the tenant-isolation column), plus the shape's + * own declaration (`$now` is `true` or the pinned instant; `addDays` is + * required and an integer or a `{ $field }`), and the positions outside the + * six scalar comparisons. + */ +export const CROSS_FIELD_NOW_REFUSALS: readonly CrossFieldRefusalCase[] = [ + { + name: '[$now] a NUMERIC target is refused — the anchor is an instant', + filter: { amount: { $gt: anchored(1) } }, + diagnosticIncludes: ['now-anchored', 'date or datetime'], + }, + { + name: '[$now] a TIME target is refused — a wall clock is no instant', + filter: { start_time: { $lte: anchored(1) } }, + diagnosticIncludes: ['now-anchored', 'date or datetime'], + }, + { + name: '[$now] a TEXT target is refused', + filter: { title: { $lte: anchored(1) } }, + diagnosticIncludes: ['now-anchored', 'date or datetime'], + }, + { + name: '[$now] a TEXT offset column is refused', + filter: { completed_on: { $lte: anchored({ $field: 'title' }) } }, + diagnosticIncludes: ['addDays', 'not a numeric column'], + }, + { + name: '[$now] a DOTTED offset path is refused — same-table columns only, on the offset too', + filter: { completed_on: { $lte: anchored({ $field: 'duty.grace_days' }) } }, + diagnosticIncludes: ['addDays', 'dotted path'], + note: 'The 2026-08-06 same-table ruling, inherited by the anchor\'s offset as the `addDays` pair\'s inherits it. The ruling\'s title said "held on a related record"; the shape that compiles is the SLA propagated onto the row.', + }, + { + name: '[$now] an undeclared offset column is refused at compile time', + filter: { completed_on: { $lte: anchored({ $field: 'no_such_offset' }) } }, + diagnosticIncludes: ['addDays', 'not a declared field'], + }, + { + name: '[$now] the tenant-isolation column is refused as the offset', + filter: { completed_on: { $lte: anchored({ $field: 'organization_id' }) } }, + diagnosticIncludes: ['tenant-isolation column'], + }, + { + name: '[$now] a fractional literal is refused — whole days only', + filter: { completed_on: { $lte: anchored(1.5) } }, + diagnosticIncludes: ['addDays', 'not an integer'], + }, + { + name: '[$now] a string literal is refused — a number is a number', + filter: { completed_on: { $lte: anchored('5') } }, + diagnosticIncludes: ['addDays', 'neither an integer'], + }, + { + name: '[$now] a MISSING addDays is refused — `{now}` is the spelling for the unshifted instant', + filter: { completed_on: { $lte: { $now: CROSS_FIELD_NOW } } }, + diagnosticIncludes: ['addDays offset', '{now}'], + }, + { + name: '[$now] a $now that is neither true nor an instant is refused', + filter: { completed_on: { $lte: { $now: 'soon', addDays: 1 } } }, + diagnosticIncludes: ['not an instant'], + }, + { + name: '[$now] a $now of false is refused — the flag is the literal true', + filter: { completed_on: { $lte: { $now: false, addDays: 1 } } }, + diagnosticIncludes: ['literal true'], + }, + { + name: '[$now] an anchor as an $in MEMBER is refused by position', + filter: { completed_on: { $in: [anchored(1)] } }, + diagnosticIncludes: ['now-anchored', 'position SQL push-down does not compile'], + }, + { + name: '[$now] the bare `{ field: { $now, addDays } }` spelling is refused by position', + filter: { completed_on: anchored(1) }, + diagnosticIncludes: ['now-anchored', 'position SQL push-down does not compile'], + note: 'The bare form reaches the operator loop as an operator named `$now`, as the bare `{ $field }` does; the spelling that compiles is `{ "$eq": { "$now": … } }`.', + }, +]; diff --git a/packages/drivers/driver-sql/src/index.ts b/packages/drivers/driver-sql/src/index.ts index 0203010133c..b24a98a0fe8 100644 --- a/packages/drivers/driver-sql/src/index.ts +++ b/packages/drivers/driver-sql/src/index.ts @@ -79,6 +79,10 @@ export { CROSS_FIELD_OFFSET_OPERAND_NAMES, CROSS_FIELD_OFFSET_REFUSALS, CROSS_FIELD_OFFSET_ROWS, + // [#25808] The `{ $now, addDays }` anchor arm — on the offset fixture. + CROSS_FIELD_NOW, + CROSS_FIELD_NOW_CASES, + CROSS_FIELD_NOW_REFUSALS, } from './cross-field-conformance-cases.js'; export type { CrossFieldAuthoredCase, diff --git a/packages/drivers/driver-sql/src/sql-driver-compile-refusal-seam.test.ts b/packages/drivers/driver-sql/src/sql-driver-compile-refusal-seam.test.ts index 00ea7eff0a5..dac8d786793 100644 --- a/packages/drivers/driver-sql/src/sql-driver-compile-refusal-seam.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-compile-refusal-seam.test.ts @@ -174,6 +174,20 @@ const DOORS: readonly Door[] = [ secrets: [POLICY_COL, 'secret_undeclared_ref'], klass: 'cannot be compiled here', }, + // ── #25808: the now-anchored comparand's two doors ───────────────────────── + { + builder: 'nowAnchorPositionError', + where: () => ({ [POLICY_COL]: { $contains: { $now: true, addDays: 1 } } }), + secrets: [POLICY_COL, '$contains'], + klass: 'A now-anchored comparand', + authorExtendsLog: true, + }, + { + builder: 'uncompilableNowAnchorError', + where: () => ({ [POLICY_COL]: { $gt: { $now: true, addDays: { $field: 'secret_undeclared_ref' } } } }), + secrets: [POLICY_COL], + klass: 'A now-anchored comparison', + }, { builder: 'jsonColumnOperatorError', where: () => ({ [JSON_COL]: { $in: [SECRET] } }), diff --git a/packages/drivers/driver-sql/src/sql-driver-cross-field-conformance.test.ts b/packages/drivers/driver-sql/src/sql-driver-cross-field-conformance.test.ts index c8381450335..1005574bfb8 100644 --- a/packages/drivers/driver-sql/src/sql-driver-cross-field-conformance.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-cross-field-conformance.test.ts @@ -60,6 +60,8 @@ import { DIALECT_CELLS, declareUnprovisionedCell, type DialectCell } from './liv import { CROSS_FIELD_AUTHORED_CASES, CROSS_FIELD_CASES, + CROSS_FIELD_NOW_CASES, + CROSS_FIELD_NOW_REFUSALS, CROSS_FIELD_OBJECT_FIELDS, CROSS_FIELD_OFFSET_CASES, CROSS_FIELD_OFFSET_OBJECT_FIELDS, @@ -315,6 +317,90 @@ describe(`[#14104] driver-sql — \`addDays\` offset push-down conformance (${ce }); } +/** + * [#25808] The `{ $now: true, addDays }` anchor, on the offset fixture: the + * same two-path agreement the offset sweep pins, with `$now` pinned to the + * corpus's instant on both paths (the evaluator reads the pinned ISO string + * exactly as the compiler binds it), and the anchor refusal arm under the + * offset arm's withholding contract. + */ +function declareCrossFieldNowSweep(cell: DialectCell): void { +describe(`[#25808] driver-sql — \`{ $now, addDays }\` anchor push-down conformance (${cell.label})`, () => { + let driver: SqlDriver; + let records: Array>; + + beforeAll(async () => { + driver = new SqlDriver(cell.config()); + await driver.execute(`drop table if exists ${OFFSET_TABLE}`).catch(() => {}); + await driver.initObjects([{ name: OFFSET_TABLE, fields: CROSS_FIELD_OFFSET_OBJECT_FIELDS } as any]); + for (const row of CROSS_FIELD_OFFSET_ROWS) await driver.create(OFFSET_TABLE, { ...row }); + records = (await driver.find(OFFSET_TABLE, {})) as Array>; + }); + + afterAll(async () => { + await driver?.execute(`drop table if exists ${OFFSET_TABLE}`).catch(() => {}); + await driver?.disconnect?.(); + }); + + const sqlIds = async (filter: unknown): Promise => { + const rows = await driver.find(OFFSET_TABLE, { + fields: ['id'], + where: filter as FilterCondition, + }); + return rows.map((r: any) => String(r.id)).sort(); + }; + + const memoryIds = (filter: unknown): string[] => + records + .filter((r) => matchesFilterCondition(r, filter as FilterCondition)) + .map((r) => String(r.id)) + .sort(); + + for (const testCase of CROSS_FIELD_NOW_CASES) { + it(`${testCase.name} — same rows on both paths`, async () => { + const expected = [...testCase.expected].sort(); + const note = testCase.note ? `\n${testCase.note}` : ''; + expect(memoryIds(testCase.filter), `in-memory evaluator disagreed${note}`).toEqual(expected); + expect(await sqlIds(testCase.filter), `SQL push-down disagreed${note}`).toEqual(expected); + }); + } + + describe('the anchor refusal arm (ADR-0112 envelope, operands withheld)', () => { + for (const refusal of CROSS_FIELD_NOW_REFUSALS) { + it(`${refusal.name} → 400 INVALID_FILTER`, async () => { + const logged: string[] = []; + const restore = (driver as unknown as { logger: { warn: (m: string) => void } }).logger; + (driver as unknown as { logger: unknown }).logger = { + ...restore, + warn: (m: string) => { logged.push(m); }, + }; + let error: (Error & { code?: string; status?: number }) | null = null; + try { + await sqlIds(refusal.filter); + } catch (e) { + error = e as Error & { code?: string; status?: number }; + } finally { + (driver as unknown as { logger: unknown }).logger = restore; + } + expect(error, `expected a refusal${refusal.note ? `\n${refusal.note}` : ''}`).not.toBeNull(); + expect(error!.code).toBe('INVALID_FILTER'); + expect(error!.status).toBe(400); + expect(error!).not.toBeInstanceOf(TypeError); + expect(error!.message).not.toContain('can only bind'); + expect(error!.message).not.toContain('[sql-driver]'); + for (const name of CROSS_FIELD_OFFSET_OPERAND_NAMES) { + expect(error!.message, `caller-visible message names "${name}"`).not.toContain(name); + } + const diagnostic = logged.join('\n'); + for (const fragment of refusal.diagnosticIncludes) { + expect(diagnostic, `server log lost "${fragment}"`).toContain(fragment); + } + }); + } + }); +}); +} + // ── The driver axis ───────────────────────────────────────────────────────── for (const cell of DIALECT_CELLS) { @@ -328,4 +414,5 @@ for (const cell of DIALECT_CELLS) { } declareCrossFieldSweep(cell); declareCrossFieldOffsetSweep(cell); + declareCrossFieldNowSweep(cell); } diff --git a/packages/drivers/driver-sql/src/sql-driver.ts b/packages/drivers/driver-sql/src/sql-driver.ts index 1c74db86746..754fd611cb3 100644 --- a/packages/drivers/driver-sql/src/sql-driver.ts +++ b/packages/drivers/driver-sql/src/sql-driver.ts @@ -2886,6 +2886,92 @@ function fieldReferenceOffsetOf(value: unknown): CrossFieldOffset | null { }; } +/** + * [#25808] A `{ $now, addDays }` now-anchored comparand — the current instant + * shifted by whole days, compared against a temporal column of the row + * (`NowAnchorSchema`, `data/filter.zod.ts`; maintainer ruling 2026-10-26, + * letter A). Recognised by SHAPE — an object carrying a `$now` key — the way + * {@link fieldReferenceOf} recognises a reference, and for the same reason: the + * evaluator (`@objectstack/formula`'s `resolveValue`) reads the same shape, so + * the two paths agree on what an anchor IS and a malformed one is refused here + * rather than bound as a literal object. + * + * `$now` arrives in two spellings. `true` is the AUTHORED form; the engine's + * token resolver (`resolveFilterTokens`, `@objectstack/core`) pins it to the + * tree's instant — the ISO string of the same `now` every `{N_days_ago}` + * token in the tree resolved from — before any driver runs. A caller that + * reaches this driver without that seam (a direct `find`, a permission filter + * assembled in code) still carries `true`, and the driver reads its own clock + * for it, as the evaluator does. Anything else is `invalid`. + */ +type NowAnchor = { kind: 'instant'; at: Date } | { kind: 'invalid'; reason: string }; + +function nowAnchorOf(value: unknown): NowAnchor | null { + if (!value || typeof value !== 'object' || Array.isArray(value) || value instanceof Date) return null; + if (!('$now' in value)) return null; + const raw = (value as Record).$now; + if (raw === true) return { kind: 'instant', at: new Date() }; + if (typeof raw === 'string') { + const ms = Date.parse(raw); + if (Number.isFinite(ms)) return { kind: 'instant', at: new Date(ms) }; + return { kind: 'invalid', reason: `$now ${JSON.stringify(raw)} is not an instant the resolver could have pinned` }; + } + return { + kind: 'invalid', + reason: `$now must be the literal true (the resolver pins it to the current instant), not ${JSON.stringify(raw)}`, + }; +} + +/** + * [#25808] A `{ $now }` anchor reached a position this driver does not compile: + * a list member, a text pattern, the bare `{ field: { $now: true } }` spelling + * (which reaches the operator loop as an operator named `$now`). The same + * boundary as {@link crossFieldComparisonError}, for the same reason, with the + * same withholding (#7929): the anchor compiles only as the WHOLE comparand of + * one of the six scalar comparisons, where {@link SqlDriver.applyNowAnchorComparison} + * answers it. + */ +function nowAnchorPositionError(field: string, op: string, subtree?: unknown, index?: number): Error { + const position = index === undefined ? '' : ` at index ${index} of its value list`; + const diagnostic = + `Operator "${op}" on field "${field}" compares against a now-anchored comparand ` + + `({ "$now": true, "addDays": … })${position}, a position SQL push-down does not compile.`; + return withheldFilterError( + `A now-anchored comparand ({ "$now": true, "addDays": … }) in this filter sits in a position SQL ` + + `push-down does not compile. It compiles only as the whole comparand of a scalar comparison ` + + `operator ($eq/$ne/$gt/$gte/$lt/$lte) on a date or datetime column. Compare against a literal ` + + `value here, or write the test as one of those comparisons. The field and the operator this ` + + `filter used are withheld from the message; the full diagnostic is in the server log.`, + diagnostic, + subtree, + `${diagnostic} It compiles only as the whole comparand of a scalar comparison operator ` + + `($eq/$ne/$gt/$gte/$lt/$lte) on a date or datetime column.`, + ); +} + +/** + * [#25808] A `{ $now }` anchor in a scalar-comparison position this driver + * cannot compile for the stated reason — the target column is not temporal, the + * offset column breaks one of the #5222 rulings, the anchor is malformed. The + * caller-visible message states the capability boundary and names nothing + * (#7929); the diagnostic goes to the server log through the same withhold + * {@link uncompilableFieldReferenceError} uses. + */ +function uncompilableNowAnchorError(field: string, op: string, reason: string, subtree?: unknown): Error { + const diagnostic = + `Operator "${op}" on field "${field}" compares against a now-anchored comparand ` + + `({ "$now": true, "addDays": … }), which cannot be compiled here: ${reason}`; + return withheldFilterError( + `A now-anchored comparison ({ "$now": true, "addDays": … }) in this filter cannot be compiled ` + + `here. On SQL push-down the anchor compares against a date or datetime column the object ` + + `declares, and its addDays offset is an integer or a same-table numeric column the object ` + + `declares, excluding the tenant-isolation column. The field, the operator this filter used ` + + `and the specific reason are withheld from the message; the full diagnostic is in the server log.`, + diagnostic, + subtree, + ); +} + /** * [#5041→#5222] `{ $field }` reached a position this driver does NOT compile to * a column-to-column comparison. @@ -3324,6 +3410,8 @@ function assertCompilableComparand( ): void { const ref = fieldReferenceOf(value); if (ref !== null) throw crossFieldComparisonError(field, op, ref, value); + // [#25808] …and a now-anchored comparand, outside the six comparisons. + if (nowAnchorOf(value) !== null) throw nowAnchorPositionError(field, op, value); // [#5234] The pattern family answers first: an array IS an object here, so // the member scan below would otherwise report `{$contains: ['a', {}]}` as a @@ -3338,6 +3426,7 @@ function assertCompilableComparand( for (const [index, member] of value.entries()) { const memberRef = fieldReferenceOf(member); if (memberRef !== null) throw crossFieldComparisonError(field, op, memberRef, member, index); + if (nowAnchorOf(member) !== null) throw nowAnchorPositionError(field, op, member, index); // [#5234] Every member of a list operator's array is a comparand in its // own right and gets the same bind test the whole comparand gets. Scoped // to the operators for which an array is legitimate, so a scalar operator @@ -4876,6 +4965,8 @@ function classifyFilterKey( if (CROSS_FIELD_COMPARISON_OPERATORS.has(op)) continue; const ref = fieldReferenceOf(comparand); if (ref !== null) throw crossFieldComparisonError(key, op, ref, comparand); + // [#25808] A now-anchored comparand under any other operator, likewise. + if (nowAnchorOf(comparand) !== null) throw nowAnchorPositionError(key, op, comparand); } } @@ -17606,6 +17697,18 @@ export class SqlDriver implements IDataDriver { this.applyCrossFieldComparison(builder, method, table, key, localField, field, rawOp, crossFieldRef, opValue); continue; } + // [#25808] The bare `{ field: { $now: true, addDays } }` spelling reaches + // this loop as an operator named `$now`, as the bare `$field` does. + if (rawOp === '$now') throw nowAnchorPositionError(field, rawOp, value); + // [#25808] A `{ $now, addDays }` anchor that is the WHOLE comparand of a + // scalar comparison compiles to `column ` (or + // refuses inside the emitter, with the boundary named). Same slot as + // the `{ $field }` arm above, for the same reasons. + const nowAnchor = nowAnchorOf(opValue); + if (nowAnchor !== null && CROSS_FIELD_COMPARISON_OPERATORS.has(rawOp)) { + this.applyNowAnchorComparison(builder, method, table, key, localField, field, rawOp, nowAnchor, opValue); + continue; + } // #5041 — reject a comparand that cannot become a bind parameter // BEFORE any rewrite or coercion touches it, so the message names the // shape the caller actually sent. @@ -18137,6 +18240,182 @@ export class SqlDriver implements IDataDriver { } } + /** + * [#25808] `column (now + days)` — a now-anchored comparand on a + * temporal column, maintainer ruling 2026-10-26 (letter A): the current + * instant, shifted by a whole-day offset read from an integer literal or a + * NUMERIC column of the SAME record, compared against a `date` or `datetime` + * column. The analytics shape it was ruled for: "opened more than + * `review_sla_days` ago", where the SLA is a column of the row and the row's + * own clock is `now`. + * + * The arithmetic is {@link SqlDriver.crossFieldOffsetExpr}'s — the `addDays` + * arm of {@link SqlDriver.applyCrossFieldComparison}, with the anchor's + * instant bound where the referenced column would be read. The offset column + * is held to every rule that arm holds it to (same-table, declared, numeric, + * never the tenant-isolation column; a NULL offset is zero days through the + * same `coalesce`), so an offset that compiles there compiles here and no + * other does. What differs is the base: it is a VALUE, never NULL, so the + * ` is not null` conjunct the reference arm writes is dropped and the + * predicate reads as the plain literal comparison would — an ordering and + * `$eq` need the target to have a value, `$ne` holds when it has none or + * differs (`@objectstack/formula`'s `evalOp` over the resolved anchor, + * made total so `$not` re-admits exactly the complement). + * + * The instant is bound in the TARGET column's storage form, so the shifted + * value compares against the column exactly as a stored value would — the + * rule every literal comparand on a temporal column follows + * (`coerceFilterValue`): a `datetime` column keeps the time of day (the + * canonical UTC ISO text, through `mysqlDatetimeLiteral` on MySQL); a `date` + * column takes the instant's UTC calendar day (`temporalStorageForm`, the + * reading `{now}` and `{today}` take on a `date` column). PostgreSQL and + * MySQL cast the bound text (`timestamptz` / `datetime(3)`, `date`) because + * the dialect's day arithmetic needs a typed operand where a bare `?` beside + * a column would have been inferred; SQLite's `date()` / `strftime()` read + * the canonical text directly, as they read a stored value. + */ + protected applyNowAnchorComparison( + builder: Knex.QueryBuilder, + method: 'where' | 'orWhere', + table: string | null, + targetKey: string, + targetLocalField: string, + targetColumn: string, + op: string, + anchor: NowAnchor, + // The `{ $now, addDays }` node itself, for the provenance resolver (#8220). + node?: unknown, + ): void { + if (anchor.kind === 'invalid') { + throw uncompilableNowAnchorError(targetColumn, op, `${anchor.reason}.`, node); + } + const offset = fieldReferenceOffsetOf(node); + if (offset === null) { + throw uncompilableNowAnchorError(targetColumn, op, + `a { "$now": true } comparand must carry an addDays offset — an integer literal of any sign ` + + `or a { "$field" } reference to a numeric column; for the unshifted instant write the "{now}" ` + + `placeholder.`, node); + } + if (offset.kind === 'invalid') { + throw uncompilableNowAnchorError(targetColumn, op, + `${offset.reason}; addDays is an integer literal of any sign or a { "$field" } reference ` + + `to a numeric column.`, node); + } + if (!table) { + throw uncompilableNowAnchorError(targetColumn, op, + `the target table of this query could not be resolved, so the target column's type ` + + `cannot be read.`, node); + } + const declared = this.declaredFieldsFor(table); + if (!declared) { + throw uncompilableNowAnchorError(targetColumn, op, + `object "${table}" has no declared column set on this driver (an external/federated ` + + `or unregistered table), so the target column's type cannot be read.`, node); + } + const hasOwn = (name: string) => Object.prototype.hasOwnProperty.call(declared, name); + const tenantField = this.resolveTenantField(table); + if (tenantField !== null && (targetKey === tenantField || targetLocalField === tenantField)) { + throw uncompilableNowAnchorError(targetColumn, op, + `"${tenantField}" is the tenant-isolation column of "${table}", which must not be the ` + + `target of a now-anchored comparison.`, node); + } + const targetDeclaredName = hasOwn(targetKey) ? targetKey : targetLocalField; + if (!hasOwn(targetDeclaredName)) { + throw uncompilableNowAnchorError(targetColumn, op, + `the target field "${targetKey}" is not a declared field of "${table}", so its type ` + + `cannot be checked as temporal.`, node); + } + const targetClass = crossFieldComparisonClass(declared[targetDeclaredName] ?? {}); + if (targetClass !== 'date' && targetClass !== 'datetime') { + throw uncompilableNowAnchorError(targetColumn, op, + `a now-anchored comparand is an instant shifted by whole days, and the target field ` + + `"${targetKey}" (type "${String(declared[targetDeclaredName]?.type ?? 'string')}"` + + `${declared[targetDeclaredName]?.multiple ? ', multiple' : ''}) is stored as ` + + `${targetClass ?? 'no scalar column'} — only a date or datetime column can be compared ` + + `against it.`, node); + } + + let offsetOperand: { sql: string; bindings: any[] }; + if (offset.kind === 'literal') { + offsetOperand = { sql: '?', bindings: [offset.days] }; + } else { + const offsetRef = offset.ref; + if (offsetRef.includes('.')) { + throw uncompilableNowAnchorError(targetColumn, op, + `the addDays offset "${offsetRef}" is a dotted path, and SQL push-down compiles ` + + `same-table column references only (no relation traversal, no alias-qualified columns).`, + node); + } + if (tenantField !== null && offsetRef === tenantField) { + throw uncompilableNowAnchorError(targetColumn, op, + `"${tenantField}" is the tenant-isolation column of "${table}", which must not appear ` + + `in a cross-field comparison — not as the addDays offset either.`, node); + } + const offsetDeclaredName = hasOwn(offsetRef) ? offsetRef : this.mapSortField(offsetRef); + if (!hasOwn(offsetDeclaredName)) { + throw uncompilableNowAnchorError(targetColumn, op, + `the addDays offset "${offsetRef}" is not a declared field of "${table}" — only ` + + `declared fields can be referenced.`, node); + } + const offsetClass = crossFieldComparisonClass(declared[offsetDeclaredName] ?? {}); + if (offsetClass !== 'numeric') { + throw uncompilableNowAnchorError(targetColumn, op, + `the addDays offset "${offsetRef}" (type ` + + `"${String(declared[offsetDeclaredName]?.type ?? 'string')}"` + + `${declared[offsetDeclaredName]?.multiple ? ', multiple' : ''}) is not a numeric ` + + `column, and a day offset must be a number of days.`, node); + } + const offsetLocal = this.mapSortField(offsetRef); + offsetOperand = { sql: '??', bindings: [this.remoteColumn(table, offsetRef, offsetLocal)] }; + } + + const lhs = this.filterColumnExpr(table, targetLocalField, targetColumn) + ?? { sql: '??', bindings: [targetColumn] }; + const base = this.nowAnchorBaseExpr(anchor.at, targetClass); + const shifted = this.crossFieldOffsetExpr(base, offsetOperand, targetClass); + const raw = method === 'orWhere' ? 'orWhereRaw' : 'whereRaw'; + const A = lhs.sql; + const Bx = shifted.sql; + switch (op) { + case '$eq': + (builder as any)[raw](`(${A} is not null and ${A} = ${Bx})`, [...lhs.bindings, ...lhs.bindings, ...shifted.bindings]); + return; + case '$ne': + (builder as any)[raw](`(${A} is null or ${A} <> ${Bx})`, [...lhs.bindings, ...lhs.bindings, ...shifted.bindings]); + return; + case '$gt': + case '$gte': + case '$lt': + case '$lte': { + const sqlOp = op === '$gt' ? '>' : op === '$gte' ? '>=' : op === '$lt' ? '<' : '<='; + (builder as any)[raw]( + `(${A} is not null and ${A} ${sqlOp} ${Bx})`, + [...lhs.bindings, ...lhs.bindings, ...shifted.bindings], + ); + return; + } + default: + // Unreachable: the call site gates on CROSS_FIELD_COMPARISON_OPERATORS. + throw nowAnchorPositionError(targetColumn, op, node); + } + } + + /** + * [#25808] The anchor's instant as a typed operand in `cls`'s storage form, + * per dialect — see {@link SqlDriver.applyNowAnchorComparison}. + */ + protected nowAnchorBaseExpr(at: Date, cls: 'date' | 'datetime'): { sql: string; bindings: any[] } { + if (cls === 'date') { + const day = temporalStorageForm(at, 'date'); + if (this.isSqlite) return { sql: '?', bindings: [day] }; + return { sql: 'cast(? as date)', bindings: [day] }; + } + const instant = at.toISOString(); + if (this.isSqlite) return { sql: '?', bindings: [instant] }; + if (this.isMysql) return { sql: 'cast(? as datetime(3))', bindings: [mysqlDatetimeLiteral(instant)] }; + return { sql: 'cast(? as timestamptz)', bindings: [instant] }; + } + /** * [#14104] `base` shifted by `offset` whole days, spelled per dialect, in the * storage form the column is compared in — so the shifted value compares diff --git a/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-cross-field-conformance.test.ts b/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-cross-field-conformance.test.ts index 88681548b17..4e0b8a0c5bc 100644 --- a/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-cross-field-conformance.test.ts +++ b/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-cross-field-conformance.test.ts @@ -33,6 +33,8 @@ import { parseFilterAST, type FilterCondition } from '@objectstack/spec/data'; import { CROSS_FIELD_AUTHORED_CASES, CROSS_FIELD_CASES, + CROSS_FIELD_NOW_CASES, + CROSS_FIELD_NOW_REFUSALS, CROSS_FIELD_OBJECT_FIELDS, CROSS_FIELD_OFFSET_CASES, CROSS_FIELD_OFFSET_OBJECT_FIELDS, @@ -220,8 +222,20 @@ describe('[#14104] driver-sqlite-wasm — `addDays` offset push-down conformance }); } - describe('the offset refusal arm (ADR-0112 envelope, operands withheld)', () => { - for (const refusal of CROSS_FIELD_OFFSET_REFUSALS) { + // [#25808] The `{ $now, addDays }` anchor on the same fixture: `$now` pinned + // to the corpus's instant on both paths, the refusal arm under the offset + // arm's withholding contract. + for (const testCase of CROSS_FIELD_NOW_CASES) { + it(`${testCase.name} — same rows on both paths`, async () => { + const expected = [...testCase.expected].sort(); + const note = testCase.note ? `\n${testCase.note}` : ''; + expect(memoryIds(testCase.filter), `in-memory evaluator disagreed${note}`).toEqual(expected); + expect(await sqlIds(testCase.filter), `wasm push-down disagreed${note}`).toEqual(expected); + }); + } + + describe('the offset and anchor refusal arms (ADR-0112 envelope, operands withheld)', () => { + for (const refusal of [...CROSS_FIELD_OFFSET_REFUSALS, ...CROSS_FIELD_NOW_REFUSALS]) { it(`${refusal.name} → 400 INVALID_FILTER`, async () => { const logged: string[] = []; const restore = (driver as unknown as { logger: { warn: (m: string) => void } }).logger; diff --git a/packages/formula/src/matches-filter-now-anchor.test.ts b/packages/formula/src/matches-filter-now-anchor.test.ts new file mode 100644 index 00000000000..f09a8e9ff8d --- /dev/null +++ b/packages/formula/src/matches-filter-now-anchor.test.ts @@ -0,0 +1,170 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#25808] `{ $now, addDays }` — the current instant shifted by whole days, + * compared against a temporal column — on the memory evaluator, over the + * offset fixture the `addDays` suite uses (`matches-filter-field-reference-offset.test.ts`), + * with `$now` PINNED to the fixture's own time of day so the table below is a + * table. The SQL twin of every row-set claim here is the conformance corpus + * (`@objectstack/driver-sql`'s `CROSS_FIELD_NOW_CASES`), driven against the + * real engines; this package is a dependency of that one, so the fixture is + * restated rather than imported. + * + * What this file pins that the corpus cannot: the FORM the anchor is shifted + * in is read off the stored value (this evaluator has no column declaration), + * the own-clock arm (`$now: true`), and the fail-closed arms. + */ + +import { describe, expect, it } from 'vitest'; +import { matchesFilterCondition as m } from './matches-filter'; + +const noon = (day: string | null) => (day === null ? null : `${day}T12:00:00.000Z`); + +/** NOW is 03-03 at 12:00:00.000Z; `now + grace_days` per row is in the corpus's table. */ +const NOW = '2026-03-03T12:00:00.000Z'; + +const DAYS: ReadonlyArray<{ id: string; completed_on: string | null; grace_days: number | null }> = [ + { id: '1', completed_on: '2026-03-05', grace_days: 2 }, + { id: '2', completed_on: '2026-03-03', grace_days: 2 }, + { id: '3', completed_on: '2026-03-01', grace_days: 0 }, + { id: '4', completed_on: '2026-03-05', grace_days: null }, + { id: '5', completed_on: null, grace_days: 2 }, + { id: '6', completed_on: '2026-03-05', grace_days: 2 }, + { id: '7', completed_on: null, grace_days: null }, + { id: '8', completed_on: '2026-03-10', grace_days: -3 }, + { id: '9', completed_on: '2026-02-27', grace_days: -3 }, +]; + +const ROWS = DAYS.map((r) => ({ + ...r, + completed_at: noon(r.completed_on), + // The dotted spelling: an SLA held on the related record, which this + // evaluator walks as it walks a dotted `$field` (SQL push-down refuses it). + duty: { sla_days: r.grace_days }, +})); + +const ids = (filter: unknown): string[] => + ROWS.filter((r) => m(r, filter as never)).map((r) => r.id).sort(); + +const GRACE = { $field: 'grace_days' }; +const anchored = (addDays: unknown, at: unknown = NOW) => ({ $now: at, addDays }); + +const PAIRS = [ + { label: 'date', target: 'completed_on' }, + { label: 'datetime', target: 'completed_at' }, +] as const; + +describe.each(PAIRS)('[#25808] a now-anchored comparand on the $label column', ({ target }) => { + it('a COLUMN offset — completed <= now + grace_days', () => { + expect(ids({ [target]: { $lte: anchored(GRACE) } })).toEqual(['1', '2', '3', '6', '9']); + }); + + it('the ruling\'s SLA shape — "more than N days ago" is a NEGATIVE offset; a column holds its sign', () => { + expect(ids({ [target]: { $lt: anchored(-3) } })).toEqual(['9']); + // The same test with the days read off the row: rows 8 and 9 hold -3. + expect(ids({ [target]: { $lt: anchored(GRACE) } })).toEqual(['2', '3', '9']); + }); + + it('a dotted column offset walks the relation, as a dotted `$field` does', () => { + expect(ids({ [target]: { $lte: anchored({ $field: 'duty.sla_days' }) } })).toEqual(['1', '2', '3', '6', '9']); + }); + + it('the other operators, with a literal offset of 0', () => { + expect(ids({ [target]: { $gte: anchored(0) } })).toEqual(['1', '2', '4', '6', '8']); + expect(ids({ [target]: { $gt: anchored(0) } })).toEqual(['1', '4', '6', '8']); + expect(ids({ [target]: { $lt: anchored(0) } })).toEqual(['3', '9']); + expect(ids({ [target]: { $eq: anchored(0) } })).toEqual(['2']); + expect(ids({ [target]: { $ne: anchored(0) } })).toEqual(['1', '3', '4', '5', '6', '7', '8', '9']); + }); + + it('a NULL offset column contributes ZERO days (row 4)', () => { + expect(ids({ id: '4', [target]: { $gt: anchored(GRACE) } })).toEqual(['4']); + expect(ids({ id: '4', [target]: { $gt: anchored(0) } })).toEqual(['4']); + }); + + it('a NULL target fails the orderings and $eq, satisfies $ne — the anchor is never NULL', () => { + for (const op of ['$eq', '$gt', '$gte', '$lt', '$lte']) { + expect(ids({ id: { $in: ['5', '7'] }, [target]: { [op]: anchored(GRACE) } }), op).toEqual([]); + } + expect(ids({ id: { $in: ['5', '7'] }, [target]: { $ne: anchored(GRACE) } })).toEqual(['5', '7']); + }); + + it('$not takes exactly the complement, NULL targets re-admitted', () => { + expect(ids({ $not: { [target]: { $lte: anchored(GRACE) } } })).toEqual(['4', '5', '7', '8']); + }); + + it('composes under $or, AND and nested combinators', () => { + expect(ids({ [target]: { $lte: anchored(GRACE) }, grace_days: { $gt: 0 } })).toEqual(['1', '2', '6']); + expect(ids({ + $and: [{ $or: [{ [target]: { $gt: anchored(GRACE) } }, { [target]: null }] }, { $not: { grace_days: null } }], + })).toEqual(['5', '8']); + }); +}); + +describe('[#25808] the form the anchor is shifted in is the STORED value\'s', () => { + it('a calendar-day value meets the instant\'s UTC calendar day — the time of day is dropped, never rounded', () => { + // 18:30Z is still 03-03 in UTC: row 2 (03-03) is on or after it. + expect(ids({ completed_on: { $gte: anchored(0, '2026-03-03T18:30:00.000Z') } })).toEqual(['1', '2', '4', '6', '8']); + // 23:59:59.999Z is still 03-03. + expect(ids({ completed_on: { $eq: anchored(0, '2026-03-03T23:59:59.999Z') } })).toEqual(['2']); + // …and 00:00:00.000Z of the next day is not. + expect(ids({ completed_on: { $eq: anchored(0, '2026-03-04T00:00:00.000Z') } })).toEqual([]); + }); + + it('an instant value keeps the time of day', () => { + // Row 2 is at 12:00Z on 03-03, before 18:30Z. + expect(ids({ completed_at: { $gte: anchored(0, '2026-03-03T18:30:00.000Z') } })).toEqual(['1', '4', '6', '8']); + expect(ids({ completed_at: { $lte: anchored(0, '2026-03-03T18:30:00.000Z') } })).toEqual(['2', '3', '9']); + }); + + it('a Date value and an epoch number meet the ISO instant, lifted to instants', () => { + const asDate = { v: new Date('2026-03-05T12:00:00.000Z') }; + const asEpoch = { v: Date.parse('2026-03-05T12:00:00.000Z') }; + expect(m(asDate, { v: { $gt: anchored(1) } } as never)).toBe(true); + expect(m(asDate, { v: { $gt: anchored(2) } } as never)).toBe(false); + expect(m(asDate, { v: { $eq: anchored(2) } } as never)).toBe(true); + // A number compares numerically against a string — not lifted; false either way. + expect(m(asEpoch, { v: { $gt: anchored(1) } } as never)).toBe(false); + }); + + it('crosses a month boundary on the calendar', () => { + expect(ids({ completed_on: { $gte: anchored(-3) } })).toEqual(['1', '2', '3', '4', '6', '8']); + expect(ids({ completed_on: { $eq: anchored(-4) } })).toEqual(['9']); + }); + + it('a fractional offset VALUE truncates toward zero — the reading every SQL dialect arm applies', () => { + expect(ids({ completed_on: { $eq: anchored(2.9) } })).toEqual(['1', '4', '6']); + expect(ids({ completed_on: { $eq: anchored(-3.9) } })).toEqual([]); + expect(ids({ completed_on: { $eq: anchored(-4.9) } })).toEqual(['9']); + }); +}); + +describe('[#25808] the own-clock arm and the fail-closed arms', () => { + it('`$now: true` reads the evaluator\'s own clock', () => { + const before = Date.now(); + const row = { at: new Date(before - 1000).toISOString() }; + expect(m(row, { at: { $lt: { $now: true, addDays: 0 } } } as never)).toBe(true); + expect(m(row, { at: { $gt: { $now: true, addDays: -1 } } } as never)).toBe(true); + expect(m(row, { at: { $gt: { $now: true, addDays: 1 } } } as never)).toBe(false); + }); + + it('an anchor without addDays fails CLOSED for every operator — `{now}` is the spelling for the bare instant', () => { + for (const op of ['$eq', '$ne', '$gt', '$gte', '$lt', '$lte']) { + expect(ids({ completed_on: { [op]: { $now: NOW } } }), op).toEqual([]); + } + }); + + it('a $now that names no instant, and an offset that is not a number, fail CLOSED', () => { + expect(ids({ completed_on: { $ne: anchored(0, 'soon') } })).toEqual([]); + expect(ids({ completed_on: { $ne: anchored(0, false) } })).toEqual([]); + expect(ids({ completed_on: { $ne: anchored('tomorrow') } })).toEqual([]); + // A column holding text that is no number (a date) is NaN days — closed; + // a numeric string (`id`) is read as its number, as `addDays` reads one. + expect(ids({ id: '1', completed_on: { $ne: anchored({ $field: 'completed_on' }) } })).toEqual([]); + expect(ids({ completed_on: { $lte: anchored({ $field: 'id' }) } })).toEqual(['2', '3', '4', '6', '8', '9']); + }); + + it('the anchor is read by SHAPE: an extra key does not disqualify it', () => { + expect(ids({ completed_on: { $eq: { ...anchored(0), note: 'x' } } })).toEqual(['2']); + }); +}); diff --git a/packages/formula/src/matches-filter.ts b/packages/formula/src/matches-filter.ts index 75a7881362b..0e839e941e1 100644 --- a/packages/formula/src/matches-filter.ts +++ b/packages/formula/src/matches-filter.ts @@ -296,6 +296,16 @@ function isFieldReference(raw: unknown): raw is Record { return raw !== null && typeof raw === 'object' && !Array.isArray(raw) && '$field' in (raw as Record); } +/** + * [#25808] A `{ $now, addDays }` now-anchored comparand — the same shape test + * {@link resolveValue} applies: an object carrying a `$now` key. `$now` is + * `true` as authored, or the ISO instant the engine's token resolver pinned it + * to; both read as an instant here ({@link nowAnchorInstant}). + */ +function isNowAnchor(raw: unknown): raw is Record { + return raw !== null && typeof raw === 'object' && !Array.isArray(raw) && '$now' in (raw as Record); +} + /** A plain object — an operator map rather than a comparand (`Date` is a comparand). */ function isOperatorMap(spec: unknown): spec is Record { if (spec === null || typeof spec !== 'object' || Array.isArray(spec) || spec instanceof Date) return false; @@ -674,10 +684,11 @@ function evalOp( if ((ORDERING_OPERATORS as readonly string[]).includes(op) && isNonScalarValue(actual)) { throw arrayComparandError(); } - const v = resolveValue(raw, record); + const v = resolveValue(raw, record, actual); // [#14104] An offset reference whose base is NULL is FALSE for every // operator — see {@link NO_OFFSET_BASE}. Before the switch, so `$ne`'s - // complement arm and `$eq`'s null arm never see it. + // complement arm and `$eq`'s null arm never see it. [#25808] A malformed + // now-anchor resolves to the same sentinel, for the same reason. if (v === NO_OFFSET_BASE) return false; switch (op) { case '$eq': return v === null ? actual == null : looseEq(actual, v); @@ -953,16 +964,63 @@ const NO_OFFSET_BASE: unique symbol = Symbol('matches-filter:no-offset-base'); * raw`, any extra key ignored) so the three faces keep agreeing on what a * reference IS; only what is done with one grew. */ -function resolveValue(raw: unknown, record: Record): unknown { +function resolveValue(raw: unknown, record: Record, actual?: unknown): unknown { if (raw && typeof raw === 'object' && !Array.isArray(raw) && '$field' in (raw as Record)) { const ref = raw as Record; const base = getPath(record, String(ref.$field)); if (!('addDays' in ref) || ref.addDays === undefined) return base; return addWholeDays(base, resolveDayOffset(ref.addDays, record)); } + if (isNowAnchor(raw)) return resolveNowAnchor(raw, record, actual); return raw; } +/** + * [#25808] The resolved comparand of a `{ $now, addDays }` anchor: the current + * instant shifted by `addDays` whole days — an integer literal or a nested + * `{ $field }` reference to a numeric column, read exactly as a `{ $field, + * addDays }` pair's offset is ({@link resolveDayOffset}: a NULL offset column + * is zero days, a fractional value truncates toward zero, a non-number fails + * closed). + * + * The shift is made IN THE FORM OF THE VALUE IT IS COMPARED AGAINST, which is + * what makes the answer the SQL twin's: `driver-sql` binds the instant in the + * target column's storage form and shifts it there (`applyNowAnchorComparison`), + * so a `date` column meets the instant's UTC calendar day plus the offset — a + * calendar day, compared day for day — and a `datetime` column meets the ISO + * instant plus the offset, time of day intact. This evaluator has no column + * declaration, so it reads the form off the stored value: a bare `YYYY-MM-DD` + * actual puts the anchor in calendar-day form; anything else (an ISO instant, + * a `Date`, an epoch number, a NULL) meets the ISO instant. + * + * `addDays` is REQUIRED on an anchor (the schema and the engine's resolver + * both refuse its absence; `{now}` is the spelling for the unshifted instant); + * one that reaches this evaluator without it fails closed, as a malformed + * offset does. + */ +function resolveNowAnchor(anchor: Record, record: Record, actual: unknown): unknown { + const at = nowAnchorInstant(anchor.$now); + if (at === null || !('addDays' in anchor) || anchor.addDays === undefined) return NO_OFFSET_BASE; + const days = resolveDayOffset(anchor.addDays, record); + const base = typeof actual === 'string' && CALENDAR_DAY_RE.test(actual) + ? at.toISOString().slice(0, 10) + : at.toISOString(); + return addWholeDays(base, days); +} + +/** + * [#25808] The instant a `$now` names: `true` is the evaluator's own clock (a + * caller that passed no resolver seam — the RLS check, a direct call); an ISO + * string is the instant the engine's `resolveFilterTokens` pinned the whole + * tree to. Anything else names no instant. + */ +function nowAnchorInstant(raw: unknown): Date | null { + if (raw === true) return new Date(); + if (typeof raw !== 'string') return null; + const ms = Date.parse(raw); + return Number.isFinite(ms) ? new Date(ms) : null; +} + /** * [#14104] The `addDays` operand as a whole number of days: a literal, or the * value of the referenced column. `null`/`undefined` (a duty with no grace) is diff --git a/packages/lint/src/validate-filter-tokens.test.ts b/packages/lint/src/validate-filter-tokens.test.ts index 2c2287fd339..2b0bd3fd2ba 100644 --- a/packages/lint/src/validate-filter-tokens.test.ts +++ b/packages/lint/src/validate-filter-tokens.test.ts @@ -161,6 +161,21 @@ describe('validateFilterTokens', () => { ]); }); + // [#25808] The `{ $now: true, addDays }` anchor is an OBJECT comparand, not + // a placeholder string: the walk reads its values (a boolean, a number, a + // nested `{ $field }`) and finds no token in them — a dataset filter carrying + // it is clean, beside the macros it can sit next to. + it('leaves a now-anchored comparand alone — it is not a placeholder', () => { + const findings = validateFilterTokens({ + datasets: [{ + name: 'overdue', + filter: { opened_at: { $lt: { $now: true, addDays: { $field: 'sla_days' } } }, owner: '{current_user_id}' }, + measures: [{ name: 'n', aggregate: 'count', filter: { closed_on: { $gte: { $now: true, addDays: -30 } } } }], + }], + }); + expect(findings).toEqual([]); + }); + // Navigation resolves an extra vocabulary (AppContextSelector ids). Filters // do not, so the rule must not wander into `params` / `recordId`. it('ignores nav params and recordId, which resolve context-selector ids', () => { diff --git a/packages/objectql/src/engine-now-anchor.test.ts b/packages/objectql/src/engine-now-anchor.test.ts new file mode 100644 index 00000000000..3f512db6b05 --- /dev/null +++ b/packages/objectql/src/engine-now-anchor.test.ts @@ -0,0 +1,259 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#25808] `{ $now: true, addDays }` — the now-anchored comparand on the + * engine's three filter positions, maintainer ruling 2026-10-26 (letter A): + * + * - `where`: the token resolver PINS `$now` to the tree's instant before any + * driver runs — a driver sees `{ $now: '', addDays }`, never `true` — + * and an anchor without `addDays` is the resolver's `INVALID_FILTER` / 400. + * - `aggregations[i].filter`: the per-aggregation judge holds the anchor to + * `where`'s class rule (a temporal target, a declared numeric offset) with + * the operands withheld, and the rows fallback counts it through + * `@objectstack/formula`. + * - `having`: the aggregated row's classes judge the same rule by name (the + * query is the author's own), and the resolved anchor is compared per group. + * + * `Date` is faked to one instant so every count below is a table, the way the + * SQL conformance corpus pins its `$now`. + */ + +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import type { EngineAggregateOptions } from '@objectstack/spec/data'; +import { ObjectQL } from './engine.js'; + +const NOW = new Date('2026-03-03T12:00:00.000Z'); + +/** Tickets: opened on a calendar day (an instant at noon), an SLA in days, and a numeric decoy. */ +const TICKETS = [ + { id: 't1', region: 'east', opened_at: '2026-02-01T12:00:00.000Z', opened_on: '2026-02-01', sla_days: -7, amount: 10, title: 'a' }, + { id: 't2', region: 'east', opened_at: '2026-03-01T12:00:00.000Z', opened_on: '2026-03-01', sla_days: -7, amount: 20, title: 'b' }, + { id: 't3', region: 'west', opened_at: '2026-02-20T12:00:00.000Z', opened_on: '2026-02-20', sla_days: -14, amount: 30, title: 'c' }, + { id: 't4', region: 'west', opened_at: '2026-02-25T12:00:00.000Z', opened_on: '2026-02-25', sla_days: null, amount: 40, title: 'd' }, + { id: 't5', region: 'west', opened_at: null, opened_on: null, sla_days: -7, amount: 50, title: 'e' }, +]; + +function makeRawDriver(rows: any[]) { + const seen: any[] = []; + const driver: any = { + name: 'raw-mock', + version: '0.0.0', + supports: {}, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; }, + async find(_o: string, query: any) { seen.push(query); return rows.map((r) => ({ ...r })); }, + async findOne() { return rows[0] ?? null; }, + async create(_o: string, d: any) { return d; }, + async update(_o: string, _id: string, d: any) { return d; }, + async delete() { return true; }, + async count() { return rows.length; }, + async bulkCreate(_o: string, r: any[]) { return r; }, + async bulkUpdate() { return []; }, async bulkDelete() {}, + async beginTransaction() { return { __trx: true, commit: async () => {}, rollback: async () => {} }; }, + async commit() {}, async rollback() {}, + }; + return { driver, seen }; +} + +async function makeEngine(driver: any) { + const engine = new ObjectQL(); + engine.registerDriver(driver, true); + await engine.init(); + (engine.registry as any).registerObject({ + name: 'ticket', + fields: { + region: { type: 'text' }, + opened_at: { type: 'datetime' }, + opened_on: { type: 'date' }, + sla_days: { type: 'number' }, + amount: { type: 'number' }, + title: { type: 'text' }, + }, + }); + return engine; +} + +type Wire = Error & { code?: string; status?: number }; +async function refusalOf(run: () => Promise): Promise { + try { + const got = await run(); + throw new Error(`expected a refusal, got ${JSON.stringify(got)}`); + } catch (e) { + return e as Wire; + } +} + +/** "Opened more than SLA days ago": the column holds NEGATIVE days, the sign rules are `addDays`'. */ +const OVERDUE = { $lt: { $now: true, addDays: { $field: 'sla_days' } } }; + +describe('[#25808] the now-anchored comparand on the engine', () => { + let engine: ObjectQL; + let seen: any[]; + const warned: string[] = []; + + beforeAll(async () => { + vi.useFakeTimers({ toFake: ['Date'], now: NOW }); + const made = makeRawDriver(TICKETS); + seen = made.seen; + engine = await makeEngine(made.driver); + (engine as any).logger = { ...(engine as any).logger, warn: (m: string) => { warned.push(m); }, debug() {}, info() {}, error() {} }; + }); + + afterAll(() => { + vi.useRealTimers(); + }); + + describe('`where` — the resolver pins `$now` to the tree\'s instant before any driver runs', () => { + it('a driver sees the ISO instant, the offset kept as written', async () => { + seen.length = 0; + await engine.find('ticket', { where: { opened_at: OVERDUE } }); + expect(seen).toHaveLength(1); + expect(seen[0].where).toEqual({ opened_at: { $lt: { $now: NOW.toISOString(), addDays: { $field: 'sla_days' } } } }); + // …and the literal arm, likewise. + seen.length = 0; + await engine.find('ticket', { where: { opened_on: { $gte: { $now: true, addDays: -30 } } } }); + expect(seen[0].where).toEqual({ opened_on: { $gte: { $now: NOW.toISOString(), addDays: -30 } } }); + }); + + it('an anchor without addDays is refused at the resolver — `{now}` is the spelling for the bare instant', async () => { + const err = await refusalOf(() => engine.find('ticket', { where: { opened_at: { $lt: { $now: true } } } })); + expect(err.code).toBe('INVALID_FILTER'); + expect(err.status).toBe(400); + expect(err.message).toContain('addDays'); + expect(err.message).toContain('{now}'); + }); + + it('a malformed offset is refused at the resolver', async () => { + const err = await refusalOf(() => engine.find('ticket', { where: { opened_at: { $lt: { $now: true, addDays: 1.5 } } } })); + expect(err.code).toBe('INVALID_FILTER'); + expect(err.status).toBe(400); + }); + + it('the temporal door does not judge the anchor as a literal', async () => { + // A `datetime` column's literal door refuses `'soon'`; the anchor passes it. + const err = await refusalOf(() => engine.find('ticket', { where: { opened_at: { $lt: 'soon' } } })); + expect(err.code).toBe('INVALID_FILTER'); + seen.length = 0; + await expect(engine.find('ticket', { where: { opened_at: OVERDUE } })).resolves.toBeDefined(); + }); + }); + + describe('`aggregations[i].filter` — counted through the rows fallback, judged by `where`\'s rules', () => { + it('counts "opened more than SLA days ago" with the SLA read off each row', async () => { + // NOW is 03-03. t1: 02-01 < 02-24 ✓; t2: 03-01 < 02-24 ✗; t3: 02-20 < 02-17 ✗; + // t4: NULL sla = 0 days, 02-25 < 03-03 ✓; t5: never opened ✗. + const aggregations: NonNullable = [ + { function: 'count', alias: 'n' }, + { function: 'count', alias: 'overdue', filter: { opened_at: OVERDUE } }, + { function: 'count', alias: 'overdue_on', filter: { opened_on: OVERDUE } }, + { function: 'count', alias: 'recent', filter: { opened_on: { $gte: { $now: true, addDays: -7 } } } }, + ]; + const rows = await engine.aggregate('ticket', { aggregations }); + expect(rows).toHaveLength(1); + expect(Number(rows[0].n)).toBe(5); + expect(Number(rows[0].overdue)).toBe(2); + // The `date` twin answers the same count — the calendar-day form. + expect(Number(rows[0].overdue_on)).toBe(2); + // On or after 02-24: t2, t4. + expect(Number(rows[0].recent)).toBe(2); + }); + + const withheld = async (filter: unknown, fragment: string) => { + warned.length = 0; + const err = await refusalOf(() => engine.aggregate('ticket', { + aggregations: [{ function: 'count', alias: 'x', filter: filter as any }], + })); + expect(err.code).toBe('INVALID_FILTER'); + expect(err.status).toBe(400); + expect(err.message).toContain('now-anchored'); + expect(err.message).toContain('aggregations[0].filter'); + for (const name of ['opened_at', 'sla_days', 'amount', 'title', 'no_such']) { + expect(err.message, `message names "${name}"`).not.toContain(name); + } + expect(warned.join('\n')).toContain(fragment); + }; + + it('a NUMERIC target is refused, operands withheld, the reason in the log', async () => { + await withheld({ amount: OVERDUE }, 'only a date or datetime column'); + }); + + it('a TEXT offset column is refused', async () => { + await withheld({ opened_at: { $lt: { $now: true, addDays: { $field: 'title' } } } }, 'not a numeric column'); + }); + + it('an undeclared offset column is refused', async () => { + await withheld({ opened_at: { $lt: { $now: true, addDays: { $field: 'no_such' } } } }, 'not a declared field'); + }); + + it('a malformed anchor is refused by its declaration, the position named', async () => { + const err = await refusalOf(() => engine.aggregate('ticket', { + aggregations: [{ function: 'count', alias: 'x', filter: { opened_at: { $lt: { $now: true } } } }], + })); + expect(err.code).toBe('INVALID_FILTER'); + expect(err.message).toContain('addDays'); + }); + + it('an anchor as an $in member is refused by position', async () => { + const err = await refusalOf(() => engine.aggregate('ticket', { + aggregations: [{ function: 'count', alias: 'x', filter: { opened_at: { $in: [{ $now: true, addDays: 1 }] } } }], + })); + expect(err.code).toBe('INVALID_FILTER'); + expect(err.message).toContain('WHOLE comparand'); + }); + }); + + describe('`having` — the aggregated row\'s classes judge the rule by name', () => { + const grouped = (having: unknown) => engine.aggregate('ticket', { + groupBy: ['region'], + aggregations: [ + { function: 'min', field: 'opened_at', alias: 'first_opened' }, + { function: 'max', field: 'opened_on', alias: 'last_opened' }, + { function: 'count', alias: 'n' }, + { function: 'min', field: 'sla_days', alias: 'tightest_sla' }, + ], + having: having as any, + }); + + it('a `min` of a datetime keeps its kind: the anchor compares per group', async () => { + // east first opened 02-01, west 02-20; NOW - 14 days is 02-17. + const older = await grouped({ first_opened: { $lt: { $now: true, addDays: -14 } } }); + expect(older.map((r) => r.region)).toEqual(['east']); + // The `date` column's `max`: east last 03-01, west 02-25; on or after 02-28. + const recent = await grouped({ last_opened: { $gte: { $now: true, addDays: -3 } } }); + expect(recent.map((r) => r.region)).toEqual(['east']); + // A column offset read off the aggregated row: `min(sla_days)` is -7 (east, + // bound 02-24) / -14 (west, bound 02-17); east's 02-01 is before its bound, + // west's 02-20 is on or after its own. + const overdue = await grouped({ first_opened: { $lt: { $now: true, addDays: { $field: 'tightest_sla' } } } }); + expect(overdue.map((r) => r.region)).toEqual(['east']); + const inside = await grouped({ first_opened: { $gte: { $now: true, addDays: { $field: 'tightest_sla' } } } }); + expect(inside.map((r) => r.region)).toEqual(['west']); + }); + + it('a `count` column is refused — the anchor is an instant', async () => { + const err = await refusalOf(() => grouped({ n: { $gt: { $now: true, addDays: 1 } } })); + expect(err.code).toBe('INVALID_FILTER'); + expect(err.status).toBe(400); + expect(err.message).toContain('now-anchored'); + expect(err.message).toContain('"n"'); + expect(err.message).toContain('numeric'); + }); + + it('an offset naming no column of the aggregated row is refused by name', async () => { + const err = await refusalOf(() => grouped({ first_opened: { $lt: { $now: true, addDays: { $field: 'sla_days' } } } })); + expect(err.code).toBe('INVALID_FILTER'); + expect(err.message).toContain('names no column of the aggregated row'); + }); + + it('a non-numeric offset column is refused', async () => { + const err = await refusalOf(() => grouped({ first_opened: { $lt: { $now: true, addDays: { $field: 'last_opened' } } } })); + expect(err.code).toBe('INVALID_FILTER'); + expect(err.message).toContain('not a numeric column'); + }); + + it('the bare `{ column: { $now, addDays } }` spelling is refused', async () => { + const err = await refusalOf(() => grouped({ first_opened: { $now: true, addDays: 1 } })); + expect(err.code).toBe('INVALID_FILTER'); + expect(err.message).toContain('now-anchored'); + }); + }); +}); diff --git a/packages/objectql/src/having-filter.ts b/packages/objectql/src/having-filter.ts index b5cad1284a9..f46fee53b29 100644 --- a/packages/objectql/src/having-filter.ts +++ b/packages/objectql/src/having-filter.ts @@ -322,6 +322,22 @@ function isFieldReferenceShape(value: unknown): value is Record && '$field' in value; } +/** + * [#25808] A `{ $now: true, addDays }` now-anchored comparand by SHAPE — a + * non-array object carrying a `$now` key — the test `@objectstack/formula`'s + * evaluator and `driver-sql`'s compiler apply. `$now` is `true` as authored + * and the tree's pinned ISO instant once the engine's token resolver has run + * (`having` resolves AFTER these doors, a per-aggregation filter too), so the + * static judges below accept both spellings and read neither. + */ +function isNowAnchorShape(value: unknown): value is Record { + return typeof value === 'object' + && value !== null + && !Array.isArray(value) + && !(value instanceof Date) + && '$now' in value; +} + /** A bounded rendering of an offending value, for a human reading a 400. */ function preview(value: unknown): string { let text: string; @@ -646,6 +662,91 @@ function malformedFieldReferenceError(path: string, detail: string): Error { return invalidFilterError(`The { "$field" } reference at ${path} is malformed: ${detail}`); } +/** + * [#25808] A `{ $now }` anchor its own declaration refuses (`NowAnchorSchema`): + * `$now` that is neither `true` nor the resolver's pinned instant, a missing + * `addDays`, an offset that is neither an integer nor a `{ $field }`. + */ +function malformedNowAnchorError(path: string, detail: string): Error { + return invalidFilterError(`The { "$now" } comparand at ${path} is malformed: ${detail}`); +} + +/** + * [#25808] The anchor's own declaration, as a reason or `undefined` — the + * `where` compiler's reading (`driver-sql`'s `nowAnchorOf` / + * `fieldReferenceOffsetOf`), so a shape it refuses is refused here too. + */ +function nowAnchorViolation(anchor: Record): string | undefined { + const at = anchor.$now; + if (at !== true && !(typeof at === 'string' && Number.isFinite(Date.parse(at)))) { + return `$now must be the literal true (the resolver pins it to the current instant), not ${preview(at)}.`; + } + const offset = anchor.addDays; + if (offset === undefined) { + return 'a { "$now": true } comparand must carry an addDays offset — an integer literal of any sign or a ' + + '{ "$field" } reference to a numeric column; for the unshifted instant write the "{now}" placeholder.'; + } + if (typeof offset === 'number') { + return Number.isInteger(offset) ? undefined : `addDays ${String(offset)} is not an integer (whole days only).`; + } + if (isFieldReferenceShape(offset) && typeof offset.$field === 'string') return undefined; + return `addDays ${preview(offset)} is neither an integer nor a { "$field": "numeric_column" } reference.`; +} + +/** + * [#25808] The class rule `driver-sql` applies to a now-anchored comparand in + * a `where`, as a reason or `undefined`: the target column is temporal (the + * anchor is an instant shifted by whole days), and a column offset is numeric. + * A class the declaration cannot tell is not judged — {@link offsetPairViolation}'s + * convention. + */ +function nowAnchorClassViolation( + field: string, + anchor: Record, + classes: ReadonlyMap, +): string | undefined { + const targetClass = classes.get(field); + if (targetClass !== undefined && targetClass !== 'date' && targetClass !== 'datetime') { + return `a now-anchored comparand is an instant shifted by whole days, and "${field}" is ${targetClass} — ` + + 'only a date or datetime column can be compared against it.'; + } + const offset = anchor.addDays; + if (!isFieldReferenceShape(offset)) return undefined; + const offsetRef = String(offset.$field); + const offsetClass = classes.get(offsetRef); + if (offsetClass !== undefined && offsetClass !== 'numeric') { + return `the addDays offset "${offsetRef}" (${offsetClass}) is not a numeric column, and a day offset ` + + 'must be a number of days.'; + } + return undefined; +} + +/** + * [#25808] A now-anchored comparand the aggregated row's classes refuse — the + * `having` twin of {@link columnPairError}, naming the columns because they + * are the author's own query. + */ +function nowAnchorClassError(field: string, op: string, path: string, reason: string): Error { + return invalidFilterError( + `Operator "${op}" on column "${field}" at ${path} compares against a now-anchored comparand ` + + `({ "$now": true, "addDays": … }), which cannot be evaluated here: ${reason}`, + ); +} + +/** + * [#25808] A `{ $now }` anchor outside the six scalar comparisons — the twin of + * {@link fieldReferencePositionError}, for the same positions and reason. + */ +function nowAnchorPositionError(field: string, op: string, path: string, index?: number): Error { + const at = index === undefined ? '' : ` at index ${index} of its value list`; + return invalidFilterError( + `Operator "${op}" on field "${field}" at ${path} compares against a now-anchored comparand ` + + `({ "$now": true, "addDays": … })${at}. The anchor is evaluated only as the WHOLE comparand of a scalar ` + + `comparison — $eq, $ne, $gt, $gte, $lt or $lte — never as a list member or a text pattern. Compare ` + + `against a literal here, or write the test as one of those comparisons.`, + ); +} + /** * [#20123] A `having` KEY that names no column of the aggregated row — the * twin of {@link unresolvedFieldReferenceError} for the other place a column @@ -1462,6 +1563,22 @@ function withheldAggregationReferenceError(root: string): Error { ); } +/** + * [#25808] A `{ $now }` anchor in a per-aggregation filter that `where`'s + * rules refuse — a non-temporal target, an undeclared or non-numeric offset + * column. Withheld as {@link withheldAggregationReferenceError} withholds, for + * its reason. + */ +function withheldAggregationNowAnchorError(root: string): Error { + return invalidFilterError( + `A now-anchored comparand ({ "$now": true, "addDays": … }) in \`${root}\` cannot be evaluated. It ` + + `compares a date or datetime field the object declares against the current instant shifted by an ` + + `integer or a numeric field's worth of days; evaluated anyway, its count would be silently wrong. ` + + `The fields, the operator and the reason are withheld from the message, as for the same comparison ` + + `in a \`where\`; the full diagnostic is in the server log.`, + ); +} + /** * [#20148] Judge every scalar-comparison `{ $field }` reference in one * per-aggregation filter against the object's declaration — the same @@ -1512,6 +1629,13 @@ export function assertAggregationFilterReferencesAreDeclared( ); throw withheldAggregationReferenceError(root); }; + const refuseAnchor = (field: string, op: string, path: string, reason: string): never => { + declared.reportWithheld( + `Operator "${op}" on field "${field}" at ${path} compares against a now-anchored comparand ` + + `({ "$now": true, "addDays": … }), which cannot be evaluated here: ${reason}`, + ); + throw withheldAggregationNowAnchorError(root); + }; const walk = (cond: unknown, path: string): void => { if (!cond || typeof cond !== 'object') return; for (const [key, value] of Object.entries(cond)) { @@ -1528,7 +1652,22 @@ export function assertAggregationFilterReferencesAreDeclared( if (key.startsWith('$')) continue; if (typeof value !== 'object' || value === null || value instanceof Date || Array.isArray(value)) continue; for (const [op, target] of Object.entries(value)) { - if (!REFERENCE_COMPARISON_OPERATORS.has(op) || !isFieldReferenceShape(target)) continue; + if (!REFERENCE_COMPARISON_OPERATORS.has(op)) continue; + // [#25808] A now-anchored comparand: its offset column is declared and + // numeric, its target temporal — `where`'s rules for the same anchor. + if (isNowAnchorShape(target)) { + const at = `${here}.${op}`; + const offset = target.addDays; + if (isFieldReferenceShape(offset) && !names.has(String(offset.$field))) { + refuseAnchor(key, op, at, + `the addDays offset "${String(offset.$field)}" is not a declared field${ofObject} — ` + + `only declared fields can be referenced.`); + } + const reason = nowAnchorClassViolation(key, target, classes); + if (reason !== undefined) refuseAnchor(key, op, at, reason); + continue; + } + if (!isFieldReferenceShape(target)) continue; const at = `${here}.${op}`; const ref = String(target.$field); if (!names.has(ref)) { @@ -1637,6 +1776,8 @@ function assertConditionIsEvaluable( for (const op of keys) { const target = spec[op]; if (op === '$field') throw bareFieldReferenceError(field, spec, path); + // [#25808] The bare `{ field: { $now: true, addDays } }` spelling, likewise. + if (op === '$now') throw nowAnchorPositionError(field, op, path); if (op === '$icontains' && (typeof target !== 'string' || target === '')) { throw icontainsComparandError(field, target, `${path}.${op}`); } @@ -1665,14 +1806,22 @@ function assertConditionIsEvaluable( if (target.addDays !== undefined && scope.classes) { assertOffsetPairIsTemporal(field, op, target, `${path}.${op}`, scope.classes); } + } else if (isNowAnchorShape(target)) { + // [#25808] A now-anchored comparand: well-formed by its declaration, + // its offset column one the row has, the classes as `where` reads them. + assertNowAnchorResolves(field, op, target, `${path}.${op}`, scope); } continue; } if (Array.isArray(target)) { const index = target.findIndex(isFieldReferenceShape); if (index !== -1) throw fieldReferencePositionError(field, op, `${path}.${op}`, index); + const anchorIndex = target.findIndex(isNowAnchorShape); + if (anchorIndex !== -1) throw nowAnchorPositionError(field, op, `${path}.${op}`, anchorIndex); } else if (isFieldReferenceShape(target)) { throw fieldReferencePositionError(field, op, `${path}.${op}`); + } else if (isNowAnchorShape(target)) { + throw nowAnchorPositionError(field, op, `${path}.${op}`); } } } @@ -1702,6 +1851,58 @@ function assertReferenceResolves( } } +/** + * [#25808] A now-anchored comparand in a scalar comparison: well-formed by its + * own declaration, its column offset (when it carries one) naming a column the + * aggregated row has, and the class rule `where` applies — target temporal, + * offset numeric — where the position's classes are known. + */ +function assertNowAnchorResolves( + field: string, + op: string, + anchor: Record, + path: string, + scope: EvaluableScope, +): void { + const detail = nowAnchorViolation(anchor); + if (detail !== undefined) throw malformedNowAnchorError(path, detail); + const offset = anchor.addDays; + if (scope.columns && isFieldReferenceShape(offset) && !scope.columns.includes(String(offset.$field))) { + throw unresolvedFieldReferenceError(String(offset.$field), path, scope.columns); + } + if (scope.classes) { + const reason = nowAnchorClassViolation(field, anchor, scope.classes); + if (reason !== undefined) throw nowAnchorClassError(field, op, path, reason); + } +} + +/** + * [#25808] Evaluate `value { $now, addDays }` on one row — the twin of + * {@link compareWithReference}, through the same evaluator, which owns the + * anchor's arithmetic (`resolveNowAnchor`): the instant shifted by whole days + * in the FORM of the value it meets, so a `date` column (handed here in its + * storage form, a calendar day) is compared day for day and a `datetime` + * column instant for instant — the rows `driver-sql`'s compiler answers. + * A column offset is re-keyed onto the probe as a reference's is. + */ +function compareWithNowAnchor( + row: Record, + value: unknown, + op: string, + anchor: Record, +): boolean { + const probe: Record = { value }; + const resolved: Record = { $now: anchor.$now }; + const offset = anchor.addDays; + if (isFieldReferenceShape(offset)) { + probe.offset = row?.[String(offset.$field)]; + resolved.addDays = { $field: 'offset' }; + } else if (offset !== undefined) { + resolved.addDays = offset; + } + return matchesFilterCondition(probe, { value: { [op]: resolved } } as FilterCondition); +} + /** * [#20099] Evaluate `value { $field }` on one row, with the reference * resolved against that row. @@ -2060,6 +2261,12 @@ function checkCondition( if (!compareWithReference(row, value, op, target)) return false; continue; } + // [#25808] A now-anchored comparand, likewise — handed the value in the + // column's storage form, which decides the form the anchor is shifted in. + if (REFERENCE_COMPARISON_OPERATORS.has(op) && isNowAnchorShape(target)) { + if (!compareWithNowAnchor(row, form(value), op, target)) return false; + continue; + } // [#20148] The comparison and list arms read a `Date` bound — or a `Date` // value — as an instant ({@link instantsOf}); every other pair compares // exactly as before. [#20176] On a temporal column both sides are in its diff --git a/packages/objectql/src/temporal-comparand-door.ts b/packages/objectql/src/temporal-comparand-door.ts index e13e9558c75..1932694c75f 100644 --- a/packages/objectql/src/temporal-comparand-door.ts +++ b/packages/objectql/src/temporal-comparand-door.ts @@ -337,6 +337,20 @@ function isFieldReference(value: unknown): boolean { return isFilterNode(value) && typeof (value as { $field?: unknown }).$field === 'string'; } +/** + * [#25808] A `{ $now: true, addDays }` anchor is not a literal either: `$now` + * is `true` as written and the tree's pinned instant (an ISO string) once + * `resolveFilterTokens` has run, and the instant it names is the server's own + * clock, which is always inside the four-digit years this door guards. The + * `addDays` half is judged where the `{ $field, addDays }` pair's is (the + * having judge, the `where` compiler), not here. + */ +function isNowAnchor(value: unknown): boolean { + if (!isFilterNode(value)) return false; + const anchor = (value as { $now?: unknown }).$now; + return anchor === true || typeof anchor === 'string'; +} + /** * Walk one `FilterCondition` and return the FIRST comparand a declared temporal * field's storage rule cannot read, or `null`. @@ -424,7 +438,7 @@ function judgeFieldComparands( // backend agrees with. const keys = Object.keys(spec); if (!keys.some((k) => k.startsWith('$'))) return null; - if (isFieldReference(spec)) return null; + if (isFieldReference(spec) || isNowAnchor(spec)) return null; for (const op of keys) { if (!op.startsWith('$')) continue; if (!scope.judgesOperator(op)) continue; @@ -451,7 +465,7 @@ function judgeComparand( value: unknown, path: string, ): UninterpretableTemporalComparand | null { - if (isFieldReference(value)) return null; + if (isFieldReference(value) || isNowAnchor(value)) return null; if (!isUninterpretableTemporalComparand(kind, value)) return null; return { field, kind, value, path }; } diff --git a/packages/services/service-analytics/src/__tests__/now-anchor-dataset.test.ts b/packages/services/service-analytics/src/__tests__/now-anchor-dataset.test.ts new file mode 100644 index 00000000000..497aedc2f47 --- /dev/null +++ b/packages/services/service-analytics/src/__tests__/now-anchor-dataset.test.ts @@ -0,0 +1,360 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#25808] `{ $now: true, addDays }` — the now-anchored comparand on the + * analytics face, maintainer ruling 2026-10-26 (letter A): a dataset filter or + * measure filter that compares a datetime column against "now minus an SLA + * held on the record". + * + * ## The routing question + * + * `NativeSQLStrategy.canHandle` declines a cross-field comparison so the + * filter routes to the engine path, where `driver-sql` compiles it (ruling + * 2026-08-12 Q1 = B, #7598). The anchor takes the same road for the same + * reason — the native compiler would bind the anchor object as the + * comparison's value, and only the driver holds the target column's class and + * the offset column's declaration — so `findCrossFieldComparand` detects it + * too. The first block pins the detector; the second measures the decline. + * + * ## The dataset question — the ruling's driving shape + * + * "Tickets open longer than their SLA" as a dataset MEASURE, so a dashboard + * binds the overdue count by name, and as a dataset FILTER, so every measure + * of the dataset reads the overdue slice. Both authored as `$now: true`: the + * service's token seam pins the instant before the strategy is chosen + * (`resolvedDatasetScopeGetter`), and `Date` is faked here so the pinned + * instant is the corpus's. + */ + +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { + CROSS_FIELD_NOW, + CROSS_FIELD_NOW_CASES, + CROSS_FIELD_NOW_REFUSALS, + CROSS_FIELD_OFFSET_OBJECT_FIELDS, + CROSS_FIELD_OFFSET_OPERAND_NAMES, + CROSS_FIELD_OFFSET_ROWS, +} from '@objectstack/driver-sql'; +import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; +import { DatasetSchema } from '@objectstack/spec/ui'; +import type { AggregationNode, Cube, FilterCondition } from '@objectstack/spec/data'; +import type { AnalyticsQuery, DriverQuery } from '@objectstack/spec/contracts'; +import type { ExecutionContext } from '@objectstack/spec/kernel'; + +import { AnalyticsService } from '../analytics-service.js'; +import { + findCrossFieldComparand, + findUninterpretableTemporalMember, + isNowAnchor, +} from '../comparand-shape.js'; + +const OBJECT = 'cross_field_task'; +const CTX = { tenantId: 'org_A' } as ExecutionContext; + +const GRACE = { $field: 'grace_days' }; +/** The authored shape: the service pins `$now` itself. */ +const WITHIN_GRACE: FilterCondition = { completed_on: { $lte: { $now: true, addDays: GRACE } } }; +const PAST_GRACE: FilterCondition = { completed_on: { $gt: { $now: true, addDays: GRACE } } }; + +interface WireBearingError extends Error { + code?: string; + status?: number; +} + +// ── The detector ──────────────────────────────────────────────────────────── + +describe('[#25808] the analytics comparand detector routes a now-anchored comparand', () => { + it('isNowAnchor — authored and pinned spellings, extra keys ignored; the negative controls', () => { + expect(isNowAnchor({ $now: true, addDays: 5 })).toBe(true); + expect(isNowAnchor({ $now: CROSS_FIELD_NOW, addDays: GRACE })).toBe(true); + expect(isNowAnchor({ $now: true })).toBe(true); + expect(isNowAnchor({ $now: false, addDays: 1 })).toBe(false); + expect(isNowAnchor({ addDays: 5 })).toBe(false); + expect(isNowAnchor({ $field: 'due_on' })).toBe(false); + }); + + it('findCrossFieldComparand routes it — at the leaf and under every combinator', () => { + expect(findCrossFieldComparand(WITHIN_GRACE)).toEqual({ op: '$lte', field: 'completed_on', ref: '$now' }); + expect(findCrossFieldComparand({ $not: PAST_GRACE })).toEqual({ op: '$gt', field: 'completed_on', ref: '$now' }); + expect(findCrossFieldComparand({ $and: [{ title: 'a' }, { $or: [{ title: 'b' }, { completed_at: { $lt: { $now: CROSS_FIELD_NOW, addDays: -3 } } }] }] })) + .toEqual({ op: '$lt', field: 'completed_at', ref: '$now' }); + // Positive control for the walk: a literal comparand routes nothing. + expect(findCrossFieldComparand({ completed_on: { $lte: '2026-03-01' } })).toBeNull(); + }); + + it('the temporal-comparand door never judges it — an anchor is not a literal', () => { + const kindOf = (member: string) => (member === 'completed_at' ? 'datetime' : member === 'completed_on' ? 'date' : null); + expect(findUninterpretableTemporalMember({ completed_at: { $lte: { $now: true, addDays: GRACE } } }, kindOf)).toBeNull(); + expect(findUninterpretableTemporalMember({ completed_on: { $lte: { $now: CROSS_FIELD_NOW, addDays: 5 } } }, kindOf)).toBeNull(); + expect(findUninterpretableTemporalMember({ completed_at: { $lte: 'soon' } }, kindOf)) + .toEqual({ field: 'completed_at', kind: 'datetime', value: 'soon' }); + }); +}); + +// ── The road: real engine at the end of it ────────────────────────────────── + +const CUBE: Cube = { + name: 'tasks', + sql: OBJECT, + measures: { n: { sql: '*', type: 'count', title: 'n' } }, + dimensions: Object.fromEntries( + Object.keys(CROSS_FIELD_OFFSET_OBJECT_FIELDS).map((n) => [n, { label: n, type: 'string', sql: n }]), + ), + public: true, +} as unknown as Cube; + +/** The ruling's dataset: within-grace and past-grace counts against NOW, and the rate. */ +const TASK_HEALTH = DatasetSchema.parse({ + name: 'task_health', + label: 'Task health', + object: OBJECT, + include: [], + dimensions: [{ name: 'title', field: 'title', type: 'string' }], + measures: [ + { name: 'total', aggregate: 'count' }, + { name: 'within_grace', aggregate: 'count', filter: WITHIN_GRACE }, + { name: 'past_grace', aggregate: 'count', filter: PAST_GRACE }, + { name: 'within_grace_at', aggregate: 'count', filter: { completed_at: { $lte: { $now: true, addDays: GRACE } } } }, + { name: 'within_rate', derived: { op: 'ratio', of: ['within_grace', 'total'] } }, + ], +}); + +/** The same dataset, SCOPED to the past-grace slice by its own filter. */ +const OVERDUE_ONLY = DatasetSchema.parse({ + ...TASK_HEALTH, + name: 'overdue_tasks', + filter: PAST_GRACE, +}); + +function bridge(driver: SqliteWasmDriver) { + return async (objectName: string, options: { filter?: unknown; groupBy?: unknown; aggregations?: Array<{ field: string; method: string; alias: string }> }) => { + const query: DriverQuery = { + where: options.filter as FilterCondition, + groupBy: options.groupBy as DriverQuery['groupBy'], + aggregations: options.aggregations?.map(({ field, method, alias }) => ({ + field, + function: method as AggregationNode['function'], + alias, + })), + }; + return (await driver.aggregate(objectName, query)) as Record[]; + }; +} + +describe('[#25808] a now-anchored comparand on the analytics face — served via the engine fallback', () => { + let driver: SqliteWasmDriver; + let service: AnalyticsService; + let rawSqlCalls: string[]; + let readScope: FilterCondition | null; + + beforeAll(async () => { + vi.useFakeTimers({ toFake: ['Date'], now: new Date(CROSS_FIELD_NOW) }); + driver = new SqliteWasmDriver({ filename: ':memory:' }); + await driver.initObjects([{ name: OBJECT, fields: CROSS_FIELD_OFFSET_OBJECT_FIELDS } as any]); + for (const row of CROSS_FIELD_OFFSET_ROWS) await driver.create(OBJECT, { ...row }); + + rawSqlCalls = []; + readScope = null; + service = new AnalyticsService({ + cubes: [CUBE], + debugSql: true, + // BOTH paths available: native SQL wins `resolveStrategy` unless it + // declines, so `executeRawSql` never being called MEASURES the decline. + queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: true, inMemory: false }), + executeRawSql: async (_object, sql) => { + rawSqlCalls.push(sql); + return []; + }, + executeAggregate: bridge(driver), + getReadScope: () => readScope ?? undefined, + }); + }); + + afterAll(async () => { + vi.useRealTimers(); + await driver?.disconnect?.(); + }); + + const idsFor = async (query: Partial): Promise => { + const result = await service.query({ + cube: 'tasks', + dimensions: ['id'], + measures: ['n'], + ...query, + } as AnalyticsQuery); + return result.rows.map((r) => String(r.id)).sort(); + }; + + const errorFrom = async (run: () => Promise): Promise => { + let returned: unknown; + try { + returned = await run(); + } catch (e) { + return e as WireBearingError; + } + throw new Error(`expected a refusal, but the analytics face returned ${JSON.stringify(returned)}`); + }; + + describe('through the caller\'s `where` — the corpus\'s own row set, native SQL declined', () => { + for (const testCase of CROSS_FIELD_NOW_CASES) { + it(testCase.name, async () => { + rawSqlCalls = []; + const ids = await idsFor({ where: testCase.filter as FilterCondition }); + expect(ids, testCase.note).toEqual([...testCase.expected].sort()); + expect(rawSqlCalls, 'NativeSQLStrategy did not decline').toEqual([]); + }); + } + }); + + describe('through an RLS read scope — the other door to the same engine', () => { + for (const testCase of CROSS_FIELD_NOW_CASES.filter((c) => /COLUMN offset|NEGATIVE literal offset \(-3\)|NOT of \$lte|\$now: true/.test(c.name))) { + it(testCase.name, async () => { + rawSqlCalls = []; + readScope = testCase.filter as FilterCondition; + try { + expect(await idsFor({}), testCase.note).toEqual([...testCase.expected].sort()); + expect(rawSqlCalls, 'NativeSQLStrategy did not decline').toEqual([]); + } finally { + readScope = null; + } + }); + } + }); + + describe('the anchor refusal arm still bites after the routing', () => { + for (const refusal of CROSS_FIELD_NOW_REFUSALS) { + it(`${refusal.name} → INVALID_FILTER / 400`, async () => { + rawSqlCalls = []; + const err = await errorFrom(() => idsFor({ where: refusal.filter as FilterCondition })); + expect(err.code).toBe('INVALID_FILTER'); + expect(err.status).toBe(400); + expect(err).not.toBeInstanceOf(TypeError); + expect(err.message).not.toContain('can only bind'); + expect(rawSqlCalls).toEqual([]); + }); + } + + it('a refused read scope stays REDACTED and never degrades to an unscoped read', async () => { + rawSqlCalls = []; + readScope = { completed_on: { $lte: { $now: true, addDays: { $field: 'organization_id' } } } } as FilterCondition; + try { + const err = await errorFrom(() => idsFor({})); + expect(err.code).toBe('INVALID_FILTER'); + expect(err.status).toBe(400); + for (const column of CROSS_FIELD_OFFSET_OPERAND_NAMES) { + expect(err.message, `policy refusal names "${column}"`).not.toContain(column); + } + expect(rawSqlCalls).toEqual([]); + } finally { + readScope = null; + } + }); + }); + + describe('as a DATASET measure filter and a DATASET filter — authored `$now: true`, pinned by the service', () => { + it('routes the anchored passes to the engine and counts them; the plain count stays native', async () => { + rawSqlCalls = []; + const result = await service.queryDataset( + TASK_HEALTH, + { measures: ['total', 'within_grace', 'past_grace'] }, + CTX, + ); + expect(rawSqlCalls).toHaveLength(1); + expect(rawSqlCalls[0]).toMatch(/count/i); + expect(result.rows).toHaveLength(1); + // NOW is 03-03: rows 1, 2, 3, 6, 9 within now + grace; rows 4, 8 past it. + expect(Number(result.rows[0].within_grace)).toBe(5); + expect(Number(result.rows[0].past_grace)).toBe(2); + }); + + it('a dataset filter carrying the anchor scopes every measure — the whole query leaves native SQL', async () => { + rawSqlCalls = []; + const result = await service.queryDataset( + OVERDUE_ONLY, + { measures: ['total', 'within_grace'] }, + CTX, + ); + expect(rawSqlCalls, 'NativeSQLStrategy did not decline the scoped query').toEqual([]); + expect(result.rows).toHaveLength(1); + expect(Number(result.rows[0].total)).toBe(2); + expect(Number(result.rows[0].within_grace)).toBe(0); + }); + }); +}); + +describe('[#25808] the dataset, valued end to end on the engine path', () => { + let driver: SqliteWasmDriver; + let service: AnalyticsService; + + beforeAll(async () => { + vi.useFakeTimers({ toFake: ['Date'], now: new Date(CROSS_FIELD_NOW) }); + driver = new SqliteWasmDriver({ filename: ':memory:' }); + await driver.initObjects([{ name: OBJECT, fields: CROSS_FIELD_OFFSET_OBJECT_FIELDS } as any]); + for (const row of CROSS_FIELD_OFFSET_ROWS) await driver.create(OBJECT, { ...row }); + service = new AnalyticsService({ + queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }), + executeAggregate: bridge(driver), + }); + }); + + afterAll(async () => { + vi.useRealTimers(); + await driver?.disconnect?.(); + }); + + it('answers the within-grace count, the past-grace count, the datetime twin and the derived rate', async () => { + const result = await service.queryDataset( + TASK_HEALTH, + { measures: ['total', 'within_grace', 'past_grace', 'within_grace_at', 'within_rate'] }, + CTX, + ); + expect(result.rows).toHaveLength(1); + const row = result.rows[0]; + expect(Number(row.total)).toBe(CROSS_FIELD_OFFSET_ROWS.length); + expect(Number(row.within_grace)).toBe(5); + expect(Number(row.past_grace)).toBe(2); + // The datetime pair answers the same count — class-independent at the fixture's time of day. + expect(Number(row.within_grace_at)).toBe(5); + expect(Number(row.within_rate)).toBeCloseTo(5 / CROSS_FIELD_OFFSET_ROWS.length, 10); + }); + + it('groups the same measures by a dimension without losing the anchor', async () => { + const result = await service.queryDataset( + TASK_HEALTH, + { dimensions: ['title'], measures: ['within_grace', 'past_grace'] }, + CTX, + ); + const byTitle = new Map(result.rows.map((r) => [r.title, r])); + expect(Number(byTitle.get('a')?.within_grace ?? 0)).toBe(1); // row 1: on the last day of grace + expect(Number(byTitle.get('d')?.past_grace ?? 0)).toBe(1); // row 4: NULL grace is zero days + expect(Number(byTitle.get('h')?.past_grace ?? 0)).toBe(1); // row 8 + expect(Number(byTitle.get('e')?.within_grace ?? 0)).toBe(0); // row 5: never completed + expect(Number(byTitle.get('e')?.past_grace ?? 0)).toBe(0); + }); + + it('the dotted offset spelling validates at the schema door and is refused by SQL push-down by name', async () => { + const dotted = { completed_on: { $lte: { $now: true, addDays: { $field: 'duty.sla_days' } } } }; + expect(DatasetSchema.safeParse({ + ...TASK_HEALTH, + measures: [{ name: 'within_grace', aggregate: 'count', filter: dotted }], + }).success).toBe(true); + const err = await (async () => { + try { + await service.queryDataset( + { ...TASK_HEALTH, measures: [{ name: 'within_grace', aggregate: 'count', filter: dotted }] }, + { measures: ['within_grace'] }, + CTX, + ); + } catch (e) { + return e as WireBearingError; + } + return null; + })(); + expect(err).not.toBeNull(); + expect(err!.code).toBe('INVALID_FILTER'); + expect(err!.status).toBe(400); + // The dataset's own filter is the author's (#8220 provenance), so the + // refusal may name the boundary it hit. + expect(err!.message).toContain('dotted path'); + }); +}); diff --git a/packages/services/service-analytics/src/comparand-shape.ts b/packages/services/service-analytics/src/comparand-shape.ts index b509ccc9010..8c5b65d5aac 100644 --- a/packages/services/service-analytics/src/comparand-shape.ts +++ b/packages/services/service-analytics/src/comparand-shape.ts @@ -270,6 +270,23 @@ export function isFieldReference(value: unknown): value is { $field: string } { return typeof (value as Record).$field === 'string'; } +/** + * [#25808] A `{ $now, addDays }` now-anchored comparand (`NowAnchorSchema`; + * maintainer ruling 2026-10-26, letter A) — `$now` is `true` as authored, or + * the ISO instant the token resolver pinned it to before any strategy read the + * query. Routed exactly as a `{ $field }` reference is + * ({@link findCrossFieldComparand}): the native SQL compiler would bind the + * anchor object as the comparison's value, and the driver alone holds the + * target column's class and the offset column's declaration, which the + * anchor's compile rules read. Mirrors `driver-sql`'s `nowAnchorOf` on the + * shape — an object carrying `$now` as `true` or a string. + */ +export function isNowAnchor(value: unknown): value is { $now: true | string } { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const raw = (value as Record).$now; + return raw === true || typeof raw === 'string'; +} + /** * [#7598] The comparison operators whose whole comparand `driver-sql` compiles * into a same-table column-to-column comparison since #5222 — and exactly the @@ -355,6 +372,11 @@ function findIn( if (CROSS_FIELD_COMPARISON_OPERATORS.has(key) && isFieldReference(value)) { return { op: key, field, ref: value.$field }; } + // [#25808] A now-anchored comparand routes the same way; `ref` names the + // anchor rather than a column, and the message reads it so. + if (CROSS_FIELD_COMPARISON_OPERATORS.has(key) && isNowAnchor(value)) { + return { op: key, field, ref: '$now' }; + } // A `$`-prefixed key is an operator or a combinator, so the FIELD in scope // does not change; anything else names a field (or a nested relation // member) and becomes the new scope. Only used for the message. diff --git a/packages/services/service-analytics/src/strategies/native-sql-strategy.ts b/packages/services/service-analytics/src/strategies/native-sql-strategy.ts index be3f6a80523..7de7823462d 100644 --- a/packages/services/service-analytics/src/strategies/native-sql-strategy.ts +++ b/packages/services/service-analytics/src/strategies/native-sql-strategy.ts @@ -1163,9 +1163,11 @@ export class NativeSQLStrategy implements AnalyticsStrategy { private assertNoCrossFieldComparison(query: AnalyticsQuery, ctx: StrategyContext): void { const hit = this.crossFieldComparisonIn(query, ctx); if (!hit) return; + const comparand = hit.ref === '$now' + ? 'a now-anchored comparand { "$now": true, "addDays": … }' + : `a field reference { "$field": "${hit.ref}" }`; throw new Error( - `[native-sql-strategy] ${hit.source} carries a field reference ` + - `{ "$field": "${hit.ref}" } under "${hit.op}" on "${hit.field}", which this strategy does not ` + + `[native-sql-strategy] ${hit.source} carries ${comparand} under "${hit.op}" on "${hit.field}", which this strategy does not ` + `compile into a column-to-column comparison — it would BIND the reference object as the ` + `comparison's value and answer a wrong row set silently. \`canHandle\` declines such a ` + `query so it routes to the ObjectQL/engine path, whose driver compiles it and enforces the ` + diff --git a/packages/services/service-analytics/src/strategies/objectql-strategy.ts b/packages/services/service-analytics/src/strategies/objectql-strategy.ts index 65cd9e8483c..403733ac2b0 100644 --- a/packages/services/service-analytics/src/strategies/objectql-strategy.ts +++ b/packages/services/service-analytics/src/strategies/objectql-strategy.ts @@ -19,7 +19,7 @@ import { SQL_CONST_TRUE, type NormalizedFilterNode, } from './filter-normalizer.js'; -import { findCrossFieldComparand, isFieldReference } from '../comparand-shape.js'; +import { findCrossFieldComparand, isFieldReference, isNowAnchor } from '../comparand-shape.js'; import { assertReadScopeAdmittedByEngine, assertReadScopeCannotVacate, @@ -2190,7 +2190,10 @@ export class ObjectQLStrategy implements AnalyticsStrategy { // would fail on its single most important spelling. Its five siblings // (`$ne`/`$gt`/`$gte`/`$lt`/`$lte`) were never affected: they emit their // operator explicitly two lines down. - case 'equals': return isFieldReference(v0) ? { $eq: v0 } : v0; + // [#25808] …and for a now-anchored comparand, for the same reason: the + // bare `{ at: { $now, addDays } }` is a field-spec object whose keys no + // backend reads as an equality; `{ $eq: … }` is the spelling that compiles. + case 'equals': return isFieldReference(v0) || isNowAnchor(v0) ? { $eq: v0 } : v0; case 'notEquals': return { $ne: v0 }; case 'gt': return { $gt: v0 }; case 'gte': return { $gte: v0 }; diff --git a/packages/spec/api-surface/data.json b/packages/spec/api-surface/data.json index 50d6a678ce0..bfcdc039fc3 100644 --- a/packages/spec/api-surface/data.json +++ b/packages/spec/api-surface/data.json @@ -510,6 +510,8 @@ "NonNumericValueForm (type)", "NormalizedFilter (type)", "NormalizedFilterSchema (const)", + "NowAnchor (type)", + "NowAnchorSchema (const)", "NumberComparandDoorCase (type)", "NumberComparandDoorDeferredCase (interface)", "NumberComparandDoorFieldMeta (interface)", diff --git a/packages/spec/authorable-surface/data.json b/packages/spec/authorable-surface/data.json index dfecf95f28c..35c73ce4af7 100644 --- a/packages/spec/authorable-surface/data.json +++ b/packages/spec/authorable-surface/data.json @@ -650,6 +650,8 @@ "data/NormalizedFilter:$and", "data/NormalizedFilter:$not", "data/NormalizedFilter:$or", + "data/NowAnchor:$now", + "data/NowAnchor:addDays", "data/Object:_lock", "data/Object:_lockDocsUrl", "data/Object:_lockReason", diff --git a/packages/spec/declaration-map/data.json b/packages/spec/declaration-map/data.json index 506e1c65832..043791ee181 100644 --- a/packages/spec/declaration-map/data.json +++ b/packages/spec/declaration-map/data.json @@ -219,6 +219,8 @@ "NoSQLTransactionOptionsSchema": "data/NoSQLTransactionOptions", "NormalizedFilter": "data/NormalizedFilter", "NormalizedFilterSchema": "data/NormalizedFilter", + "NowAnchor": "data/NowAnchor", + "NowAnchorSchema": "data/NowAnchor", "ObjectAccessConfig": "data/ObjectAccessConfig", "ObjectAccessConfigSchema": "data/ObjectAccessConfig", "ObjectCapabilities": "data/ObjectCapabilities", diff --git a/packages/spec/export-origins/data.json b/packages/spec/export-origins/data.json index 587103d9cef..e4c9ccd6ee7 100644 --- a/packages/spec/export-origins/data.json +++ b/packages/spec/export-origins/data.json @@ -500,6 +500,8 @@ "NonNumericValueForm": "src/data/filter-number-comparand-declared-type.ts#NonNumericValueForm (type)", "NormalizedFilter": "src/data/filter.zod.ts#NormalizedFilter (type)", "NormalizedFilterSchema": "src/data/filter.zod.ts#NormalizedFilterSchema (const)", + "NowAnchor": "src/data/filter.zod.ts#NowAnchor (type)", + "NowAnchorSchema": "src/data/filter.zod.ts#NowAnchorSchema (const)", "NumberComparandDoorCase": "src/data/filter-number-comparand-declared-type.ts#NumberComparandDoorCase (type)", "NumberComparandDoorDeferredCase": "src/data/filter-number-comparand-declared-type.ts#NumberComparandDoorDeferredCase (interface)", "NumberComparandDoorFieldMeta": "src/data/filter-number-comparand-declared-type.ts#NumberComparandDoorFieldMeta (interface)", diff --git a/packages/spec/json-schema.manifest/data.json b/packages/spec/json-schema.manifest/data.json index 6391af09b01..6219a208268 100644 --- a/packages/spec/json-schema.manifest/data.json +++ b/packages/spec/json-schema.manifest/data.json @@ -120,6 +120,7 @@ "data/NoSQLQueryOptions", "data/NoSQLTransactionOptions", "data/NormalizedFilter", + "data/NowAnchor", "data/Object", "data/ObjectAccessConfig", "data/ObjectCapabilities", diff --git a/packages/spec/src/data/filter-comparand-type.ts b/packages/spec/src/data/filter-comparand-type.ts index b053eff02d3..67aaf23c18e 100644 --- a/packages/spec/src/data/filter-comparand-type.ts +++ b/packages/spec/src/data/filter-comparand-type.ts @@ -82,7 +82,9 @@ * literal, and its per-position legality is ruled elsewhere (#5222 compiles * it on the ordering operators; #7596 rules it out of list members by name; * #7597 pins the hand-authored implicit form's fate). The door steps around - * every one of those decisions. + * every one of those decisions. [#25808] A `$now` anchor + * (`{ $now: true, addDays }`, `NowAnchorSchema`) the same way, and for the + * same reason: it is legal exactly where a reference is. * - **Arrays outside `$in`/`$nin`/`$between`.** An array in an implicit or * scalar-operator position is answered per driver today (`driver-sql` and * `driver-memory` refuse it, each with its own message; `driver-mongodb` @@ -209,6 +211,46 @@ function isFieldReference(value: unknown): boolean { return isFilterNode(value) && typeof value.$field === 'string'; } +/** + * [#25808] A `NowAnchorSchema` comparand — `{ $now: true, addDays }`, or the + * resolved form the engine hands the executors (`$now` holding the pinned ISO + * instant). Not a literal either: its legality per position is the anchor's + * own ruling (the six scalar comparisons, as a `{ $field }` reference), and a + * malformed one is refused by its own schema sentence at the authoring door + * and by each executor at query time. Matches `driver-sql`'s `nowAnchorOf` on + * what an anchor IS: a non-array object whose `$now` is `true` or a string. + */ +function isNowAnchor(value: unknown): boolean { + return isFilterNode(value) && (value.$now === true || typeof value.$now === 'string'); +} + +/** + * [#25808] Is this anchor WELL-FORMED — `addDays` present, an integer or a + * `{ $field: string }`? The door steps around a well-formed anchor as it steps + * around a reference; a malformed one (`{ $now: true }` with no offset, a + * fractional or string offset) is refused HERE in the anchor's own words, + * because the `FilterCondition` door runs this walk and not the operator + * schemas, and an exemption by `$now` alone would have let the malformed shape + * through to the executors as a plain object they each refuse differently. + */ +function isWellFormedNowAnchor(value: Record): boolean { + const offset = value.addDays; + if (typeof offset === 'number') return Number.isInteger(offset); + return isFilterNode(offset) && typeof offset.$field === 'string'; +} + +function malformedNowAnchorRefusal(context: string | undefined, path: string, value: Record): Error { + const detail = value.addDays === undefined + ? 'it carries no addDays — for the unshifted instant write the "{now}" placeholder' + : `its addDays (${preview(value.addDays)}) is neither an integer nor a { "$field": "numeric_column" } reference`; + return invalidComparandError( + context, + `Filter comparand at ${path} is a malformed now-anchored comparand (${preview(value)}): ${detail}. ` + + `A now-anchored comparand is { "$now": true, "addDays": }: the current ` + + `instant shifted by whole days. ${NOT_APPLIED}`, + ); +} + /** The word the refusal uses for the offending value's type. */ function describeComparandType(value: unknown): string { if (value === null) return 'null'; @@ -326,6 +368,11 @@ function judgeLiteralComparand( // those before asking. What reaches this line is a non-plain object // (Map / Set / class instance), a Symbol or a function. if (Array.isArray(value) || isFieldReference(value)) return { kind: 'keep' }; + if (isNowAnchor(value)) { + return isWellFormedNowAnchor(value as Record) + ? { kind: 'keep' } + : { kind: 'refuse', error: malformedNowAnchorRefusal(context, path, value as Record) }; + } return { kind: 'refuse', error: comparandTypeRefusal(context, path, value) }; } diff --git a/packages/spec/src/data/filter-lowering.ts b/packages/spec/src/data/filter-lowering.ts index 04ab3eec135..f5b43ee437a 100644 --- a/packages/spec/src/data/filter-lowering.ts +++ b/packages/spec/src/data/filter-lowering.ts @@ -127,6 +127,17 @@ function isFieldReference(value: unknown): boolean { return isFilterNode(value) && typeof value.$field === 'string'; } +/** + * [#25808] `{ $now: true, addDays }` — the now-anchored comparand + * (`NowAnchorSchema`), authored (`true`) or resolved (the pinned instant). + * Compiled TOTAL on the six scalar comparisons exactly as a reference is + * (`driver-sql` writes the NULL conjuncts into the predicate; the memory + * evaluator answers two-valuedly), so rule 3 leaves it alone the same way. + */ +function isNowAnchor(value: unknown): boolean { + return isFilterNode(value) && (value.$now === true || typeof value.$now === 'string'); +} + /** An operator map: a plain object with at least one `$` key. */ function isOperatorMap(spec: unknown): spec is Record { return isFilterNode(spec) && !isFieldReference(spec) && Object.keys(spec).some((k) => k.startsWith('$')); @@ -378,7 +389,7 @@ const CROSS_FIELD_COMPARISON_OPERATORS: ReadonlySet = new Set([ * UNKNOWN? The copies' `operatorIsNullTotal`, cell for cell. */ function operatorIsNullTotal(op: string, value: unknown): boolean { - if (CROSS_FIELD_COMPARISON_OPERATORS.has(op) && isFieldReference(value)) return true; + if (CROSS_FIELD_COMPARISON_OPERATORS.has(op) && (isFieldReference(value) || isNowAnchor(value))) return true; switch (op) { case '$null': case '$exists': @@ -416,7 +427,7 @@ function nullGuardForFieldSpec(spec: unknown): NullGuard { * `IS NOT NULL`, and a `$ne: { $field }` is the total column comparison. */ function isNegativePolarityOperator(op: string, value: unknown): boolean { - if (op === '$ne') return value !== null && !isFieldReference(value); + if (op === '$ne') return value !== null && !isFieldReference(value) && !isNowAnchor(value); return op === '$nin' || op === '$notContains'; } diff --git a/packages/spec/src/data/filter.test.ts b/packages/spec/src/data/filter.test.ts index 86a7a7b73db..cf9c39e732c 100644 --- a/packages/spec/src/data/filter.test.ts +++ b/packages/spec/src/data/filter.test.ts @@ -7,6 +7,7 @@ import { QueryFilterSchema, FieldOperatorsSchema, FieldReferenceSchema, + NowAnchorSchema, EqualityOperatorSchema, ComparisonOperatorSchema, SetOperatorSchema, @@ -223,6 +224,92 @@ describe('ComparisonOperatorSchema', () => { // 3.2 Set & Range Operators Tests // ============================================================================ +// ============================================================================ +// [#25808] NowAnchorSchema — { $now: true, addDays } +// ============================================================================ + +describe('[#25808] NowAnchorSchema — the now-anchored comparand', () => { + const GRACE = { $field: 'review_sla_days_offset' }; + + it('accepts the two offset arms: an integer literal of any sign, a { $field } reference', () => { + expect(NowAnchorSchema.safeParse({ $now: true, addDays: -30 }).success).toBe(true); + expect(NowAnchorSchema.safeParse({ $now: true, addDays: 0 }).success).toBe(true); + expect(NowAnchorSchema.safeParse({ $now: true, addDays: GRACE }).success).toBe(true); + }); + + it('addDays is REQUIRED — `{now}` is the spelling for the unshifted instant', () => { + const result = NowAnchorSchema.safeParse({ $now: true }); + expect(result.success).toBe(false); + expect(result.error?.issues[0]?.message).toContain('addDays'); + expect(result.error?.issues[0]?.message).toContain('{now}'); + }); + + it('$now is the literal true — false, a string, 1 are refused', () => { + for (const bad of [false, 'now', 1, null]) { + const result = NowAnchorSchema.safeParse({ $now: bad, addDays: 1 }); + expect(result.success, String(bad)).toBe(false); + expect(result.error?.issues[0]?.message).toContain('literal true'); + } + }); + + it('a malformed offset is refused in addDays\' own words — fractional, string, an object without $field', () => { + for (const bad of [1.5, '5', { days: 5 }, { $field: 42 }]) { + const result = NowAnchorSchema.safeParse({ $now: true, addDays: bad }); + expect(result.success, JSON.stringify(bad)).toBe(false); + expect(result.error?.issues[0]?.message).toContain('addDays'); + } + }); + + it('is closed: an extra key is refused — there is no subDays', () => { + expect(NowAnchorSchema.safeParse({ $now: true, addDays: 1, subDays: 1 }).success).toBe(false); + expect(NowAnchorSchema.safeParse({ $now: true, addDays: -1, note: 'x' }).success).toBe(false); + }); + + it('is legal exactly where a { $field } reference is: the six scalar comparisons', () => { + for (const op of ['$eq', '$ne', '$gt', '$gte', '$lt', '$lte']) { + const result = FilterConditionSchema.safeParse({ opened_at: { [op]: { $now: true, addDays: GRACE } } }); + expect(result.success, op).toBe(true); + } + }); + + it('…and refused as an $in / $nin member and a $between endpoint by the operator schemas, as a reference is', () => { + expect(SetOperatorSchema.safeParse({ $in: [{ $now: true, addDays: 1 }] }).success).toBe(false); + expect(SetOperatorSchema.safeParse({ $nin: [{ $now: true, addDays: 1 }] }).success).toBe(false); + expect(RangeOperatorSchema.safeParse({ $between: [{ $now: true, addDays: -7 }, '2026-12-31'] }).success).toBe(false); + // The `FilterCondition` door's parity with `{ $field }`: both pass it in a + // list slot (it judges comparand TYPES, not positions) and every executor + // refuses the position by name — pinned on the drivers' conformance corpus. + expect(FilterConditionSchema.safeParse({ opened_at: { $in: [{ $field: 'other' }] } }).success).toBe(true); + expect(FilterConditionSchema.safeParse({ opened_at: { $in: [{ $now: true, addDays: 1 }] } }).success).toBe(true); + }); + + it('a malformed anchor in a comparison slot is refused in the anchor\'s own words, not as a plain object', () => { + const missing = ComparisonOperatorSchema.safeParse({ $lt: { $now: true } }); + expect(missing.success).toBe(false); + expect(missing.error?.issues[0]?.message).toContain('addDays'); + expect(missing.error?.issues[0]?.message).toContain('{now}'); + const flag = ComparisonOperatorSchema.safeParse({ $lt: { $now: 'yes', addDays: 1 } }); + expect(flag.success).toBe(false); + expect(flag.error?.issues[0]?.message).toContain('literal true'); + // The `FilterCondition` door (the comparand-TYPE walk) refuses the same + // malformations in the anchor's words too, never as "a plain object". + const atDoor = FilterConditionSchema.safeParse({ opened_at: { $lt: { $now: true } } }); + expect(atDoor.success).toBe(false); + expect(JSON.stringify(atDoor.error?.issues)).toContain('{now}'); + const fractional = FilterConditionSchema.safeParse({ opened_at: { $lt: { $now: true, addDays: 1.5 } } }); + expect(fractional.success).toBe(false); + expect(JSON.stringify(fractional.error?.issues)).toContain('neither an integer'); + }); + + it('an equality triple with an anchored comparand lowers to the explicit $eq, as a reference does', () => { + const anchor = { $now: true, addDays: 0 }; + for (const op of ['=', '==', 'equals', 'eq']) { + expect(parseFilterAST(['opened_on', op, anchor] as any), op).toEqual({ opened_on: { $eq: anchor } }); + } + expect(parseFilterAST(['opened_on', '<', anchor] as any)).toEqual({ opened_on: { $lt: anchor } }); + }); +}); + describe('SetOperatorSchema', () => { it('should accept $in operator with array', () => { const filter = { $in: ['admin', 'editor', 'viewer'] }; diff --git a/packages/spec/src/data/filter.zod.ts b/packages/spec/src/data/filter.zod.ts index ce9cca8e2a4..233f69314ff 100644 --- a/packages/spec/src/data/filter.zod.ts +++ b/packages/spec/src/data/filter.zod.ts @@ -125,6 +125,68 @@ import { OPERATOR_PREFIX_KEY_PATTERN, bannedKeyPattern } from '../shared/refinem * matches; the shared corpus carries the literal, column, negative, NULL-offset, * NULL-base and `$not`-wrapped rows on both. * + * ## `$now` — the current instant, shifted by whole days (#25808, ruled 2026-10-26) + * + * A dataset could not count "reviews started more than N days ago, where N is + * the SLA held in another column of the same record": `addDays` puts a + * whole-day offset on a COLUMN, and the now-anchored placeholders + * (`{N_days_ago}` and kin) take a literal N. The ruling (letter A) adds the + * symmetric anchor — {@link NowAnchorSchema}: + * + * @example + * // review_started_at < now + review_sla_days_offset — the "overdue by + * // type" count, the column holding the NEGATIVE number of SLA days + * { "$lt": { "$now": true, "addDays": { "$field": "review_sla_days_offset" } } } + * + * @example + * // created_at >= now - 30 days, with its time of day — unlike "{30_days_ago}", + * // which names the calendar DAY thirty days back + * { "$gte": { "$now": true, "addDays": -30 } } + * + * `$now: true` is the instant the query runs at — ONE instant for the whole + * filter, pinned by the engine's placeholder resolver beside `{now}` — and + * `addDays` is the offset grammar `FieldReferenceSchema.addDays` already + * declares, sign rule included: an integer literal of any sign (a negative + * value subtracts; there is no `subDays`), or a nested `{ $field }` naming a + * NUMERIC column of the SAME record, whose value is added as whole days (so + * "now minus the column" is a column that holds a negative number). `addDays` + * is REQUIRED: the unshifted instant already has a spelling, the `{now}` + * placeholder, and this anchor exists for the offset. It is legal exactly + * where a `{ $field }` reference is — the whole comparand of the six scalar + * comparisons — and refused in every list position for the same reason. + * + * Execution, on both paths and held to the same rows by the shared corpus + * (`cross-field-conformance-cases.ts`, the now-anchor section): + * + * - **The target column decides the form.** Against a `datetime` column the + * shifted instant keeps its time of day; against a `date` column it is read + * as its UTC calendar day — the reading every instant comparand gets on a + * `date` column — and shifted day for day. The anchor applies to a `date` + * or `datetime` target only: on a number, a text or a `time`, day + * arithmetic has no meaning and the comparison is refused loudly. + * - **A NULL offset column contributes ZERO days** (`COALESCE`, as for + * `addDays` on a reference): a row with no SLA days counts as overdue the + * moment the anchor passes. The anchor itself is never NULL, so the + * comparison is total: a NULL target fails the orderings and `$eq`, and + * satisfies `$ne`; `$not` is the exact complement. + * - **SQL push-down** (`@objectstack/driver-sql`, on SQLite, PostgreSQL and + * MySQL) binds the instant and compiles `instant + offset days` through the + * same per-dialect day arithmetic as the reference offset; the offset column + * rides the 2026-08-06 rulings as it stands — same-table (a dotted path is + * refused), declared, never the tenant-isolation column, NUMERIC class. + * `NativeSQLStrategy` declines the anchor as it declines every cross-field + * comparand (2026-08-12), so a dataset `filter` and a measure `filter` + * reach the engine path, where the driver compiles it. + * - **The memory evaluator** (`matchesFilter`) adds the days to the instant in + * the target value's own form; `driver-mongodb` refuses the anchor as it + * refuses a `{ $field }` reference. + * + * Both standing rulings hold unchanged: the offset column is a column of the + * SAME record (letter A; a path-valued offset — B′ — was not taken), and no + * JOIN is planned for it. + * + * @see https://github.com/objectstack-ai/objectstack/issues/25808 (the `$now` anchor) + * * ## LIST positions are NOT part of this declaration (#7596, ruled 2026-08-11) * * A reference may be the whole comparand of a scalar comparison. It may **not** @@ -237,6 +299,91 @@ function fieldReferenceIssueMessage(input: unknown): string | undefined { export type FieldReference = z.input; +/** + * [#25808] The author-facing refusal for a `$now` anchor that fails its own + * declaration. Three defects, one sentence each, the prescription shared with + * {@link addDaysOffsetMessage}: `$now` is not the literal `true` (the anchor + * is a flag, never a value — an instant to compare against is written as a + * literal or as the `{now}` placeholder), the offset is missing (the + * unshifted instant is `{now}`), or the offset is malformed (the `addDays` + * sentence, verbatim). + */ +function nowAnchorMessage(input: unknown): string { + const prescription = + 'A now-anchored comparand is { "$now": true, "addDays": }: the current instant ' + + 'shifted by whole days, where addDays is an integer literal of any sign (a negative value ' + + 'subtracts) or a nested { "$field": "numeric_column" } reference to a column of the same ' + + 'record. For the unshifted instant write the "{now}" placeholder.'; + if (!input || typeof input !== 'object' || Array.isArray(input)) return prescription; + const node = input as Record; + if (node.$now !== true) { + return `$now must be the literal true (it is a flag, not a value), not ${JSON.stringify(node.$now)}. ${prescription}`; + } + if (!('addDays' in node) || node.addDays === undefined) { + return `A { "$now": true } comparand must carry an addDays offset. ${prescription}`; + } + return addDaysOffsetMessage(node.addDays); +} + +/** + * [#25808] A now-anchored comparand: the instant the query runs at, shifted by + * a whole-day offset — an integer literal of any sign, or a `{ $field }` + * reference to a numeric column of the same record. The offset is REQUIRED + * (the bare instant is the `{now}` placeholder). See the schema docblock + * above for the execution contract on both paths. + * + * Its own object rather than a `$field` variant: there is no referenced + * column, so none of a reference's column rules apply to the anchor itself — + * only to the offset column, through the same `addDays` grammar. + */ +export const NowAnchorSchema = lazySchema(() => closedObject(z.object({ + $now: z.literal(true, { + error: (issue) => nowAnchorMessage({ $now: issue.input, addDays: 0 }), + }).describe('The anchor flag: the instant the query runs at. Always the literal true.'), + addDays: z.union([z.number().int(), AddDaysReferenceSchema], { + error: (issue) => (issue.input === undefined + ? nowAnchorMessage({ $now: true }) + : addDaysOffsetMessage(issue.input)), + }).describe( + 'Whole-day offset added to the current instant before comparing — REQUIRED: an integer literal ' + + 'of any sign (negative subtracts; whole days only), or a { $field } reference to a numeric ' + + 'column of the same record. A NULL offset column contributes zero days. Against a datetime ' + + 'column the shifted instant keeps its time of day; against a date column it is read as its UTC ' + + 'calendar day. Compiles on SQL push-down on every dialect, with the offset column held to the ' + + 'same-table, declared, non-tenant, numeric rules; evaluates identically in memory.', + ), +}).strict()).describe( + 'A now-anchored comparand: { $now: true, addDays: } — the current instant ' + + 'shifted by whole days, the offset read from a numeric column of the same record or written as ' + + 'a literal. Legal exactly where a { $field } reference is.', +)); + +export type NowAnchor = z.input; + +/** + * [#25808] Is `value` shaped like a `$now` anchor — a non-array object carrying + * a `$now` key? SHAPE only, as {@link isFieldReferenceShape} is for a + * reference: the author's intent is recognised so a malformed anchor is + * refused by its own sentence rather than as a plain object. The resolved form + * the engine hands the executors (`$now` holding the pinned ISO instant) is + * the same shape. + */ +function isNowAnchorShape(value: unknown): boolean { + return !!value && typeof value === 'object' && !Array.isArray(value) && '$now' in value; +} + +/** + * [#25808] The message a scalar-comparison slot answers when a `$now` anchor + * fails ITS OWN schema — the twin of {@link fieldReferenceIssueMessage}, so + * the operator door repeats the anchor's pointed sentence instead of zod's + * generic union text. + */ +function nowAnchorIssueMessage(input: unknown): string | undefined { + if (!isNowAnchorShape(input)) return undefined; + const result = NowAnchorSchema.safeParse(input); + return result.success ? undefined : nowAnchorMessage(input); +} + // ============================================================================ // 3.1 Comparison Operators // ============================================================================ @@ -251,8 +398,9 @@ export type FieldReference = z.input; const EQ_DESCRIPTION = 'Equal to — the DEFAULT operator: a bare value written against a field key is the same ' + 'condition as this one. Lowered to `=` on the SQL family and to `$eq` on MongoDB. The ' - + 'comparand is a literal, or a { $field } reference to another column of the same table ' - + '($eq is one of the six scalar comparisons a reference may be the whole comparand of). ' + + 'comparand is a literal, a { $field } reference to another column of the same table, or a ' + + '{ $now: true, addDays } anchor ($eq is one of the six scalar comparisons a reference or an ' + + 'anchor may be the whole comparand of). ' + '`{ "$eq": null }` is the has-NO-value predicate — the same question `{ "$null": true }` ' + 'asks, and it is about the VALUE, never about whether a key is present.'; @@ -263,8 +411,9 @@ const EQ_DESCRIPTION = */ const NE_DESCRIPTION = 'Not equal to. Lowered to `<>` / `!=` on the SQL family and to `$ne` on MongoDB. The ' - + 'comparand is a literal, or a { $field } reference to another column of the same table ' - + '($ne is one of the six scalar comparisons a reference may be the whole comparand of). ' + + 'comparand is a literal, a { $field } reference to another column of the same table, or a ' + + '{ $now: true, addDays } anchor ($ne is one of the six scalar comparisons a reference or an ' + + 'anchor may be the whole comparand of). ' + '`{ "$ne": null }` is the HAS-A-VALUE predicate — the same question `{ "$exists": true }` ' + 'asks: it matches rows whose field holds a value, never rows that merely carry the key.'; @@ -351,7 +500,8 @@ export const EqualityOperatorSchema = lazySchema(() => z.object({ * docblock. */ const ORDERING_COMPARAND_DESCRIPTION = - 'Comparand is a number, a Date, a string, or a { $field } reference (optionally carrying a whole-day addDays offset). ' + 'Comparand is a number, a Date, a string, a { $field } reference (optionally carrying a whole-day addDays offset), ' + + 'or a { $now: true, addDays } anchor (the current instant shifted by whole days, the offset a literal or a numeric column of the same record). ' + 'STRING is the form the platform itself produces: the date-macro resolver ' + 'returns only strings ("{current_year_start}" -> "2026-01-01"), and the ' + 'guaranteed spellings are an ISO calendar day (YYYY-MM-DD), a UTC ISO-8601 ' @@ -485,12 +635,15 @@ function nullOrderingComparandMessage(op: string): string { * reachable surface still refusing the platform's own output). */ const orderingComparandSchema = (op: '$gt' | '$gte' | '$lt' | '$lte', label: string) => - z.union([z.number(), z.date(), z.string(), FieldReferenceSchema], { + z.union([z.number(), z.date(), z.string(), FieldReferenceSchema, NowAnchorSchema], { // [#14080] null gets the pointed null sentence; [#14104] a `{ $field }` // that fails its own schema (a bad `addDays`) gets the reference's own - // first sentence, so the author is told at the operator door too. + // first sentence, so the author is told at the operator door too; + // [#25808] a `$now` anchor that fails its own schema, the anchor's. error: (issue) => ( - issue.input === null ? nullOrderingComparandMessage(op) : fieldReferenceIssueMessage(issue.input) + issue.input === null + ? nullOrderingComparandMessage(op) + : fieldReferenceIssueMessage(issue.input) ?? nowAnchorIssueMessage(issue.input) ), }).optional().describe(`${label}. ${ORDERING_COMPARAND_DESCRIPTION}`); @@ -614,11 +767,25 @@ const setMembershipSchema = (op: '$in' | '$nin') => }); return; } - if (!isFieldReferenceShape(member)) return; + if (isFieldReferenceShape(member)) { + ctx.addIssue({ + code: 'custom', + path: [index], + message: listPositionFieldReferenceMessage(`${op} member at index ${index}`), + }); + return; + } + // [#25808] …and a now-anchored comparand, for the same reason: no + // backend resolves one inside a list, and every executor refuses the + // position by name. Ruled out here so an `$nin` exclusion the author + // wrote cannot be dropped by a position nothing evaluates. + if (!isNowAnchorShape(member)) return; ctx.addIssue({ code: 'custom', path: [index], - message: listPositionFieldReferenceMessage(`${op} member at index ${index}`), + message: `A { "$now": true, "addDays": … } comparand is not a valid ${op} member at index ${index}. ` + + 'It is legal exactly where a { "$field" } reference is — as the whole comparand of a scalar ' + + 'comparison ($eq/$ne/$gt/$gte/$lt/$lte) — never as a list member.', }); }); }); @@ -626,7 +793,8 @@ const setMembershipSchema = (op: '$in' | '$nin') => /** The `describe()` both `$in` and `$nin` carry, stating the two ruled-out member shapes. */ const SET_MEMBER_DESCRIPTION = 'Membership list. Members are literal values of any type the column stores. A ' - + '{ $field } reference is NOT a member shape: no backend resolves one inside a list' + + '{ $field } reference is NOT a member shape, nor is a { $now: true, addDays } anchor: no ' + + 'backend resolves either inside a list' + ' — put it in a scalar comparison ($eq/$ne/$gt/$gte/$lt/$lte) instead. null is NOT ' + 'a member shape either: state absence explicitly with the null predicate — ' + '"one of […] OR has no value" is { "$or": [{ "$in": […] }, { "$null": true }] }.'; @@ -2725,8 +2893,11 @@ function convertComparison(node: [string, string, unknown]): FilterCondition { // All four `$eq` spellings of {@link AST_OPERATOR_MAP} are covered — the // condition below is that key set, and `filter.test.ts` pins the two lists // together so a fifth spelling cannot enter the map and miss this branch. + // [#25808] …and a `$now` anchor, for the same reason: `{ at: { $now: true, + // addDays: 1 } }` reads as an operator spec named `$now`, which nothing + // implements, so the anchor is lowered to the explicit `$eq` too. if (op === '=' || op === '==' || op === 'equals' || op === 'eq') { - return isFieldReferenceComparand(value) + return isFieldReferenceComparand(value) || isNowAnchorShape(value) ? ({ [field]: { $eq: value } } as FilterCondition) : ({ [field]: value } as FilterCondition); } diff --git a/packages/spec/src/type-alias-convention.pin.test.ts b/packages/spec/src/type-alias-convention.pin.test.ts index 40c6c20ac41..330b26f8929 100644 --- a/packages/spec/src/type-alias-convention.pin.test.ts +++ b/packages/spec/src/type-alias-convention.pin.test.ts @@ -280,7 +280,7 @@ import type * as M191 from './automation/api-trigger-secret-ref.zod.js'; import type * as M192 from './data/column-apply-on.zod.js'; // --------------------------------------------------------------------------- -// 789 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. +// 790 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. // // That number is machine-checked, not hand-kept. The runtime companion at the // bottom of this file recomputes the pin count from the source and asserts that @@ -879,6 +879,7 @@ export type Iso_data_field__LocationCoordinatesSchema = Assert, z.infer< typeof M67.FieldOperatorsSchema > >>; export type Iso_data_filter__FieldReferenceSchema = Assert, z.infer< typeof M67.FieldReferenceSchema > >>; +export type Iso_data_filter__NowAnchorSchema = Assert, z.infer< typeof M67.NowAnchorSchema > >>; export type Iso_data_filter__QueryFilterSchema = Assert, z.infer< typeof M67.QueryFilterSchema > >>; // data/hook-body.zod.ts @@ -1710,7 +1711,7 @@ describe('ADR-0122 type-alias convention', () => { // this title and the section header above the pin list — are now asserted // against the recomputed count below, so neither can go stale without a red // test naming it. - it('still declares all 789 isomorphic pins', () => { + it('still declares all 790 isomorphic pins', () => { // The truth of each pin is proved by tsc, not here — an `Assert>` // that stops holding is a compile error with the alias named. What tsc // cannot notice is a pin that was DELETED: removing the assertion removes @@ -2600,7 +2601,13 @@ describe('ADR-0122 type-alias convention', () => { // (Iso_ui_dataset__DatasetMeasureDurationSchema, // Iso_ui_dataset__DatasetDurationUnit) rather than given `…Parsed` // synonyms. +2 added. - expect(pins).toHaveLength(789); + // + // 789 -> 790 is #25808: `NowAnchorSchema` (`data/filter.zod.ts`), the + // `{ $now: true, addDays }` now-anchored comparand — a literal flag and + // the `addDays` union `FieldReferenceSchema` already carries, no default + // and no transform — so it is pinned here (Iso_data_filter__NowAnchorSchema) + // rather than given a `…Parsed` synonym. +1 added. + expect(pins).toHaveLength(790); // The count is stated in PROSE twice as well — this case's title and the // section header above the pin list — and until commit c6b05c76a nothing read either