Skip to content

Commit a2de542

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-16570-initobjects-indexes-param
2 parents 2e309ce + 7ceb416 commit a2de542

14 files changed

Lines changed: 1670 additions & 185 deletions
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/driver-sql": minor
3+
---
4+
5+
The record read doors present the builtin audit stamps (`created_at`, `updated_at`) and every declared `Field.datetime` column as the canonical instant text `YYYY-MM-DDTHH:MM:SS.sssZ` on EVERY dialect — Postgres and MySQL now included, exactly as SQLite always has (ADR-0053 addendum D-F1..D-F3, #13973).
6+
7+
**Consumer-visible change, Postgres and MySQL only.** An in-process consumer reading such a column off a `find()` / `findOne()` row, off the row `create()` / `update()` / `upsert()` / `bulkCreate()` / `bulkUpdate()` return, or out of `aggregate()` (`min` / `max`, a raw temporal group key) or `distinct()`, receives a `string` where it received a JS `Date`. The wire is unchanged: `JSON.stringify` already serialised that `Date` as the same ISO text, so REST, MCP and SDK callers see nothing move. A consumer that called a `Date` method directly on the field (`.getTime()`, `.toISOString()`, `.getFullYear()`) now fails loudly with a `TypeError` instead of silently working on one dialect; the sweep behind this change found none in the repository's non-test sources. A consumer that compared, sorted, keyed or formatted the value as text — the shape eight production-driver defects had (#13382, #13993#13999) — is now correct by construction on every dialect.
8+
9+
- **Where the fold happens.** At the driver's own read boundary (`formatOutput` for rows, `presentReadValue` for the aggregate/distinct doors). The `pg` and `mysql2` client parsers are untouched: a `Date` is still what the client materialises, and a raw knex read still hands it back. Only the driver's read doors changed.
10+
- **The builtin audit columns gain an `aggregate()` / `distinct()` arm on every dialect.** `max(updated_at)` and `distinct('created_at')` had no read presentation at all before — on SQLite they even missed ADR-0074's legacy-row repair — and now present exactly what `find()` presents.
11+
- **An Invalid `Date` is the one shape the fold hands through unchanged** (#14078: a MySQL zero `DATETIME`; a Postgres year past 275760). It has no canonical text; the fold never throws on it, and the consumer-side guards #14078 landed absorb it as before.
12+
13+
The per-site canonicalisations landed for #13993#13999 and #14078 stay correct and become no-ops on driver rows; nothing is removed here.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
"@objectstack/plugin-webhooks": patch
3+
---
4+
5+
Webhook fan-out now matches subscriptions on the organization dimension, closing a cross-organization delivery on walled deployments (`OS_TENANCY_POSTURE=isolated|group`).
6+
7+
`AutoEnqueuer` selected the subscriptions to deliver to by object name and trigger only, and every organization's `sys_webhook` rows live in one cache — so organization A's record events reached organization B's webhook endpoint, signed with B's secret, on first delivery. Both the per-record (`data.record.*`) and the bulk (`data.records.*`) fan-out paths now compare the subscription's own organization (`sys_webhook.organization_id`) with the organization the engine stamps on the event (`DataEvent.organizationId`, `BulkDataEvent.organizationId`): one equality per candidate, no lookup on the hot path.
8+
9+
What changes for a subscription:
10+
11+
- **Owned by organization A** — receives only events stamped A. An event that names no organization (an environment-wide row or an object outside the wall on the per-record path; a batch the tenant wall could not attribute to one organization on the bulk path) is not delivered inside the wall — fail-closed — and the first such refusal is logged once with the reason.
12+
- **With no organization** (`organization_id` NULL — for example a package-declared webhook on a walled deployment) — no longer receives any organization-stamped event; the refusal is logged once per subscription. It still receives events that name no organization. On a `single`-posture deployment nothing stamps either side, so delivery there is unchanged.
13+
14+
An event whose `organizationId` is present but not a non-empty string is dropped loudly as off-contract, delivering to nobody.

.claude/agents/os-dev.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,6 @@ model: opus
9494
- ⛔ 永不把验证挂在后台 watcher 上然后停轮;禁令与两种合法终态见干净收尾节。
9595
- 平台事实:容器把前台命令钉在约 10 分钟上限,超时 SIGTERM 杀掉(`exit 143`)。
9696
- 上限划定前台里放什么:重活走规则 1 的锁;仓级扫描归 CI(见本地验证范围节)。
97-
- 消融/变异脚本自带还原 trap(硬线在标准条款节的 ablation 条)。
9897
7. **排队不是停摆,在轮内主动等。** 持锁的是你不拥有的进程,它的完成不会唤醒你。
9998
- 99 专指没排到,读作 NOT MEASURED;把间隔花在无锁工作上(写测试、changeset、PR 正文)。
10099
- 再取以同名续位,⛔ 不从队尾重排:`OS_VERIFY_LOCK_SLOT=<稳定名>` 在第一次尝试前就设好。
@@ -130,7 +129,6 @@ model: opus
130129
- ② 受影响包自己的 `pnpm test` / `pnpm typecheck`,用 `--filter` 圈定。
131130
- 受影响包 = 本包;import 方只在公开面变化时欠测试:spec 契约、发布的 `exports`、线上形状。
132131
- 公开面字节不变 ⇒ 只欠本包测试与派生门禁,⛔ 不给每个 import 方补测试。
133-
- `packages/cli` 只欠 `unit` 层(见 Definition of done 的测试条)。
134132
- ③ 派发词点名的门禁族,加上你看得出被牵连的。
135133
- 新 fake engine ⇒ `check:engine-double-contract`;新错误码 ⇒ `check:error-code-casing`
136134
- `.claude/agents/**``check:agent-model-declared`;任何编辑 ⇒ `check:nul-bytes`
@@ -161,6 +159,8 @@ model: opus
161159
- 报告的 `tests` 字段和 PR 正文两处都要引;门禁日志不带 sha,最后 commit 前的并集量的旧树。
162160
- 迟到的 commit 挪动的恰是 ratchet 读数;复核后任何一次 push,都在新 head 上重跑并集。
163161
- 至少重跑 ratchet 族,然后才更新报告或 PR 正文。
162+
- 收窄引 `turbo ls --affected` 须同句写明盲区:集出自包依赖图,盲于读盘取别包源码的测试。
163+
- 仓根包不在 `turbo ls` 里,根级套件整个在图外;故只称「不在图上」,永不称「动不了」。
164164
- 门禁结果的读法:退出码在任何管道之前捕获,报告里引门禁自己印的判定行。
165165
- 免疫写法只有一种,先重定向再捕获:`cmd > /tmp/out 2>&1; EXIT=$?; tail -40 /tmp/out`
166166
- `EXIT=$?` 跟在 `cmd 2>&1 | tail -40` 之后读到的是 `tail` 的状态。

docs/adr/0053-date-and-datetime-semantics.md

Lines changed: 151 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# ADR-0053: `date` is a timezone-naive calendar day; `datetime` is an instant rendered in a reference timezone
22

3-
**Status**: Accepted (2026-06-16) — Phase 1 + addendum D-A1 implemented (`sql-driver.ts` `toDateOnly` write/read/filter normalization; analytics `coerceTemporalFilterValue`), Phase 2 landing incrementally; D-A2 resolved 2026-07-30: `temporalFilterValue` + `temporalFilterColumnSql` are optional `IDataDriver` contract members with identity semantics, and analytics types its driver seam from the contract. **Partly superseded (2026-07-29, addendum D-B1..D-B4):** Phase 1's "`Field.datetime` stays stored as UTC epoch ms" is replaced by one canonical UTC instant per dialect — `YYYY-MM-DDTHH:MM:SS.sssZ` text on SQLite, `timestamptz` on Postgres, `DATETIME(3)` on MySQL — applied on write and to filter comparands alike (#3912, #3942). **Extended (2026-07-30, addendum D-C1..D-C3):** `Field.time` takes the same construction — canonical `HH:MM:SS[.fff]` wall-clock text, one function on write/filter/read, `TIME(3)` on MySQL, UTC `NOW()` defaults on every dialect (#3994).
3+
**Status**: Accepted (2026-06-16) — Phase 1 + addendum D-A1 implemented (`sql-driver.ts` `toDateOnly` write/read/filter normalization; analytics `coerceTemporalFilterValue`), Phase 2 landing incrementally; D-A2 resolved 2026-07-30: `temporalFilterValue` + `temporalFilterColumnSql` are optional `IDataDriver` contract members with identity semantics, and analytics types its driver seam from the contract. **Partly superseded (2026-07-29, addendum D-B1..D-B4):** Phase 1's "`Field.datetime` stays stored as UTC epoch ms" is replaced by one canonical UTC instant per dialect — `YYYY-MM-DDTHH:MM:SS.sssZ` text on SQLite, `timestamptz` on Postgres, `DATETIME(3)` on MySQL — applied on write and to filter comparands alike (#3912, #3942). **Extended (2026-07-30, addendum D-C1..D-C3):** `Field.time` takes the same construction — canonical `HH:MM:SS[.fff]` wall-clock text, one function on write/filter/read, `TIME(3)` on MySQL, UTC `NOW()` defaults on every dialect (#3994). **Extended (2026-09-07, addendum D-F1..D-F3):** the READ side takes the same canon — every `@objectstack/driver-sql` record read door but `findWithWindowFunctions` (#16609) presents `Field.datetime` values and the builtin `created_at` / `updated_at` audit stamps as the canonical `YYYY-MM-DDTHH:MM:SS.sssZ` text on every dialect, folded at the driver's read boundary with the client parsers untouched; those doors never hand out a JS `Date` for those columns, save an Invalid `Date`, which has no canonical text and passes through unchanged (#13973, maintainer ruling B1 narrow, 2026-09-02).
44
**Deciders**: ObjectStack Protocol Architects
55
**Builds on**: [ADR-0032](./0032-unified-expression-layer.md) (unified expression layer — CEL dialect, `today()`/`daysFromNow()`), [ADR-0014](./0014-record-form-field-type.md) (field types)
66
**Consumers**: `@objectstack/spec` (`Field.date`/`Field.datetime`), `@objectstack/driver-sql` (`coerceFilterValue`, `formatInput`/`formatOutput`, `dateFields`/`datetimeFields`), `@objectstack/formula` (`stdlib` time functions, `cel-engine` hydration), `@objectstack/objectql` (`applyFormulaPlan`), schedule/cron executors, report/analytics date bucketing, `sys-user-preference.timezone`.
@@ -1020,3 +1020,153 @@ again — the #5499 freeze that used to be the other half of this condition was
10201020
dissolved on 2026-08-11. The conversion itself is pinned by
10211021
`mongodb-time-storage.test.ts`, which is unaffected: it is pure, needs no
10221022
server, and still runs everywhere.
1023+
1024+
---
1025+
1026+
## Addendum (2026-09-07) — the read door presents ONE instant shape on every dialect (D-F1..D-F3, #13973)
1027+
1028+
> **Status:** landed. Extends D-B1's canon from the storage and comparand sides
1029+
> to the READ side, and ADR-0074's audit-column read repair from SQLite to every
1030+
> dialect. Provenance: maintainer ruling on #13973 — 「同意」 to the director
1031+
> seat's analysis recommending B1 (narrow), 2026-09-02, recorded on the card as
1032+
> comment 5507803003. The options that ruling declined are listed at the end.
1033+
1034+
### What the read side actually was
1035+
1036+
D-B1 gave `Field.datetime` one storage form per dialect and one comparand rule,
1037+
and said nothing about what the driver HANDS BACK. That was decided by the
1038+
client library. `formatOutput` folded every SQLite storage shape to
1039+
`YYYY-MM-DDTHH:MM:SS.sssZ` — inside `if (this.isSqlite)`. On Postgres and MySQL
1040+
a `timestamptz` / `DATETIME(3)` left the read door as node-pg's / mysql2's JS
1041+
`Date`; on SQLite (and the memory driver) as canonical text. The builtin audit
1042+
columns had the same gate around ADR-0074's repair, and the `aggregate()` /
1043+
`distinct()` presentation (`readPresentationKind`) had no arm for them on ANY
1044+
dialect. One column, two runtime types, through the same `any`-shaped record —
1045+
invisible to the type system, and to every test that never ran on a live
1046+
dialect.
1047+
1048+
The #13973 census measured what that cost. 44 packages call a read door; 43 of
1049+
them had only ever seen the text side (the one CI job with a live Postgres and
1050+
MySQL runs `driver-sql` alone). Eight consumers were wrong on the production
1051+
default driver — #13382 in production (the OCC seam compared `String(v)` on
1052+
both sides and refused every guarded save), #13993#13999 by reading (an
1053+
idempotency window that never expires; a migration that persisted
1054+
`Date.toString()`; a timeline sorted by weekday name; a `z.string()` field
1055+
holding a `Date`) — every one in the direction "expected the text, received a
1056+
`Date`", none the reverse. The driver comment stating the `Date` side as
1057+
deliberate ("`Field.datetime` depends on it") was checked on the tree and did
1058+
not hold: nothing on the read path consumed the `Date`.
1059+
1060+
### D-F1 — Every read door presents the canonical instant text, on every dialect
1061+
1062+
For the builtin audit columns (`created_at`, `updated_at`) and every declared
1063+
`Field.datetime` column, every record read door of `@objectstack/driver-sql`
1064+
listed here presents the value as `YYYY-MM-DDTHH:MM:SS.sssZ` text — on SQLite,
1065+
Postgres and MySQL alike, exactly as SQLite always did:
1066+
1067+
- `find()`, `findOne()`, and the rows `create()`, `update()`, `upsert()`,
1068+
`bulkCreate()` and `bulkUpdate()` return (all through `formatOutput`, whose two
1069+
gates are now unconditional and whose audit-column arm folds a `Date`);
1070+
- `aggregate()` for `min` / `max` over such a column and for a raw temporal
1071+
group key, and `distinct()` over such a column (`presentReadValue`, whose
1072+
`datetime` arm is now unconditional, and `readPresentationKind`, which now
1073+
routes the two audit columns to the same `presentAuditTimestampOutput`
1074+
`formatOutput` applies — one presenter per column class, shared by every
1075+
door, so a value `find()` passes through as a number — ADR-0074 §3's epoch
1076+
INTEGER, or an author-declared non-temporal `created_at` — is that same
1077+
number here, never ISO text at this door alone).
1078+
1079+
None of these doors hands out a JS `Date` for these columns, save the one
1080+
shape D-F3 names: an Invalid `Date`, which has no canonical text and passes
1081+
through unchanged. `findWithWindowFunctions` is not one of these doors (see
1082+
Consequences; #16609). Declared = enforced:
1083+
`sql-driver-13973-canonical-iso-read-door.test.ts` asserts it per cell of the
1084+
D-A3 driver axis for every door listed — `bulkCreate()` over the rows a
1085+
dialect's bulk insert returns (MySQL, with no `RETURNING`, returns none, and
1086+
that cell reads the batch back through `find()` instead) — the SQLite cell
1087+
everywhere, the Postgres and MySQL cells under `Temporal Conformance (live PG
1088+
+ MySQL)`, with the three-way zone skew guard so a `Z` that only survived
1089+
because every clock agreed cannot pass — and
1090+
`sql-driver-13567-audit-stamp-materialisation.test.ts` re-pins the audit
1091+
column at the OCC seam's door. The same file's §D pins, on SQLite, that
1092+
`aggregate()` / `distinct()` present the audit columns through the presenter
1093+
`find()` uses, on the two shapes where a different one would show (an
1094+
author-declared non-temporal `created_at`; a raw-written epoch INTEGER) —
1095+
SQLite because its type affinity is what lets a number sit in that column at
1096+
all; the `timestamptz` / `DATETIME(3)` the DDL types it as elsewhere cannot.
1097+
1098+
Why text rather than "everything a `Date`" (the maintainer asked exactly this:
1099+
「为什么不能都用日期类型」): every platform with a metadata layer decides the
1100+
in-process type from the declared field type at its own read boundary, and
1101+
ObjectStack had already declared that type — D-B1's text — everywhere but this
1102+
one door. A `Date` is not a value type (`===` compares identity, a `Map` key is
1103+
by reference, it is mutable); `Invalid Date` is a `Date` whose `toISOString()`
1104+
throws; its local-zone methods answer differently on every host; JSON cannot
1105+
carry it; and it holds less precision than `timestamptz`. The canonical text
1106+
makes `String(v)`, `===`, a template, a sort and a `Map` key correct by
1107+
construction, which is what an author — human or AI — reaches for first. A
1108+
`Date` belongs at exactly two places: the client parser (D-F2) and the moment
1109+
arithmetic happens (`new Date(text).getTime()`, which every class (a) site in
1110+
the census already spells and which accepts both shapes).
1111+
1112+
### D-F2 — Folded at the driver's read boundary, not at the client parser
1113+
1114+
The pg and mysql2 type parsers are not touched. A `Date` stays the client-level
1115+
materialisation — a raw knex read of the same row still hands one back, and a
1116+
host's own `pg` clients keep the stock behaviour — and the driver canonicalises
1117+
in `formatOutput` / `presentReadValue`. This is the narrow form of B1: the
1118+
`date` parser installed by `withPostgresCalendarDayAsText` stays the one place
1119+
a clock is chosen, the instant types keep their stock parser, and nothing
1120+
outside the driver's own read doors moves. §C of
1121+
`sql-driver-13973-canonical-iso-read-door.test.ts` measures this on every live
1122+
cell: the raw read is a `Date`, the read door is text, and the two name the
1123+
same instant.
1124+
1125+
### D-F3 — The one shape the fold cannot canonicalise passes through
1126+
1127+
An Invalid `Date` — a `Date` whose time value is `NaN`, which #14078 measured
1128+
both live dialects to produce from rows already on disk (a MySQL zero
1129+
`DATETIME`; any Postgres year in 275760..294276) — has no canonical text. The
1130+
fold is total in the sense #14078 ruled for the shared consumer spelling: it
1131+
never throws, and it hands the client's Invalid `Date` through unchanged. It is
1132+
neither nulled (a stored value silently erased) nor spelled as the text
1133+
`Invalid Date` (a wire change: `JSON.stringify` already serialises the shape as
1134+
`null`); it leaves as the one `Date` the consumer-side guards #14078 landed
1135+
already absorb. `sql-driver-14078-invalid-date-materialisation.test.ts` pins
1136+
both halves: the control instant leaves as text, the Invalid `Date` leaves as
1137+
itself.
1138+
1139+
### Consequences
1140+
1141+
- **Consumer-visible, Postgres and MySQL only** (changeset `minor` for
1142+
`@objectstack/driver-sql`): in-process consumers receive a `string` where
1143+
they received a `Date`. The wire is unchanged — `JSON.stringify` already
1144+
serialised the `Date` as the same ISO text. A consumer that called a `Date`
1145+
method directly on such a field fails loudly with a `TypeError`; the census
1146+
for this addendum (its expressions are recorded on #13973's landing PR)
1147+
found none in non-test sources. A consumer that compared, sorted, keyed or
1148+
formatted the value as text is now correct on every dialect.
1149+
- The per-site canonicalisations landed for #13993#13999 and the five total
1150+
`Date` arms #14078 landed become no-ops on driver rows. They stay correct and
1151+
are not removed here; retiring them is separate, deliberate work.
1152+
- ADR-0074's read repair is no longer SQLite-only in effect: its string arm is
1153+
unchanged and now runs on every dialect (a no-op on canonical text), and a
1154+
`Date` arm sits beside it.
1155+
- D-A3's matrix gains the read-shape cell. `Temporal Conformance (live PG +
1156+
MySQL)` is the job that proves it; its package set is not widened.
1157+
- Not covered: `findWithWindowFunctions`, which applies no read presentation of
1158+
any kind today (booleans, dates and JSON included) — a pre-existing gap of its
1159+
own, recorded rather than folded in.
1160+
1161+
### Options not taken
1162+
1163+
- **B1-full** — text at the pg parser. Same effect, one more decision reversed
1164+
(the parser is scoped per pool and would then differ from a host's own `pg`
1165+
clients), marginally better precision. Not taken: the driver's read boundary
1166+
is the layer that owns the declared type.
1167+
- **B2** — a union return type the consumer must narrow. Moot once the read
1168+
type is `string`; it only made sense if both shapes were kept.
1169+
- **B3** — accept the divergence and add a shared `Date` fixture. It accepts a
1170+
divergence this ADR had already declared away and leaves 43 packages
1171+
untested against the shape. Its fixture folds into the conformance cells
1172+
above.

0 commit comments

Comments
 (0)