Skip to content

Commit ef96c9e

Browse files
fix(plugin-security): the RLS write check judges a date / datetime / time column in its stored form (#21235)
Fixes #21109 Clause-②: no Executes ruling A (comment 5933322270 on #21109): **the RLS write check judges the row as it will be stored.** Dispatched by the `domain:services` seat 2 PM under claim 5938708535, session `session_01DiCSbmJrkzNhuEAier4VoJ`. ## What changed - New `packages/plugins/plugin-security/src/rls-check-stored-form.ts`. Its `storedFormCheckJudge` is the one step every write-check path passes through before `matchesFilterCondition` judges a `check` clause. That covers the insert seam, the by-id update image and the update seams, since all of them call `satisfiesCheck` in `security-plugin.ts`. On each column the object **declares** `date`, `datetime` or `time` (the spec's `CALENDAR_DATE_TYPES`, `INSTANT_TYPES`, `CLOCK_TIME_TYPES`), it puts two things through `@objectstack/core`'s `temporalStorageForm`, which it consumes and does not change: - the post-image's value; - the check's value comparands on that column (`$eq`, `$ne`, the four orderings, `$in`, `$nin`, `$between`, implicit equality). - The declared types come from the declaration the write check already reads (`writeCheckFieldOptions` → `declaredComparisonColumns`), never from the values. A column the object does not declare temporal is judged as written. An object whose schema cannot load is judged as before. - Refusals are still attributed to the compiled policies as they are (`checkParts`). The rewritten parts are used only for evaluation, and they are copy-on-write: a filter with nothing temporal comes back as the same object. - `security/explain` does not judge a `check` clause. It judges fetched, stored rows under the read filters (`explain-engine.ts:1088`), so it is not on this step. ## Measured (ObjectQL.insert + SecurityPlugin + SqlDriver on better-sqlite3, as a member resolving a permission set; the same predicate as `using` and `check`) Compiled filters are shown instead of the CEL text. | compiled check | written | `main` 62b90d7 | this PR | stored | read | |---|---|---|---|---|---| | `{ due_on: { $lte: '2026-01-05' } }` | `'2026-01-05T15:00:00Z'` / `Date` / `'2026-01-05'` | admitted ×3 | admitted ×3 | `2026-01-05` | shown | | `{ due_on: '2026-01-05' }` | `'2026-01-05T15:00:00Z'` | **403** | admitted | `2026-01-05` | shown | | `{ due_on: '2026-01-05' }` | `Date(2026-01-05T15:00Z)` | **403** | admitted | `2026-01-05` | shown | | `{ due_on: { $gt: '2026-01-05' } }` | `'2026-01-05T15:00:00Z'` | **admitted** | 403 | `2026-01-05` | hidden | | `{ due_on: { $lt: '2026-01-05' } }` | `'2026-01-04T23:00:00-05:00'` | admitted | admitted | `2026-01-04` | shown | | `{ start_time: '09:00' }` | `'09:00'` | admitted | admitted | `09:00:00` | shown | | `{ start_time: '09:00' }` | `'09:00:00'` | **403** | admitted | `09:00:00` | shown | | `{ due_at: { $gte: '2026-01-05T10:00:00Z' } }` | `'2026-01-05T10:00:00Z'` | admitted | admitted | `2026-01-05T10:00:00.000Z` | shown | | `{ due_at: '2026-01-05T10:00:00Z' }` | `'2026-01-05T18:00:00+08:00'` | **403** | admitted | `2026-01-05T10:00:00.000Z` | shown | | `{ tags: { $contains: 'x' } }` | `'x'` | 403 | 403 (not folded, below) | `["x"]` | shown | | `{ tags: { $contains: 'x' } }` | `'xy'` | 403 | 403 | `["xy"]` | hidden | On every temporal row, this PR admits a write exactly when the read shows the stored row. The one write that is admitted on `main` and refused here is the `$gt` boundary-day cell, whose stored row the read hides. The changeset names it as the read/write agreement the ruling buys. ## The comparand half (beyond the ruling's literal words) The ruling names the post-image. Putting only the image into the form would **refuse writes `main` admits and the read shows**, whenever a policy spells its comparand unlike the stored form. Ablation A2 below measured two such writes: `{ due_at: { $gte: '2026-01-05T10:00:00Z' } }` written with that same instant, and `{ start_time: '09:00' }` written as `'09:00'`. The read pairs both sides (`driver-sql`'s `coerceFilterValue`, the memory driver, objectql's `having` walker), so the step pairs both sides too. This is called out for the seat's confirmation in the report. ## Not folded: the multi-valued scalar wrap (claim 5938708535's condition) The condition: the wrap must come from the same function the write door uses. That function is `normalizeMultiValueFields`, at `packages/objectql/src/validation/record-validator.ts:599`. It cannot be reached without a new export in another lane's package: - neither entry of `@objectstack/objectql` exports it (`index.ts:404` exports `ValidationError`, `validateRecord`, `VALIDATION_FAILED_CODE`; `core.ts:90` exports `ValidationError`, `validateRecord`); - `plugin-security` lists `@objectstack/objectql` under `devDependencies` only. So the fold is not built, and nothing is copied. Still measured (table above): `tags: 'x'` under `contains('x')` is 403 while the read shows `["x"]`. The insert seam runs at `engine.ts:13253`, before `normalizeMultiValueFields` at `:13410`. The export a fold needs, and the options, are in the report. ## Fork clause: driver-mongodb The SQL family (`driver-sql`, `driver-sqlite-wasm`, `driver-turso`) and `driver-memory` call `temporalStorageForm`. `driver-mongodb` stores through its own copy (`mongodb-temporal.ts`). Shape by shape, over 15 shapes, the two give the same logical value. A `datetime` is the same instant held as a BSON `Date`, a physical spelling of the kind ADR-0053 keeps for MySQL. The one exception is a `Date` in a UTC year from 0001 to 0999 on a `date` column: core gives `0999-06-15` and the copy gives `999-06-15`. On `main`, the raw check already admits that write under a bare-day `$lte` (the `Date` is lifted to an instant). Mongo's own read of `999-06-15` against any padded day sorts it above that day whatever RLS does. This PR treats that as a mongo defect, not a fork in the stored form, and reports it to the seat to rule. A live mongod is NOT MEASURED: no binary in the container. ## F7 stays `lteBound` (`packages/formula/src/matches-filter.ts:902`) stays live and untouched: `git diff 62b90d7..HEAD -- packages/formula` is empty. Its deletion is the follow-up card the seat files after this lands. The pins here already pass with it out of reach (below). The RLS CEL lowering emits no `{ $field, addDays }` comparand: `cel-to-filter.ts` has 0 `addDays` hits, against 5 `$field` hits as the control. That half of F7 is therefore the deletion card's to measure on its other callers. ## Tests - `rls-check-stored-form.test.ts`, 38 cells, on two driver families (driver-sql better-sqlite3 and driver-sqlite-wasm): - 16 write-and-read cells: each write is admitted exactly when the read under the same predicate shows the stored row (`PERMISSION_DENIED` / 403 asserted as code + status), and a refused write stores nothing. They cover `date` (the boundary day as an ISO instant, a `Date` and a bare day), `datetime`, `time`, and a `text` control. - A by-id update cell. - 3 unit cells: the declaration-only column reading, comparand rewriting, and copy-on-write. - **F7 is out of reach in this file.** `@objectstack/formula`'s matcher is wrapped so a `$lte` arrives as `$lt` or `$eq` (and a `$between` as `$gte` plus that), and the first cell pins that the wrapper is live. - At head 00cb551: `vitest run` for plugin-security gives **157 files, 3383 passed, 23 skipped** (the new file is +1 file / +38 tests). `pnpm --filter @objectstack/plugin-security typecheck` exits 0: `tsc --noEmit`, the scripts project, and `check:test-typecheck` OK with 0 debt. ## Ablations All on committed head ec6db40, through `scripts/ablation-replace.mjs` in WRAP mode. Every restore is proven: blob == HEAD and `git diff HEAD` is empty. Tests import `src` directly, so no `dist/` was involved. | | mutation | red of 38 | |---|---|---| | A0 | the judge swapped back to `main`'s raw evaluation | 16: per driver, `$lte` ISO + `Date` (F7 out of reach), `$eq` ISO + `Date`, `$gt` boundary, `datetime` +08:00, `time` `'09:00:00'`, by-id update | | A1 | image half off | 16: as A0, except `time` `'09:00'` written replaces `'09:00:00'` | | A2 | comparand half off (image only) | 8: `datetime` `==` +08:00, `datetime` `$gte` same spelling, `time` `'09:00'`, `time` `'09:00:00'`. Two of these are admitted on `main` and shown by the read | | A3 | a `text` column read as `date` | first attempt a **no-op** (the tool refused: the replacement contained its anchor, count 1 → 1); rerun with the anchor `return undefined;`: 7, both control cells on both drivers plus the 3 unit cells | | A4 | the judge admits everything | 14: exactly the 7 negative pins × 2 drivers | ## Gates - Derivation: `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` at 00cb551 gives 63 families from 4 paths vs merge base 62b90d7. All 63 were run with exit codes recorded, and all exited 0. `--ran`: 63 derived, 63 run, 0 NOT-MEASURED, 0 UNRUN. - The first sweep, at 1d9ccec, had three non-zero exits: - `check:dual-build-cjs-loads` and `check:i18n` exited 3 (PREREQUISITE NOT MET). Both passed after a full turbo build (72/72). - `check:test-source-alias` exited 1: a dynamic `import('@objectstack/formula')` inside a test body. Fixed in bfa3609 (a module-top import). - Narrowed lint at 00cb551: `eslint --no-inline-config --format json` over the 3 changed `.ts` files gives 3 files, 0 errors, 0 warnings, none ignored. The population comes from `eslint.config.mjs:971` (`files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}']`). `eslint.config.mjs:327-329` enables no type-aware linting, so no untouched file's verdict can move. The full `pnpm lint` is CI's. - Control-byte self-scan over the changed files: none. ## Docs I grepped `content/docs/**` (outside `releases/`) and `skills/**` for sentences on how a `check` judges a write (raw versus stored form, date columns), and found none this PR makes false. Positive control: the grep reaches `content/docs/permissions/rls.mdx:64` (the `check` row: each row as its hooks leave it) and `content/docs/permissions/authorization.mdx:56` (`check` judges the write post-image). Both are still true. No docs edit. ## Acceptance notes - `security/explain` compares a stored row against a `using` comparand as written. A temporal comparand spelled unlike the stored form (for example `'09:00'` on a `time` column) would be compared unlike the read. This is unmeasured, so it is noted and not filed. - The multi-valued wrap and driver-mongodb's unpadded year go to the seat as findings in the report. --- _Generated by [Claude Code](https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1a4c7f8 commit ef96c9e

4 files changed

Lines changed: 583 additions & 1 deletion

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
---
4+
5+
fix(plugin-security): a row-level `check` judges a `date`, `datetime` or `time` column as the row will be stored, so the write and the read the same policy scopes give one answer for one row (#21109)
6+
7+
Clause-②: no
8+
9+
The write check evaluates the compiled `check` filter in-process against the write's post-image. That image held each value as the caller or a hook wrote it, while the read compares the value the driver stored, in its column's storage form, against a comparand put into the same form. On a temporal column the two forms differ, so one row could get two answers. Measured through `ObjectQL.insert` with `SecurityPlugin` on SQLite, as a member resolving a permission set, with the same predicate as `using` and `check`:
10+
11+
| `check` | written | write, before | stored | read |
12+
|---|---|---|---|---|
13+
| `record.due_on == '2026-01-05'` | `'2026-01-05T15:00:00Z'`, or a `Date` on that day | 403 | `2026-01-05` | shown |
14+
| `record.start_time == '09:00'` | `'09:00:00'` | 403 | `09:00:00` | shown |
15+
| `record.due_at == '2026-01-05T10:00:00Z'` | `'2026-01-05T18:00:00+08:00'` | 403 | `2026-01-05T10:00:00.000Z` | shown |
16+
| `record.due_on > '2026-01-05'` | `'2026-01-05T15:00:00Z'` | admitted | `2026-01-05` | hidden |
17+
18+
Now, before the check is judged, every column the object declares `date`, `datetime` or `time` is put into `@objectstack/core`'s `temporalStorageForm`, the rule the drivers write and compare those columns by. That applies to the post-image's value and to the check's value comparands on the column (`$eq`, `$ne`, the orderings, `$in`, `$nin`, `$between`). The first three rows above are now admitted. The last is now refused, which is the read/write agreement this change buys: the read never showed that row, so a write that stores a day the predicate excludes is no longer admitted on the strength of the time of day it was sent with. Every insert, by-id update and predicate update takes the same step.
19+
20+
Unchanged: a column the object does not declare temporal is judged as written, whatever its value looks like; an object whose schema cannot be loaded is judged as before; a value the storage rule cannot read is judged as written; and refusals keep their code and status (`PERMISSION_DENIED` / 403).
Lines changed: 288 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,288 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#21109, ruling A] The row-level write `check` judges a `date`, `datetime` or
5+
* `time` column in its STORED form, so the write and the read the same policy
6+
* scopes give one answer for one row.
7+
*
8+
* Driven through the real engine, the real plugin and two SQL driver families,
9+
* as a member who resolves a permission set. Each cell writes one value under a
10+
* policy whose `using` and `check` are the same predicate, then asks the read:
11+
* the write is admitted exactly when the read shows the row that value is
12+
* stored as. Measured on `main` before the step (`rls-check-stored-form.ts`):
13+
*
14+
* | `check` | written | write, before | stored | read |
15+
* |---|---|---|---|---|
16+
* | `record.due_on == '2026-01-05'` | `'2026-01-05T15:00:00Z'`, a `Date` | 403 | `2026-01-05` | shown |
17+
* | `record.due_on > '2026-01-05'` | `'2026-01-05T15:00:00Z'` | admitted | `2026-01-05` | hidden |
18+
* | `record.start_time == '09:00'` | `'09:00:00'` | 403 | `09:00:00` | shown |
19+
* | `record.due_at == '2026-01-05T10:00:00Z'` | `'2026-01-05T18:00:00+08:00'` | 403 | `2026-01-05T10:00:00.000Z` | shown |
20+
*
21+
* ## formula's whole-day copy is out of reach here
22+
*
23+
* `@objectstack/formula`'s matcher carries its own copy of the whole-day upper
24+
* bound (a bare-day `$lte` read as "through that day"), which today is what
25+
* admits a `due_on` written as an instant or a `Date` on the boundary day under
26+
* `record.due_on <= '2026-01-05'`. Ruling A retires that copy once the check
27+
* judges the stored form, so these cells must pass WITHOUT it: the matcher is
28+
* wrapped so a `$lte` reaches it as `$lt` or `$eq`, and a `$between` as `$gte`
29+
* plus that, and the copy never runs. The first cell pins that the wrapper is
30+
* live.
31+
*/
32+
33+
import { describe, it, expect, afterEach, vi } from 'vitest';
34+
// The mocked module (see `vi.mock` below), loaded at module top.
35+
import { matchesFilterCondition } from '@objectstack/formula';
36+
import { ObjectQL } from '@objectstack/objectql';
37+
import { SqlDriver } from '@objectstack/driver-sql';
38+
import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm';
39+
import { PermissionSetSchema } from '@objectstack/spec/security';
40+
import { SecurityPlugin } from './security-plugin.js';
41+
import { defaultPermissionSets } from './objects/default-permission-sets.js';
42+
import { declaredTemporalColumns, storedFormCheckFilter, storedFormImage } from './rls-check-stored-form.js';
43+
44+
/** A plain object — a filter node or an operator map. */
45+
function isPlain(value: unknown): value is Record<string, unknown> {
46+
return value !== null && typeof value === 'object' && !Array.isArray(value)
47+
&& Object.getPrototypeOf(value) === Object.prototype;
48+
}
49+
50+
/** `$lte` as `$lt` or `$eq`, so the matcher's whole-day copy is never reached. */
51+
const plainLte = (column: string, bound: unknown) => ({
52+
$or: [{ [column]: { $lt: bound } }, { [column]: { $eq: bound } }],
53+
});
54+
55+
/** The filter with every `$lte` and `$between` spelled without the operators the copy serves. */
56+
function withoutWholeDayCopy(node: unknown): unknown {
57+
if (!isPlain(node)) return node;
58+
const conjuncts: unknown[] = [];
59+
for (const [key, value] of Object.entries(node)) {
60+
if ((key === '$and' || key === '$or') && Array.isArray(value)) {
61+
conjuncts.push({ [key]: value.map(withoutWholeDayCopy) });
62+
} else if (key === '$not') {
63+
conjuncts.push({ $not: withoutWholeDayCopy(value) });
64+
} else if (key.startsWith('$') || !isPlain(value) || !('$lte' in value || '$between' in value)) {
65+
conjuncts.push({ [key]: value });
66+
} else {
67+
const { $lte, $between, ...rest } = value;
68+
if (Object.keys(rest).length > 0) conjuncts.push({ [key]: rest });
69+
if ($lte !== undefined) conjuncts.push(plainLte(key, $lte));
70+
if (Array.isArray($between)) conjuncts.push({ [key]: { $gte: $between[0] } }, plainLte(key, $between[1]));
71+
}
72+
}
73+
return conjuncts.length === 1 ? conjuncts[0] : { $and: conjuncts };
74+
}
75+
76+
vi.mock('@objectstack/formula', async (importOriginal) => {
77+
const real = await importOriginal<typeof import('@objectstack/formula')>();
78+
return {
79+
...real,
80+
matchesFilterCondition: ((record, filter, options) =>
81+
real.matchesFilterCondition(record, withoutWholeDayCopy(filter) as never, options)) as typeof real.matchesFilterCondition,
82+
};
83+
});
84+
85+
const SYS_CTX = { isSystem: true, userId: 'usr_system' };
86+
const MEMBER_DEFAULT = defaultPermissionSets.find((p) => p.name === 'member_default')!;
87+
88+
type Driver = { disconnect?: () => Promise<void> };
89+
const DRIVERS: Array<[name: string, make: () => Driver]> = [
90+
['driver-sql (better-sqlite3)', () =>
91+
new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true })],
92+
['driver-sqlite-wasm', () => new SqliteWasmDriver({ filename: ':memory:' })],
93+
];
94+
95+
const booted: ObjectQL[] = [];
96+
afterEach(async () => {
97+
while (booted.length) {
98+
try { await booted.pop()!.destroy(); } catch { /* noop */ }
99+
}
100+
});
101+
102+
let seq = 0;
103+
/** One engine and plugin, with ONE policy whose `using` and `check` are the same predicate. */
104+
async function boot(makeDriver: () => Driver, predicate: string) {
105+
const OBJ = `qa_due_stored_${process.pid}_${++seq}`;
106+
const engine = new ObjectQL();
107+
engine.registerDriver(makeDriver() as never, true);
108+
await engine.init();
109+
engine.registerApp({
110+
id: `com.objectstack.qa.rls-check-stored-form-${seq}`,
111+
name: 'RLS check stored form',
112+
version: '1.0.0',
113+
type: 'plugin',
114+
scope: 'system',
115+
objects: [
116+
{
117+
name: OBJ,
118+
label: 'Duty',
119+
sharingModel: 'public_read_write',
120+
fields: {
121+
id: { name: 'id', type: 'text', primaryKey: true },
122+
title: { name: 'title', type: 'text' },
123+
due_on: { name: 'due_on', type: 'date' },
124+
due_at: { name: 'due_at', type: 'datetime' },
125+
start_time: { name: 'start_time', type: 'time' },
126+
},
127+
},
128+
],
129+
} as never);
130+
await engine.syncSchemas();
131+
booted.push(engine);
132+
133+
const set = PermissionSetSchema.parse({
134+
name: 'qa_due_guard',
135+
objects: { [OBJ]: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } },
136+
rowLevelSecurity: [{ name: 'due_guard', object: OBJ, operation: 'all', using: predicate, check: predicate }],
137+
});
138+
const services: Record<string, unknown> = {
139+
manifest: { register: vi.fn() },
140+
objectql: engine,
141+
metadata: {
142+
get: async (_type: string, name: string) => engine.getSchema(name) ?? null,
143+
list: async () => [MEMBER_DEFAULT, set],
144+
},
145+
};
146+
const ctx = {
147+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
148+
registerService: vi.fn(),
149+
getService: (name: string) => {
150+
if (!(name in services)) throw new Error(`service not registered: ${name}`);
151+
return services[name];
152+
},
153+
};
154+
const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' });
155+
await plugin.init(ctx as never);
156+
await plugin.start(ctx as never);
157+
vi.spyOn((engine as unknown as { logger: { warn: () => void } }).logger, 'warn').mockImplementation(() => undefined);
158+
159+
const caller = { userId: 'usr_member', positions: ['qa_pos'], permissions: [set.name], posture: 'MEMBER' };
160+
const storedRow = async (id: string) =>
161+
((await engine.find(OBJ, { where: { id }, context: SYS_CTX } as never)) as Array<Record<string, unknown>>)[0];
162+
const shownTo = async (id: string) =>
163+
((await engine.find(OBJ, { where: { id }, context: caller } as never)) as unknown[]).length > 0;
164+
return { OBJ, engine, caller, storedRow, shownTo };
165+
}
166+
167+
type Envelope = { code: string; status: number };
168+
const DENIED: Envelope = { code: 'PERMISSION_DENIED', status: 403 };
169+
const envelopeOf = (e: unknown): Envelope => {
170+
const x = e as { code?: string; status?: number; statusCode?: number };
171+
return { code: String(x?.code), status: Number(x?.statusCode ?? x?.status) };
172+
};
173+
const outcome = (p: Promise<unknown>): Promise<'admitted' | Envelope> =>
174+
p.then(() => 'admitted' as const, (e: unknown) => envelopeOf(e));
175+
176+
const show = (v: unknown): string => (v instanceof Date ? `Date(${v.toISOString()})` : JSON.stringify(v));
177+
178+
type Cell = { predicate: string; column: string; value: unknown; stored: unknown; admitted: boolean };
179+
const CELLS: Cell[] = [
180+
// `date`, the boundary day under a bare-day `$lte`: the three ways an SDK writes it.
181+
{ predicate: "record.due_on <= '2026-01-05'", column: 'due_on', value: '2026-01-05T15:00:00Z', stored: '2026-01-05', admitted: true },
182+
{ predicate: "record.due_on <= '2026-01-05'", column: 'due_on', value: new Date('2026-01-05T15:00:00Z'), stored: '2026-01-05', admitted: true },
183+
{ predicate: "record.due_on <= '2026-01-05'", column: 'due_on', value: '2026-01-05', stored: '2026-01-05', admitted: true },
184+
{ predicate: "record.due_on <= '2026-01-05'", column: 'due_on', value: '2026-01-06T01:00:00Z', stored: '2026-01-06', admitted: false },
185+
// `date` equality: refused before for an instant or a `Date` on the day.
186+
{ predicate: "record.due_on == '2026-01-05'", column: 'due_on', value: '2026-01-05T15:00:00Z', stored: '2026-01-05', admitted: true },
187+
{ predicate: "record.due_on == '2026-01-05'", column: 'due_on', value: new Date('2026-01-05T15:00:00Z'), stored: '2026-01-05', admitted: true },
188+
{ predicate: "record.due_on == '2026-01-05'", column: 'due_on', value: '2026-01-06', stored: '2026-01-06', admitted: false },
189+
// `date`, strictly after the day: ADMITTED before, while the read hid the stored day.
190+
{ predicate: "record.due_on > '2026-01-05'", column: 'due_on', value: '2026-01-05T15:00:00Z', stored: '2026-01-05', admitted: false },
191+
// `datetime`: one instant in two zones, and a comparand the policy spells unlike the stored form.
192+
{ predicate: "record.due_at == '2026-01-05T10:00:00Z'", column: 'due_at', value: '2026-01-05T18:00:00+08:00', stored: '2026-01-05T10:00:00.000Z', admitted: true },
193+
{ predicate: "record.due_at == '2026-01-05T10:00:00Z'", column: 'due_at', value: '2026-01-05T10:00:01Z', stored: '2026-01-05T10:00:01.000Z', admitted: false },
194+
{ predicate: "record.due_at >= '2026-01-05T10:00:00Z'", column: 'due_at', value: '2026-01-05T10:00:00Z', stored: '2026-01-05T10:00:00.000Z', admitted: true },
195+
// `time`: a wall clock written with and without seconds.
196+
{ predicate: "record.start_time == '09:00'", column: 'start_time', value: '09:00:00', stored: '09:00:00', admitted: true },
197+
{ predicate: "record.start_time == '09:00'", column: 'start_time', value: '09:00', stored: '09:00:00', admitted: true },
198+
{ predicate: "record.start_time == '09:00'", column: 'start_time', value: '09:01', stored: '09:01:00', admitted: false },
199+
// Control: a TEXT column is judged as written, whatever its value looks like.
200+
{ predicate: "record.title == '2026-01-05'", column: 'title', value: '2026-01-05T15:00:00Z', stored: '2026-01-05T15:00:00Z', admitted: false },
201+
{ predicate: "record.title > '2026-01-05'", column: 'title', value: '2026-01-05T15:00:00Z', stored: '2026-01-05T15:00:00Z', admitted: true },
202+
];
203+
204+
describe("formula's whole-day copy is out of reach in this file", () => {
205+
it('a bare-day $lte no longer admits an instant later on that day', () => {
206+
expect(matchesFilterCondition({ d: '2026-01-05T15:00:00Z' }, { d: { $lte: '2026-01-05' } } as never)).toBe(false);
207+
expect(matchesFilterCondition({ d: '2026-01-05' }, { d: { $lte: '2026-01-05' } } as never)).toBe(true);
208+
});
209+
});
210+
211+
for (const [driverName, makeDriver] of DRIVERS) {
212+
describe(`${driverName}: the write check and the read give one answer for one temporal row`, () => {
213+
for (const cell of CELLS) {
214+
const verdict = cell.admitted ? 'admitted and shown' : 'refused 403 and hidden';
215+
it(`${cell.predicate}, ${cell.column} written as ${show(cell.value)}: ${verdict}`, async () => {
216+
const r = await boot(makeDriver, cell.predicate);
217+
const written = await outcome(r.engine.insert(r.OBJ, { id: 'w', [cell.column]: cell.value }, { context: r.caller } as never));
218+
expect(written).toEqual(cell.admitted ? 'admitted' : DENIED);
219+
// The read under the same predicate, over the same value stored by the system.
220+
await r.engine.insert(r.OBJ, { id: 'r', [cell.column]: cell.value }, { context: SYS_CTX } as never);
221+
expect((await r.storedRow('r'))?.[cell.column]).toEqual(cell.stored);
222+
expect(await r.shownTo('r')).toBe(cell.admitted);
223+
// A refused write stored nothing; an admitted one stored the same row.
224+
const w = await r.storedRow('w');
225+
expect(w === undefined ? undefined : w[cell.column]).toEqual(cell.admitted ? cell.stored : undefined);
226+
});
227+
}
228+
229+
it("a by-id update judges the stored form too: onto the day as an instant 'admitted', onto the next day 403 and unchanged", async () => {
230+
const r = await boot(makeDriver, "record.due_on == '2026-01-05'");
231+
await r.engine.insert(r.OBJ, { id: 'u', due_on: '2026-01-05' }, { context: SYS_CTX } as never);
232+
expect(await outcome(r.engine.update(r.OBJ, { due_on: '2026-01-05T23:30:00Z' }, { where: { id: 'u' }, context: r.caller } as never)))
233+
.toBe('admitted');
234+
expect(await outcome(r.engine.update(r.OBJ, { due_on: new Date('2026-01-06T00:30:00Z') }, { where: { id: 'u' }, context: r.caller } as never)))
235+
.toEqual(DENIED);
236+
expect((await r.storedRow('u'))?.due_on).toBe('2026-01-05');
237+
});
238+
});
239+
}
240+
241+
describe('the stored-form step reads the declaration, never the values', () => {
242+
const COLUMNS = declaredTemporalColumns({
243+
fields: {
244+
due_on: { type: 'date', multiple: false },
245+
due_at: { type: 'datetime', multiple: false },
246+
start_time: { type: 'time', multiple: false },
247+
title: { type: 'text', multiple: false },
248+
},
249+
});
250+
251+
it('names exactly the declared date / datetime / time columns, and none without a declaration', () => {
252+
expect([...COLUMNS.entries()]).toEqual([['due_on', 'date'], ['due_at', 'datetime'], ['start_time', 'time']]);
253+
expect(declaredTemporalColumns(undefined).size).toBe(0);
254+
});
255+
256+
it('puts every value comparand on a temporal column into its form, and leaves everything else as written', () => {
257+
const filter = {
258+
$and: [
259+
{ due_on: '2026-01-05T15:00:00Z' },
260+
{ due_on: { $in: ['2026-01-05T15:00:00Z', '2026-01-06'], $null: false } },
261+
{ start_time: { $between: ['09:00', '17:30'] } },
262+
{ due_at: { $gte: '2026-01-05T18:00:00+08:00', $contains: '2026' } },
263+
{ due_on: { $lte: { $field: 'title' } } },
264+
{ title: '2026-01-05T15:00:00Z' },
265+
],
266+
};
267+
const before = JSON.stringify(filter);
268+
expect(storedFormCheckFilter(filter, COLUMNS)).toEqual({
269+
$and: [
270+
{ due_on: '2026-01-05' },
271+
{ due_on: { $in: ['2026-01-05', '2026-01-06'], $null: false } },
272+
{ start_time: { $between: ['09:00:00', '17:30:00'] } },
273+
{ due_at: { $gte: '2026-01-05T10:00:00.000Z', $contains: '2026' } },
274+
{ due_on: { $lte: { $field: 'title' } } },
275+
{ title: '2026-01-05T15:00:00Z' },
276+
],
277+
});
278+
expect(JSON.stringify(filter)).toBe(before);
279+
});
280+
281+
it('hands back the same filter and image when nothing is temporal (copy-on-write)', () => {
282+
const filter = { $or: [{ title: '2026-01-05T15:00:00Z' }, { due_on: { $null: true } }] };
283+
expect(storedFormCheckFilter(filter, COLUMNS)).toBe(filter);
284+
const image = { title: '2026-01-05T15:00:00Z', due_on: '2026-01-05' };
285+
expect(storedFormImage(image, COLUMNS)).toBe(image);
286+
expect(storedFormImage({ due_on: new Date('2026-01-05T15:00:00Z') }, COLUMNS)).toEqual({ due_on: '2026-01-05' });
287+
});
288+
});

0 commit comments

Comments
 (0)