Uh oh!
There was an error while loading. Please reload this page.
fix(driver-sql,objectql): JSON-field values must not depend on DDL having run (#10995) - #11070
Conversation
…un (#10995) On a Postgres deployment that manages DDL out-of-band (`skipSchemaSync` / `OS_SKIP_SCHEMA_SYNC=1`), writing an array to a JSON field returned 500 `DATABASE_ERROR`, a bare string returned 500, and an empty array was accepted and silently stored as an empty object. `formatInput` does stringify JSON-field values on every non-SQLite dialect, but only for fields in the per-object `jsonFields` registry — and that registry was built exclusively as the first step of a DDL call, so a boot that skips schema sync served writes with every coercion registry empty and let node-postgres' per-type defaults encode the value: object -> JSON text (accidentally correct), array -> Postgres array literal -> `22P02`, `[]` -> `{}` (valid JSON, hence accepted and corrupted), bare string -> raw -> `22P02`. SQLite hid it behind a dialect-local bind-safety net, which is why the Turso/SQLite suites are blind. Registration is now separable from DDL, per the #7737/#10629 ruling for federated objects: `SqlDriver.registerObjectMetadata()` (declared optional on `IDataDriver`) installs the coercion registries with no DDL and no round-trip, a `skipSchemaSync` boot and every metadata reload take that route, and `initObjects` registers before the ADR-0015 DDL gate refuses so guest datasources are covered too. The refusal itself is unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019yDEhPBC3tcGkW9bkce1HM
`check:query-options-erasure` counts `as any` on a driver query bag in test code too — the new pins pushed the test surface 240 -> 243. The options here are on-contract, so they are typed rather than grandfathered. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019yDEhPBC3tcGkW9bkce1HM
📓 Docs Drift CheckThis PR changes 3 package(s): 14 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 129 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 56ac86d3ef24d55c77c4b5bb839025b3809851bd && git checkout 56ac86d3ef24d55c77c4b5bb839025b3809851bd
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 3e26359a7d87fbe0e03c7236346166c71a821ec0 a68f1a21035d5a805721e24087c3c68acecca88c && git checkout -B drift-repro 3e26359a7d87fbe0e03c7236346166c71a821ec0 && git merge --no-ff a68f1a21035d5a805721e24087c3c68acecca88c
node scripts/docs-audit/affected-docs.mjs --json 3e26359a7d87fbe0e03c7236346166c71a821ec0
|
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 32581338193 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
Fixes#10995
The card's matrix is real. Its inferred mechanism is not — and that changes where the fix goes.
The card said, explicitly, "Mechanism (inferred from the signature — verify at the driver)".
Verified at the driver, on a live Postgres 16, and the guess does not hold:
formatInputdoes
JSON.stringifya JSON field's value on every non-SQLite dialect, and has since thedriver moved into
packages/drivers:What gates it is the line above:
const jsonFields = this.jsonFields[object]. That registry —along with the boolean / numeric / date / datetime / time / auto_number registries and the
tenant-isolation column — is filled only as the first step of a DDL call (
initObjects,which
syncSchema()delegates to), or byregisterExternalObjectfor federated objects.So the defect is not a missing
JSON.stringify. It is that the correctness of every valuethis driver binds depended on DDL having run in the same process. Two in-repo postures reach
the write path with those registries empty:
skipSchemaSync/OS_SKIP_SCHEMA_SYNC=1— published incontent/docs/deployment/environment-variables.mdxas "skip the implicitdb:synconboot. Use after running migrations manually", and named in
plugin.tsfor exactly theruntime the card was found on: "Cold-start-sensitive runtimes (Cloudflare Containers,
Lambda) can opt out … an out-of-band migration must have already created every table".
That branch called nothing, so the boot told its drivers nothing.
schemaMode !== 'managed', ADR-0015):initObjectsthrew at the DDL gate before registering anything, so managed objects on afederated datasource were never described to the driver either.
With the registry empty, the value goes to node-postgres and is encoded by its per-type
defaults — which reproduces the card's matrix exactly, on INSERT and on UPDATE:
jsonfield{a:1}object42number42— already valid JSON{items:[1,2]}[{type:'app',…}]22P02 invalid input syntax for type json→ 500'x'bare stringx, not JSON text ("x"would be) → 500[]empty array{}— valid JSON, so accepted and silently stored as an empty OBJECTWhy every existing suite is blind to it
formatInputends with a bind-safety net that stringifies any leftover object/array — gatedon
isSqlite, because better-sqlite3 cannot bind them at all. On SQLite an empty registry istherefore invisible: the right bytes still land on disk (they just read back as text, since
the read-side parse is keyed by the same registry). Tenant environments run Turso/SQLite, so
the seed and data suites exercise a different dialect branch of the same function.
Postgres has no such net, and both control planes are Postgres. That blindness is part of the
defect surface, and the fix comes with coverage on the Postgres path specifically.
The fix: registration is separable from DDL
This is the ruling #7737/#10629 already made for federated objects — "that flag is about DDL,
and this binding is DDL-free" — extended to the managed ones.
packages/drivers/driver-sql— the in-memory half ofinitObjectsbecomesregisterManagedObjectMetadata(), exposed asregisterObjectMetadata(objects): noCREATE TABLE, noALTER TABLE, no existence probe, no round-trip. That property is whatkeeps it affordable on the cold-start budget
skipSchemaSyncexists to protect.initObjectsnow calls it before the ADR-0015 gate, so a guest datasource is described even though its
DDL is (still) refused — the refusal itself is byte-for-byte unchanged.
packages/spec—IDataDriver.registerObjectMetadata?(), declared optional beside itsfederated sibling
registerExternalObject?(). Drivers whose encoding does not depend ondeclared field types (memory, mongodb) omit it and are skipped.
packages/objectql—start()'s two schema seams and themetadata:reloadedhook now gothrough one
installRegisteredSchemas(): sync when the flag is off,registerSchemasWithoutDdl()when it is on. The skip line now reports what it registered, so a boot can be audited; a driver
without the route is counted, not thrown at; a registration that throws is reported at
errorper the degradation-log-level rule.
What was deliberately NOT done: extending the SQLite bind-safety net to Postgres. It cannot
fix the bare-string row (
'x'on ajsonbcolumn and'x'on atextcolumn areindistinguishable without knowing the column type), it would break a genuine native
text[]column on a table the driver was never told about, and it is a tolerant fallback at the consumer
for a defect whose producer is knowable — the shape AGENTS.md's contract-first rule refuses.
Evidence — three named defect controls, red before, green after
Live Postgres 16 (
OS_TEST_POSTGRES_URL,TimeZone=Asia/Shanghai, processTZ=America/New_York),through the real
SqlDriver.create()/update()paths. Not SQLite: the SQLite cell appearsonly as the expected non-effect in §4.
packages/drivers/driver-sql/src/sql-driver-json-binding-without-ddl.test.tssql-driver.tsatorigin/main)error: insert into "os10995_red_pref" … - invalid input syntax for type jsontrue/falseround-trip (insert + update)22P02[]round-trips as[], not{}AssertionError: expected false to be trueonArray.isArray(inserted)— it came back an objecterror: insert into "os10995_red_guest" … - invalid input syntax for type jsonpackages/objectql/src/skip-schema-sync-registers-object-metadata.test.tspins the boot half(no database, so it runs on every CI PR): the flag routes to the DDL-free registration and to no
DDL; the flag OFF still syncs and does not take the metadata route; federated objects stay with
registerExternalObject; a driver without the route degrades without throwing; a failedregistration is reported at
error. Pre-fix: 6 failed (plugin.installRegisteredSchemas is not a function— the seam did not exist; the old branch logged a line and returned).Reverse verification was run from the committed state and restoration proved on disk:
git hash-object==git rev-parse HEAD:PATHfor both mutated files,git diff --exit-code0, porcelain empty. The driver's tests import
../src/index.js— source, notdist— so themutation is in the file the run executes; no rebuild step is involved for the file under test,
and its dependency closure (
@objectstack/spec,@objectstack/core) was built beforehand.Suites and gates (all at
a68f1a2103)Gate union from
node scripts/pm/dispatch-gates.mjs(no path arguments, re-derived on the finalcommit — identical to the earlier derivation): all 24 path-matched families green, plus the five
convention-triggered ones. Two are worth naming:
check:query-options-erasurewent red first(the new pins pushed the test surface 240 → 243 with
as anyquery bags) and is green aftertyping them — "test surface: 240 site(s) … at the ceiling";
check:type-check-debt --re-measurereports "OK — 33 ledger entr(ies) re-measured … none above its recorded number" after a full
workspace build. Every exit code was captured before any pipe.
Out of scope, filed
updated_atis never refreshed on a deployment that skips boot schema sync —tablesWithTimestampsis also only filled by DDL #11067 —tablesWithTimestampsis filled only by DDL too, so the sameskipSchemaSyncposture never refreshes
updated_aton update. It cannot ride along here: on the DDL paththat set is decided from the physical columns (
columnInfo()), which no in-memoryregistration can answer, so it needs a decision (infer / probe once / declare) rather than a
silent guess.
sql-driver.tsalso carries #10997 and #11009. Neither is touched here:#10997 is not addressed in this PR, and#11009 remains open.Generated by Claude Code