Skip to content

fix(driver-sql): create and bulkCreate answer the stored row on MySQL (#21227) - #21239

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-21227-create-reads-back-stored-row
Oct 1, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-21227-create-reads-back-stored-row

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #21227

Clause-②: no

What changes

SqlDriver.create and SqlDriver.bulkCreate ran builder.insert(...).returning('*') and answered what the statement answered. MySQL has no RETURNING: knex's MySQL compiler drops the clause (it logs .returning() is not supported by mysql) and answers [insertId]. That is ONE element whatever the row count, and 0 for this driver's string primary key.

Both doors now answer the stored row on every dialect:

  • SQLite and PostgreSQL families: unchanged. They answer from RETURNING in one statement.
  • MySQL family, and any client the driver recognises as neither family: the INSERT is issued without .returning(), and the rows are read back by the ids that were written. That is one SELECT per create, and one per bulkCreate batch.

The switch is one protected capability getter, insertReturnsStoredRows (isSqlite || isPostgres), next to isMysql. One private helper, readBackInsertedRows, serves both doors. No caller was patched: the auth adapter, the engine and the protocol are untouched.

Measured before the fix (live MySQL 8.0.46, origin/main 62b90d74, driver called directly)

call answered stored
create, generated id 0 the row
create, supplied id 0 the row
bulkCreate, 3 rows [0] (length 1) all 3 rows
bulkCreate, 1 row [0] the row

SQLite and live PostgreSQL 16.14 answered the full stored rows in the same probe, including done: false, which only the column DEFAULT supplies. That is the control.

The doors, before and after (pnpm dev:crm -- --fresh --database mysql://... on that MySQL 8.0.46)

door at 62b90d74 with this change
POST /api/v1/auth/sign-up/email, first user (--no-seed-admin) 400 FAILED_TO_CREATE_USER; sys_user row stored, no sys_account row 200; user, credential account and session stored
POST /api/v1/auth/sign-in/email, same credentials 401 INVALID_EMAIL_OR_PASSWORD 200
--seed-admin at boot dev admin seed skipped: Failed to create user; orphaned sys_user seeded; admin sign-in 200
POST /api/v1/data/crm_account as the seeded admin not reachable (no session could exist) 201, with the full record including the stamped organization_id
boot: curated capability ... has no platform row and could not be seeded warnings 9 0

Decisions the card left open

  • H3: read back only where RETURNING does not answer the stored row. Reading back on every dialect would add one round trip to every create on SQLite and PostgreSQL, where RETURNING already answers the stored row (measured above). It would also move the control cells onto new code. Cost on MySQL: +1 SELECT per statement. Cost on SQLite and PostgreSQL: 0. The pin file counts the statements on every cell. The getter is a positive list on purpose. A client the driver does not recognise (a Client constructor, redshift, mariadb) reads back, which is correct on every dialect. RETURNING is the shortcut that only a dialect known to answer the stored row gets. driver-sqlite-wasm overrides isSqlite to true, so it keeps RETURNING.

  • H2: one helper for create and bulkCreate only. update and upsert are byte-unchanged. The four read-backs answer different things:

    • update reads by id under the caller's scope and answers null on a miss, which its contract allows.
    • upsert reads by the conflict-key values it matched on and falls back to the payload.
    • create must answer a row, and is keyed on ids it wrote.

    Sharing one helper would change one of those answers. It would also touch the upsert region, which the card fences off.

  • H4: the read key is the written id, and that id always exists. create and bulkCreate give every row its id before the statement is built: the caller's id, else _id, else a minted nanoid. The managed id column is varchar(255) PRIMARY KEY with no AUTO_INCREMENT, so no insert id is ever read. That is the only kind of key this path produces.

    • The column goes through remoteColumn, so an external columnMap that renames id is read by its physical column.
    • The table is the write target, a rotation shard included.
    • The tenant scope goes through applyTenantScope, scoped to the tenant(s) the rows were WRITTEN under (as assertMergeLandedOnSuppliedIdentity scopes its probe). For a batch that is the union through tenantIds. So an admin write that names another tenant in the row data is answered rather than missed.
    • The ids are this call's own and id is the PRIMARY KEY, so the read cannot answer another organization's row.
    • The read uses the caller's transaction when there is one.
  • A written row that is gone before the read-back (a concurrent delete, or a trigger) is refused with DATABASE_ERROR / 500. It is not answered with the payload, and the insert is not re-issued. The read-back runs outside the insert's try, so a read fault can never reach the autonumber collision retry.

One conclusion per face of the invariant (IDataDriver.create answers the inserted record)

  1. driver-sql: changed for the MySQL family. SQLite and PostgreSQL are already conformant and unchanged (evidence: the pin's control cells, green before and after).
  2. driver-sqlite-wasm and LOCAL-mode driver-turso: inherit SqlDriver.create / bulkCreate and stay on RETURNING. Wasm overrides isSqlite to true; Turso local uses better-sqlite3. Their suites are green: wasm 36 files / 675 tests, turso 86 files / 2313 passed, 33 skipped.
  3. REMOTE-mode driver-turso: already conformant. RemoteTransport.create issues its INSERT and then SELECT * ... WHERE "id" = ? and answers that row (remote-transport.ts). Its bulkCreate loops the driver's own create.
  4. driver-memory: already conformant. create pushes the built record and answers a copy of it, and bulkCreate answers the pending records it pushed.
  5. driver-mongodb: already conformant. create answers the document it inserted (minus _id), and bulkCreate answers the inserted docs in order.

Pins

packages/drivers/driver-sql/src/sql-driver-21227-create-answers-stored-row.test.ts, through declareDialectCell: SQLite always, and live PostgreSQL and MySQL where provisioned. The Temporal Conformance (live PG + MySQL) job runs them on both. There is also one cell that always runs: SQLite with the read-back path forced. It puts the read-back's ordering, tenant scope, transaction and refusal into every CI run, not only the job with a MySQL server. Each test pins one behaviour:

  • create with a generated id and with a supplied id;
  • bulkCreate of 3 rows, which answers 3 rows in written order from ONE insert, plus ONE read on the read-back path;
  • bulkCreate of 1 row;
  • a tenanted create;
  • an admin write naming another tenant (single and batch);
  • create and bulkCreate inside a rolled-back caller transaction;
  • the vanished-row refusal (forced cell only), asserting code and status and that the insert is not re-issued;
  • a per-cell check that the cell measures the path it claims to.

Every answer is compared with the driver's own findOne and must carry the DEFAULT-only done: false.

Reverse verification, both legs run with live PostgreSQL 16.14 and MySQL 8.0.46:

  • Fix reverted (sql-driver.ts at 62b90d74, worktree only, restore by trap with a hash check): 13 failed | 20 passed.
    • All 8 MySQL tests are red: expected +0 to deeply equal {...}, and expected [ +0 ] to have a length of 3 but got 1.
    • 3 forced-cell path tests are red.
    • The SQLite and PostgreSQL behaviour tests stay green (the control). Their path checks are red only because the getter does not exist before the fix.
  • Fix in place (HEAD f42d0354c3): 33 passed.

Sign-up door pin: not added, declared. No live-dialect harness runs the real auth stack against a datasource URL. The plugin-auth real-engine harness (signup-existing-address-refusal.test.ts and its siblings) hard-codes better-sqlite3. A MySQL door pin there would need a per-file database isolation helper in plugin-auth and a new CI step in the live job. Without that step, the pin is a named skip that never runs in CI. The door is measured above instead. The options are in the report for the PM.

Verification (HEAD ee7c024b92, after merging origin/main with #21225 in it)

  • pnpm --filter @objectstack/driver-sql exec vitest run --maxWorkers=2 with OS_TEST_POSTGRES_URL and OS_TEST_MYSQL_URL (PG 16.14, MySQL 8.0.46), OS_EXPECT_LIVE_DIALECT_MATRIX=1, TZ=America/New_York: 223 files passed, 5369 passed, 1 skipped. The skip is pre-existing, in schema-drift.base-type-mismatch.test.ts.
  • pnpm --filter @objectstack/driver-sqlite-wasm test (36 / 675 passed) and pnpm --filter @objectstack/driver-turso test (86 files, 2313 passed, 33 skipped): exit 0.
  • typecheck for driver-sql, driver-sqlite-wasm and driver-turso: exit 0. tsc --listFiles includes the new test file.
  • node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands derived 63 commands at ee7c024b92, and all 63 exited 0. --ran reconciliation: 63 derived, 63 run, 0 NOT-MEASURED, 0 UNRUN, all with recorded exit codes. This includes check:tenant-chokepoint ("every read builder routes through applyTenantScope()").
  • pnpm check:driver-conformance: before the first edit (62b90d74) 50 covered cell(s), 0 in the DEBT ledger, 0 exempt, dialect axis 8 conformance suite(s) ... 0 in the DIALECT ledger; after the last commit (ee7c024b92), identical.
  • Lint, a declared narrowing. eslint --no-inline-config --format json was run on the two touched TypeScript files.
    • Population: eslint's own --print-config resolves rules for both (6 and 5 rules; neither file is ignored).
    • Count: 2 files, 0 errors, 0 warnings.
    • Invariance: eslint.config.mjs enables no type-aware linting (parserOptions.project and projectService are null for both files), so this diff cannot move a verdict on an untouched file.
    • The repo-wide pnpm lint is left to CI.

Acceptance notes

  • sql-driver-21163-autonumber-prefix-like-escape.test.ts's header says its cases read the stored row "Not from create's return value: on MySQL that is not the row". After this change that sentence is stale. It is a test comment, not published; it is left for whoever next edits that file.
  • In the same MySQL boot, service-package's raw CREATE TABLE IF NOT EXISTS sys_packages (... created_at TEXT DEFAULT CURRENT_TIMESTAMP ...) is refused with ER_INVALID_DEFAULT (Invalid default value for 'created_at'), and later SELECT * FROM sys_packages reads answer ER_NO_SUCH_TABLE. No door was measured for it, so it is noted here and not filed.
  • The sys_activity boot failure is reported to the PM with a measured door, for the seat to file. It is not touched here.

Generated by Claude Code

claude added 4 commits October 1, 2026 20:02
On the MySQL family knex drops RETURNING and answers [insertId], so
create answered 0 and bulkCreate answered one element for the whole
batch. Where the dialect's INSERT answers no rows, both doors now read
back what they wrote, by the ids they wrote, under the tenant scope the
rows were written with. SQLite and PostgreSQL keep RETURNING unchanged.

Claude-Session: https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 1, 2026
@github-actions

github-actions Bot commented Oct 1, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/driver-sql, touching 5 documentable anchor(s).

9 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/drivers.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/data-modeling/index.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/data-modeling/queries.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/permissions/tenant-audit-census.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/plugins/packages.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/protocol/kernel/index.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/protocol/kernel/lifecycle.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/protocol/objectql/query-syntax.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/protocol/objectql/types.mdx (via SqlDriver (symbol, a top-level class))

⛔ 2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-0.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/releases/v17/17-5.mdx (via SqlDriver (symbol, a top-level class))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 11 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 3a7b6eb0635827442fa248baffa14187a60f5a22 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 0d61a56aa6b419d817fecebb169b933a388b404c — the merge of head ee7c024b92bc03e388eac0de377b78117cbfeeef into base 3a7b6eb0635827442fa248baffa14187a60f5a22, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 0d61a56aa6b419d817fecebb169b933a388b404c && git checkout 0d61a56aa6b419d817fecebb169b933a388b404c
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 3a7b6eb0635827442fa248baffa14187a60f5a22 ee7c024b92bc03e388eac0de377b78117cbfeeef && git checkout -B drift-repro 3a7b6eb0635827442fa248baffa14187a60f5a22 && git merge --no-ff ee7c024b92bc03e388eac0de377b78117cbfeeef

node scripts/docs-audit/affected-docs.mjs --json 3a7b6eb0635827442fa248baffa14187a60f5a22

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 3a7b6eb0635827442fa248baffa14187a60f5a22 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: ee7c024b92bc03e388eac0de377b78117cbfeeef
Local-runs: none

Inputs: card #21227 (body; comments 5938387083, 5938943912, 5940143677, 5940180378), PR #21239 (body, 3-file list, net diff against merge base 95e24b00, which is the origin/main the head merges, carrying #21185's upsert change), the head's source read with git show, and the head's check-runs, read last. GitHub's three-dot diff and git diff 95e24b00..ee7c024b are byte-identical (index lines aside). Head repo is the base repo (not a fork). No governed path in the file list.

① Derived judgments

  1. insertReturnsStoredRows (new protected getter, isSqlite || isPostgres): RIGHT. A positive list over SQLITE_EMIT_CLIENTS (sqlite3, sqlite, better-sqlite3) and POSTGRES_EMIT_CLIENTS (postgres, pg, postgresql, pgnative). mysql / mysql2, the wire-only cockroachdb / redshift, and a Client constructor (clientSpelling answers the empty string) all take the read-back, which is correct on every dialect at the price of one statement. driver-sqlite-wasm overrides isSqlite to true (sqlite-wasm-driver.ts:76) so it keeps RETURNING; driver-turso overrides create and bulkCreate outright (turso-driver.ts:2634, :3300) so the switch never reaches it; neither subclass overrides the new getter.

  2. create on the read-back path: RIGHT. The INSERT is issued without .returning() inside the existing try; the new continue in the catch's retry arm keeps the loop's semantics; readBackInsertedRows(object, rotationWriteTarget(object) ?? object, [toInsert], options) is reached only when the INSERT resolved. SQLite and PostgreSQL still answer formatOutput(object, result[0]) from the statement.

  3. bulkCreate on the read-back path: RIGHT. Same shape; ONE read for the batch over rows (the logical rows after id assignment and injectTenantOnInsert), answered in written order. The RETURNING arm is unchanged apart from the guard.

  4. The read-back (readBackInsertedRows), point by point:

    • Keyed on the written ids: RIGHT. Every row carries id before the statement (the caller's id, else _id, else a nanoid); the column is remoteColumn(object, 'id', 'id'), so a columnMap'd external object is read by its physical column; whereIn plus a byId map re-ordered through written.map(...), String(...) on both sides.
    • On the write target: RIGHT. getBuilder(rotationWriteTarget(object) ?? object, options), the same expression the INSERT used. The target is recomputed rather than captured from the INSERT; a rollover between the two statements needs a concurrent rotateShards or syncSchema (the only ensureRotation callers) inside that window, and the pre-existing decorateHashShadowDuplicate recomputes the same way. Not a contract gap; capture-once would be tighter. Noted, not required.
    • Tenant scope: RIGHT. The scope is the set of tenant values on the WRITTEN logical rows, threaded as tenantId: writtenTenants[0] plus tenantIds: writtenTenants, which applyTenantScope compiles to IN (...) OR IS NULL. Can it answer a row of another organization? No: id is .primary() on managed tables and on every rotation shard (DDL at sql-driver.ts:14363, :11751, :12330), and the INSERT succeeded with these ids, so no other row carries them; the id predicate alone pins the row and the tenant predicate only narrows. Can it miss a row written under an admin-named organization? No: the named value sits on the written row and so in the union; with no tenant on the call and none on the rows the read is unscoped by applyTenantScope's own contract. The caller's membership tenantIds is replaced, not widened, which is right: the read asks about this call's rows, not the caller's reach. (An external object whose id is not unique is the pre-existing update read-back's exposure too; outside this card.)
    • On the caller's transaction: RIGHT. getBuilder(..., options) applies options.transaction.
    • One row per written row, in written order: RIGHT, by the keyed re-ordering above.
    • A missing row: RIGHT. insertedRowsNotReadBackError sets code = StandardErrorCode.enum.DATABASE_ERROR and status = 500, carries the ids under a non-enumerable cause, and its message names no tracker number. It is thrown from outside the try, so it can never enter the autonumber collision re-seed, and the INSERT is never re-issued.
  5. update and upsert byte-unchanged: RIGHT, verified. update (1540 bytes), upsert (23044 bytes), bulkUpdate, delete and bulkDelete extracted from merge base and head compare byte-equal; every hunk of the diff lands in create, bulkCreate, the new helper, the new getter or the new error function. The insertOnlyUpsertColumns region [security] driver upsert: a tenant-scoped upsert keyed on a globally-unique business column can merge into, and re-parent, another tenant's row #21185 edited is untouched.

  6. Callers untouched: RIGHT. No change in plugin-auth, objectql or the protocol; the auth adapter's create answers dataEngine.insert's result (objectql-adapter.ts:849-872), so the door inherits the fix without a workaround. The engine's ERR_BULK_RESULT_MISMATCH guard (engine.ts:13517) is the one the changeset describes; it stays as the guard and is now satisfied on MySQL.

  7. The pins (sql-driver-21227-create-answers-stored-row.test.ts) prove what they claim, with controls.

    • Per-dialect cells through declareDialectCell: SQLite always; PG and MySQL where provisioned, and under OS_EXPECT_LIVE_DIALECT_MATRIX=1 an unprovisioned cell is a named red, not a skip. The required Temporal Conformance (live PG + MySQL) job sets that flag and runs pnpm --filter @objectstack/driver-sql test, so the MySQL cell runs on every PR.
    • Non-vacuity: measures the path it claims to asserts the getter's value per cell.
    • Stored, not echoed: done: false is DEFAULT-only (never in a payload) and every answer must equal the driver's own findOne.
    • Statement counting through knex's query event filtered to the fixture table: the control cells assert exactly ['insert'] (no new statement on SQLite or PG); the read-back cells assert ['insert', 'select'] for the single create and for the 3-row batch (ONE read); the test's own findOne is accounted for by position.
    • The forced read-back cell on SQLite (insertReturnsStoredRows overridden to false) puts the written order, the written-tenant union (org_a stamped and org_c named in one batch), caller-transaction visibility with rollback, and the vanished-row refusal (an AFTER INSERT trigger deletes the row; asserts code, status and ['insert', 'select'] with no re-issue) into every CI run.
    • Gaps, not defects: no cell exercises a rotation shard or a columnMap'd id on the read-back path; both reuse the INSERT's own resolution. The reverse-verification leg (13 red with the fix reverted) is the dev's report; this review is read-only and did not re-run it.

② Semver level

.changeset/21227-create-reads-back-stored-row.md: '@objectstack/driver-sql': patch, Clause-②: no at line start (line 7); the PR body carries Clause-②: no at line start (line 3). RIGHT. The change moves MySQL onto the contract IDataDriver.create already declares ("Create a new record. MUST return id as string", packages/spec/src/contracts/data-driver.ts:184-187) and the card quotes; no accept set widens (nothing an author writes changes) and no public door changes signature. The one new member, the protected getter insertReturnsStoredRows, is an extension hook on an exported class of the same kind as isMysql, which itself arrived under a patch changeset (06ba036270); no .d.ts narrowing. The new refusal (DATABASE_ERROR / 500 when a written row vanished before the read) is a failure arm on a path that previously answered a wrong value: a fix toward the contract, not a narrowing. Prose accuracy: every sentence matches what was measured and read — knex's dropped clause and [insertId], 0 for the string key, the one-element array whatever the row count, sign-up answering 400 FAILED_TO_CREATE_USER with the user stored and no account, the admin seed failing, the engine refusing a multi-row batch after its rows landed, one extra SELECT per create and per batch on MySQL and none on SQLite or PostgreSQL, the refusal with no retry, and "nothing to change in a project". Citing #21227 in the changeset follows five sibling changesets that do the same.

③ Boundary flags

  • (a) MySQL server packages installed in the shared container (mysql-server-core-8.0, mysql-client-core-8.0) and left installed: declared; AGENTS.md states no rule on container packages; the server was stopped by its recorded PID and every data dir removed. ESCALATED to the seat as a state change to the shared container (binaries remain, nothing runs). Not a diff matter.
  • (b) /tmp/os-21227 for the MySQL socket and PG data dir, with a mechanical reason (the 107-byte socket limit; the postgres user cannot traverse the scratchpad): declared, torn down. Answered.
  • (c) one dev server outlived its shell and was killed by its own recorded PGID: the dev killed only what it started. Answered.
  • (d) commit trailers: the branch commits carry Claude-Session: plus Co-authored-by: Claude, the model-free pair AGENTS.md prescribes, and the harness reminder defers to project rules by its own text. Answered, right.
  • (e) worktree removed after the PR opened; the remote head holds every commit. Answered.
  • Sign-up door pin: triage's pin list asked for "the sign-up door answering 200 on a MySQL datasource". The dev measured that no live-dialect harness runs the real auth stack on a datasource URL and asked; the seat answered B (5940180378): the driver pins are the pins of record, the door is a recorded one-off BASE-versus-fix measurement in the PR body, A's restart condition is named, C is refused. Judged RIGHT as a verification-strategy decision inside the seat's authority: the defect is one driver door, the adapter is dialect-agnostic (confirmed at objectql-adapter.ts:849-872), and the driver pins run on live MySQL in a required job. Recorded as a deviation from triage's pin list, accepted by the owning seat, with the restart condition on the card.
  • sys_activity on MySQL: filed as driver-sql on MySQL: a declared datetime field defaulting to NOW() gets a precision-less CURRENT_TIMESTAMP default on its datetime(3) column, so MySQL 8.0 refuses the table: sys_activity is never created and its data door answers 500 #21241 by the seat with the measured door (GET /api/v1/data/sys_activity?limit=1 answers 500) and the corrected column (timestamp, not created_at). Answered.
  • sys_packages DDL on MySQL: service-package's raw CREATE TABLE IF NOT EXISTS sys_packages (... created_at TEXT DEFAULT CURRENT_TIMESTAMP ...) (packages/services/service-package/src/index.ts:653) is refused with ER_INVALID_DEFAULT, and later sys_packages reads answer ER_NO_SUCH_TABLE. A reproducible defect with a named landing site, left in the PR's acceptance notes because no door was measured. ESCALATED: the seat measures the /packages door on MySQL and files it. Note its shape: a non-driver, MySQL-only door defect, which is what option A's restart condition names.
  • Stale header in sql-driver-21163-autonumber-prefix-like-escape.test.ts (lines 42-46, "Not from create's return value: on MySQL that is not the row"): false once this merges; a test comment, unpublished. ESCALATED as a one-line follow-up for the next edit of that file; the dev's choice not to widen the diff is within scope discipline.
  • Check-runs on the head, read at 2026-10-01T20:55Z:
    • Required, completed: Build Core success (2026-10-01T20:45:49Z); Dogfood Regression Gate success (2026-10-01T20:46:24Z); Governed Surface Queue Guard success (2026-10-01T20:41:52Z); Temporal Conformance (live PG + MySQL) success (2026-10-01T20:47:37Z), the job that runs the MySQL and PG cells of the new pin file.
    • Required, not yet a verdict: Lint & Repo Gates in_progress; the Test Core gate not yet reported (shards 2/6, 3/6, 4/6 success; 1/6, 5/6, 6/6 in_progress); the TypeScript Type Check gate not yet reported (source gates, consumer gates and debt ledger success; workspace in_progress).
    • Non-required: Check Changeset success; every other completed run is success or a declared skip (Build Docs, Console Pin Gate, Packed-tarball smoke).
    • The seat confirms the green bar itself before enqueue; an in-progress required job is recorded here as not yet a verdict and does not by itself make this record FAIL.

Implemented-by: claude/issue-21227-create-reads-back-stored-row
Reviewed-by: session_017xfMoEjKUuSh2xYB8sCozp

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 1, 2026 21:05
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 1, 2026 21:05
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 1, 2026
Merged via the queue into main with commit be5a83c Oct 1, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21227-create-reads-back-stored-row branch October 1, 2026 21:32
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…precision (objectstack-ai#21252)

Fixes objectstack-ai#21241

Clause-②: no

## What was wrong

On MySQL a declared `Field.datetime` is built as `DATETIME(3)`. For its
`defaultValue: 'NOW()'` default,
`SqlDriver.nowColumnDefault('datetime')` fell through to a bare
`knex.fn.now()`, which is `CURRENT_TIMESTAMP` at precision 0. MySQL 8.0
refuses a `CURRENT_TIMESTAMP` default whose precision differs from its
column's, so the whole `CREATE TABLE` failed and so did `ALTER TABLE …
ADD`. The builtin `created_at` / `updated_at` columns beside it carried
their own literal `now(3)` and were accepted.

Measured at the base `be5a83cf` against a throwaway MySQL 8.0.46 (server
zone `+08:00`):

| boot | schema sync | refused DDL | data door |
|:--|:--|:--|:--|
| `pnpm dev:crm -- --fresh --database mysql://…` | `synced 80, skipped
0, failed 1` | `sys_activity`: `` `timestamp` datetime(3) default
CURRENT_TIMESTAMP `` → `Invalid default value for 'timestamp'` | `GET
/api/v1/data/sys_activity?limit=1` → `500 DATABASE_ERROR` |
| `pnpm dev:showcase -- --fresh --database mysql://…` | `synced 102,
skipped 2, failed 2` | `sys_activity` as above, and `sys_presence`: ``
`last_seen` datetime(3) default CURRENT_TIMESTAMP `` → `Invalid default
value for 'last_seen'` | `GET /api/v1/data/sys_presence?limit=1` →
`500`, `sys_activity` → `500` |

## The fix

- **One source for the precision.** `MYSQL_DATETIME_PRECISION` is a
module constant in `sql-driver.ts`. Five sites read it: the declared
datetime column (`createColumn`), the builtin audit columns
(`createAuditTimestampColumn`), the `NOW()` default
(`nowColumnDefault`), the UPDATE stamp (`updatedAtStamp`) and the legacy
`TIMESTAMP` widening (`migrateMysqlDatetimeColumns`). Before this PR,
five literal `3`s lived at those sites. Now there is one, and no new
literal was added.
- **`nowColumnDefault('datetime')` on MySQL** returns
`knex.fn.now(MYSQL_DATETIME_PRECISION)`, which renders
`CURRENT_TIMESTAMP(3)`.
- **The builtin audit column's MySQL default is routed through
`nowColumnDefault('datetime')`**, the way the SQLite branch already is.
The declared column and the builtin column now share one definition. The
emitted DDL for the audit columns is byte-identical: `datetime(3)
default CURRENT_TIMESTAMP(3)`.
- PostgreSQL and SQLite emit unchanged SQL. On PostgreSQL the driver
builds `timestamptz`, and `CURRENT_TIMESTAMP` on it was measured
accepted on PostgreSQL 16.14. `timestamp(3) default CURRENT_TIMESTAMP`
is also accepted there, rounding silently. So the mismatch does not
exist on that dialect.

### Bounded in-place fix in the same statement: the `TIMESTAMP` widening
dropped a declared `NOW()` default

The widening's `ALTER … MODIFY` line now reads the constant, so this PR
touches it. That same statement restated the default of `created_at` /
`updated_at` and dropped the default of a declared `NOW()` column. The
`TIME` twin (`migrateMysqlTimeColumns`) already restates it.

Measured on MySQL 8.0.46 with the driver built at this branch before the
change: a legacy `stamped_at timestamp null default current_timestamp`
column came out of schema sync as `datetime(3)` with `COLUMN_DEFAULT`
`null`, and `create` without the field answered `stamped_at = null`. The
widening now restates `nowColumnDefault('datetime')` for a declared
`NOW()` column as well. All four conditions for an in-place fix hold:

- same family (a MySQL `NOW()` datetime default that is not honoured);
- mechanical, with the shape fixed by the `TIME` twin;
- same file and claim;
- same gate family.

The changed lines are `sql-driver.ts` `migrateMysqlDatetimeColumns`, and
a MySQL-only pin, §3.

## Pins:
`packages/drivers/driver-sql/src/sql-driver-21241-mysql-now-default-precision.test.ts`

- **§1**, run on every runner with no server: for `mysql2`, `pg` and
`better-sqlite3`, the compiled DDL of a declared `NOW()` datetime column
is byte-identical to the builtin audit column's. A second case reads the
`mysql2` DDL of the required `sys_activity.timestamp` shape and checks
that its `CURRENT_TIMESTAMP(n)` names the column's own `datetime(n)`.
This compares against the column, not against a literal.
- **§2**, one cell per dialect through `declareDialectCell`:
- the table syncs, both on create and on add-column for a table that
already exists;
- `create` without the field answers the instant the column `DEFAULT`
stored. The check uses a window and confirms that the answer equals
`findOne`;
- a raw insert that never names the column is filled by the `DEFAULT`
alone;
- the server's own catalogue (`information_schema` / `pragma
table_info`) reports the declared column's type and default equal to
`created_at`'s.
- **§3**, MySQL only: a legacy `TIMESTAMP` `NOW()` column keeps a
default through the widening, equal to `created_at`'s.

## Verification (HEAD `d334fe314b`)

Every reading below was re-run at `d334fe314b`: the branch plus one
merge of `origin/main` `4727fcb22a`, which touches no `driver-sql` file
and no lockfile. The readings match the earlier head `e5cab5f20b`,
except the `driver-turso` count, which moved because objectstack-ai#21226 landed on
`main`. (Seat edit.)

The live servers were local: MySQL 8.0.46 at `+08:00` and PostgreSQL
16.14 at `Asia/Shanghai`, with `TZ=America/New_York`.

- Pins: `vitest run --reporter=verbose
src/sql-driver-21241-mysql-now-default-precision.test.ts` with both URLs
set → **17 passed (17)**.
- **Reverse verification.** `sql-driver.ts` was written back to the base
blob `e65a0f08` while the HEAD blob `533a790b` stayed committed. On-disk
hash equal to the base blob, `grep -c MYSQL_DATETIME_PRECISION` → 0.
Result: **7 failed | 10 passed (17)**. Every failure is MySQL: §1
`mysql2` ×2, §2 live mysql ×4 (`Invalid default value for 'stamped_at'`
at create and at add-column), and §3. The SQLite and PostgreSQL cells
stayed green (the control). The restore was checked: hash equal to the
HEAD blob and `git diff HEAD` empty.
- **Ablation of the widening half.** Through
`scripts/ablation-replace.mjs`: anchor 1 → 0, blob `533a790b` →
`16057202`. Only §3 went red, `dflt: null` against
`CURRENT_TIMESTAMP(3)`; the other 16 stayed green. Restored: blob equal
to HEAD and `git diff HEAD` empty.
- `@objectstack/driver-sql`, the whole suite against both live servers
with `OS_EXPECT_LIVE_DIALECT_MATRIX=1`: **224 files passed, 5386 passed
| 1 skipped**. The live-dialect reporter printed "all 3 dialects were
exercised".
- `@objectstack/driver-sql`, the SQLite tier (`pnpm --filter
@objectstack/driver-sql test`, no URLs, at `04b23d2791` before the
widening commit): 213 files passed | 11 skipped, 3570 passed | 200
skipped.
- Typecheck of `@objectstack/driver-sql`,
`@objectstack/driver-sqlite-wasm` and `@objectstack/driver-turso` (the
inheritors): all three `Done`. `tsc --listFiles` includes the new test
file once.
- Tests of the inheritors: `driver-sqlite-wasm` 36 files / 675 passed;
`driver-turso` 87 files / 2349 passed | 33 skipped.
- `pnpm check:driver-conformance` gave the same reading before the first
edit and after the last commit: `OK — 50 covered cell(s), 0 in the DEBT
ledger, 0 exempt`, with 0 in the DIALECT ledger.
- Gates: `node scripts/pm/dispatch-gates.mjs --repo
objectstack-ai/objectstack --commands` derived 63 families. All 63 ran
with exit 0. `--ran` reconciliation: `63 derived, 63 run, 0
NOT-MEASURED, 0 UNRUN`, with every line recording its exit code. The
derivation warned that the tree is one `main` commit behind
(`3dc33b2d13`, file-disjoint from this diff, and it touches
`check-route-envelope.mjs` / `engine-double-contract.pinned.json`). CI
re-derives on the merge ref.
- Lint, narrowed:
- `eslint --no-inline-config --format json` over the two changed `.ts`
files: 2 files, 0 errors, 0 warnings.
  - Population: both files are linted, not ignored.
- Invariance: the config's `parserOptions` carry no `project` /
`projectService`, so linting is not type-aware and this diff cannot move
a verdict on an untouched file.
  - Repo-wide `pnpm lint` is left to CI.

### After the fix, same boot

`pnpm dev:showcase -- --fresh --database mysql://…` at `e5cab5f20b`:
- no `Schema sync FAILED`;
- `GET /api/v1/data/sys_presence` → 200 and `sys_activity` → 200;
- `information_schema` reports `sys_activity.timestamp` and
`sys_presence.last_seen` as `datetime(3)` / `CURRENT_TIMESTAMP(3)`,
equal to their `created_at`;
- a `POST` / `PATCH` / `DELETE` on `showcase_account` left three
`sys_activity` rows (`created`, `updated`, `deleted`).

## Raise-rule reading (triage)

At the base on MySQL, with `sys_activity` absent, a missing table does
not break a record write. `POST /api/v1/data/crm_account` → 201, `PATCH`
→ 200 (a read-back showed the new name), `DELETE` → 200 (a read-back
answered 404). `sys_audit_log` holds 3 rows for the record. Each
mutation lost its activity row: the server logged `Insert operation
failed {object: sys_activity …}` at `warn` and `Audit write FAILED
(ER_NO_SUCH_TABLE …)` at `error`. The API answers carried nothing about
it. Applying the rule is the seat's decision.

## Acceptance notes

- **Existing tables / migration (H5).** On MySQL no table could have
been created with the refused default, so a fresh boot after this change
creates the missing tables and no migration is owed. A column that an
earlier release's `TIMESTAMP` widening already left without a default
does not get one back from this change. The widening only touches
columns that are still `timestamp`. This is a read-only inference beyond
the measurement above.
- **MariaDB not measured.** No MariaDB server here. `client: 'mariadb'`
is not in the driver's MySQL family, but `mysql2` pointed at a MariaDB
server is. Whether MariaDB accepted the bare default, and so holds
tables with a precision-0 default, is NOT MEASURED.
- **Operator text.** At the base, the `Audit write FAILED` line for the
`sys_activity` insert names `sys_audit_log` as the row that "never
landed" and as the table to check. It printed 4 times despite "reported
ONCE". Observation only, nothing filed.
- **`sys_packages`.** Its raw DDL (`created_at TEXT DEFAULT
CURRENT_TIMESTAMP`) is still refused on MySQL in both boots. This was
already recorded in PR objectstack-ai#21239's acceptance notes; it is
`domain:services`, and no door was measured.
- **Not filed from this PR, handed to the seat in the report.** At the
base on MySQL, `GET /api/v1/auth/jwks` and `GET /api/v1/auth/token`
answered 500. An insert into `sys_jwks` is refused with `Incorrect
datetime value … for column 'updated_at'`, and the server logs `JWT
signing failed with alg "EdDSA"`.

---
_Generated by [Claude
Code](https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants