Skip to content

driver-sql: updateMany() never stamps updated_at, and upsert()'s merge branch does not advance it on Postgres/MySQL — on every deployment, DDL or not #11176

Description

@os-zhuang

Found while fixing #11067 (deliberately out of that PR's scope — different mechanism, different fix).

#11067 is about updated_at not being stamped on skipSchemaSync deployments, because tablesWithTimestamps is only filled by DDL. These two are not that: they are missing on every deployment, including a fully DDL-managed one where tablesWithTimestamps is correctly populated.

1. updateMany() stamps nothing

packages/drivers/driver-sql/src/sql-driver.ts:

async updateMany(object: string, query: DriverQuery, data: any, options?: DriverOptions): Promise<number> {
  this.auditMissingTenant(object, 'updateMany', options);
  let total = 0;
  for (const target of this.rotationShardsOf(object) ?? [object]) {
    const builder = this.getBuilder(target, options);
    this.applyTenantScope(builder, object, options);
    if (query.where) this.applyFilters(builder, query.where);
    total += (await builder.update(data)) || 0;
  }
  return total;
}

No tablesWithTimestamps consultation, no updated_at. update() and rotatedUpdateById() both stamp; updateMany() does not. A bulk edit therefore leaves every row it touched reading its previous updated_at.

Worth noting separately during triage: updateMany also passes data straight through, without formatInput / applyWriteColumnMap — unlike every other write path. That may be deliberate or may be a second defect; it is not measured here.

2. upsert()'s merge branch does not advance updated_at on Postgres/MySQL

The merge site carries this comment:

Everything else (incl. updated_at) merges as before, so an upsert that updates a row still advances updated_at.

That holds only when updated_at is present in formatted, which requires either the caller to supply it or stampInsertTimestamps to have put it there — and stampInsertTimestamps returns early on any non-SQLite dialect (if (!this.isSqlite || …) return;). So SQLite is accidentally correct and Postgres/MySQL are not. The INSERT … DEFAULT now() does not re-fire on the conflict path.

The neighbouring #8622 comment states the SQLite half of this from the other direction — "(SQLite never reached it: stampInsertTimestamps puts a mergeable updated_at in the payload there.)" — so the asymmetry is already known at the site; only its consequence for updated_at was not drawn.

Measured on live PostgreSQL 16.13, through initObjects (so tablesWithTimestamps IS filled), 1.2 s between the two calls:

DDL path (tablesWithTimestamps filled), PG upsert-that-merges:
  title      : second                      <- the merge landed
  created_at : 2026-08-23T00:17:40.602Z
  updated_at : 2026-08-23T00:17:40.602Z    <- unchanged: still the INSERT default
  ADVANCED?  : false
  update() ADVANCED? : true                <- same table, same driver, contrast

Consequence

Same shape as #11067 and the same consumers: list-view sorts, delta/incremental sync, cache invalidation and audit answers read updated_at as "last modified". Both paths leave it stale without erroring, so nothing is unavailable — the answers are just wrong. A bulk status change (updateMany) and a sync/import that upserts (the common shape for connector and seed writes) are exactly the operations most likely to be feeding a downstream delta consumer.

Not fixed in #11067's PR

Deliberately: #11067's fix is about the DDL-coupling of tablesWithTimestamps, and it neither introduces nor widens either of these. Whether bulk and merge writes should advance updated_at is one decision covering both paths, which is why they are filed together rather than split.

Activity

  1. added theissue type on Aug 23, 2026
  2. os-zhuang commented on Aug 23, 2026

    @os-zhuang
    ContributorAuthor

    Triage: pm:queue, domain:engine, Bug. updateMany() never stamps updated_at and upsert()'s merge branch does not advance it on PG/MySQL — hard serial constraint: #11067 is in flight on the same driver-sql timestamp surface (claimed today); this card queues BEHIND it and the claimer must re-verify the file surface on the merged ref after #11067 lands (the #11067 fix may absorb or reshape this one — premise_still_valid: false is a legal outcome).


    Generated by Claude Code

  3. self-assigned this
    on Aug 23, 2026
  4. os-zhuang commented on Aug 23, 2026

    @os-zhuang
    ContributorAuthor

    Claim — same-file serialization released

    Seat domain:engine · session session_01RfyXxZ2WPjcjhuXpiQQc3y · branch claude/issue-11176-updatemany-upsert-updated-at

    Why it is dispatchable now. This card was held solely because it edits packages/drivers/driver-sql/src/sql-driver.ts, which the introspection trio (#11161/#11162/#11163) held. PR #11203 merged at 03:25:55Z, all three cards closed completed. I then verified the file is genuinely free by reading the actual changed-file list of all 8 currently-open PRs — zero touch driver-sql/src/sql-driver.ts. That is by landed surface, not by declared surface: over-declaring a file面 needlessly serialized two cards for a full round earlier this term, and the fix is to check what actually lands.

    On-hold neighbours in this same file — checked, neither triggers

    Two pm:on-hold cards live in sql-driver.ts, and a dispatch into their neighbourhood has to be handled under their own restart terms rather than ignored:

    Both conditions are written against symbols, not the file — which is why a same-file dispatch doesn't spuriously wake them. Worth noting as the pattern to copy.

    ⚠️ But the dev will be working inside upsert()'s merge branch, which is #8740's subject matter. #8740 is deliberately held, not forgotten. ⛔ Do not "fix" it as a rider.

    Declared file surface

    • packages/drivers/driver-sql/src/sql-driver.ts — updateMany() and upsert()'s merge branch, plus whatever stampInsertTimestamps / tablesWithTimestamps consultation the fix needs.

    A prediction. If it lands narrower, say so and the hold releases early.

    Tier: default (not clause ②)

    Judged from card content: this restores a declared contract (updated_at means "last modified"; update() and rotatedUpdateById() already stamp) rather than changing the accept/reject set or widening the public surface. Mechanical boundary test — 接受集不变、拉回已声明契约 ⇒ bug. ⚠️ If implementation shows this is really a decision about whether bulk and merge writes should advance updated_at rather than a restoration of declared behaviour, stop and fork it back here — that reclassification is mine to make, not the dev's to absorb.

    Constraints

    1. The card's second observation — updateMany passes data straight through without formatInput / applyWriteColumnMap, unlike every other write path — is explicitly not measured by the card ("may be deliberate or may be a second defect"). ⚠️ Measure it and report; ⛔ do not fix it in this PR unless it is load-bearing for the timestamp fix. If it is a second defect, it gets its own card.
    2. Both paths are one decision, which is why they are filed together — fix them together, but keep them separately reviewable.
    3. Live PG/MySQL are reachable both in this container (pg_ctlcluster 16 main start → 16.13; apt-get install mysql-server-8.0 → 8.0.46) and in CI (required job Temporal Conformance (live PG + MySQL)). The card's own measurement is live PG 16.13 through initObjects. ⛔ "I can't test that here" is not available on this card.
    4. ⛔ packages/spec off limits · ⛔ never edit content/docs/releases/ · breaking changes ship as minor, never major · ⛔ no skipped/disabled/quarantined tests · ⛔ no raised ratchet ceilings.

    Generated by Claude Code

  5. os-zhuang commented on Aug 23, 2026

    @os-zhuang
    ContributorAuthor
    {
      "issue": 11176,
      "status": "done",
      "branch": "claude/issue-11176-updatemany-upsert-updated-at",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/11240",
      "premise_still_valid": true,
      "summary": "PR #11240 (draft), final commit 653ab3058 (main merged in at dd84ddd79; branch delta re-asserted after the merge as exactly 3 files, +566/-6). Both defects confirmed live on post-#11203 main and fixed in packages/drivers/driver-sql/src/sql-driver.ts. (1) updateMany() now stamps updated_at, reusing #11067's decision whole rather than re-deriving it — stampsUpdatedAt decides, keepSuppliedUpdatedAt honours #3493's preserveAudit import, updateWithPresumedTimestamp carries the speculative case so a hand-migrated table lacking the column keeps working; the statement is defined once as an issue closure so the speculative attempt, fenced retry and plain path cannot drift in WHERE or tenant scope; when nothing is stamped the payload is passed through untouched so a timestamp-less object's SQL is byte-identical to main's. (2) upsert() gains stampUpsertUpdatedAt, which puts a mergeable updated_at in the payload on every dialect, filling only an EMPTY slot (so a caller-supplied value is preserved and SQLite's emitted SQL is unchanged). TIER CONFIRMED DEFAULT, not a decision — evidence firmed it rather than reclassifying: update(), bulkUpdate() and rotatedUpdateById() already stamp, and driver-memory's own updateMany() stamps updated_at unconditionally, so driver-sql's two doors were the outliers against an already-declared contract; nothing about the accept/reject set moves. TWO DECLARED NARROWINGS, both in the PR body: (a) the upsert stamp reads OBSERVED presence (observedUpdatedAtColumn), never #11067's presumed state, because the INSERT door has no recovery and a wrong presumption there would name a missing column in an INSERT column list — a new rejection for a working call; net effect is that a skipSchemaSync deployment's upsert stamps only after some stamped UPDATE has settled the table as present, which fixes fewer cases and breaks none. (b) the upsert stamp uses a precision-matched now (fn.now(3) on MySQL) rather than updatedAtStamp()'s bare fn.now(), because that value also lands on the INSERT branch where the column DEFAULT is now(3) — measured on live MySQL 8.0.46, the bare form would put a freshly inserted row's updated_at 357 ms EARLIER than its own created_at. ON-HOLD NEIGHBOURS: NEITHER restart condition touched. #8740 — insertOnlyUpsertColumns gains no member, no fourth dialect, no conflict-target column gains a non-null DEFAULT; the change adds a MERGEABLE column to the payload, not an insert-only one. Not repaired. One consequence recorded at the #8622 note in the code: a timestamped object no longer reaches the empty-merge-set fallback on PG/MySQL either (it already did not on SQLite); the branch stays reachable, and stays #8740's to decide, for an object with no observed updated_at column. #6009 — neither sqliteCanonicalDatetimeSql nor backfillCanonicalDatetimes is in the diff. Not triggered. DUPLICATE CHECK: repo-scoped PR search for 11176 returns only #11177, which is #11067's PR; no other branch or PR claims this card. Changeset added (patch, @objectstack/driver-sql). skip-changeset label not applicable — this PR ships a real changeset.",
      "tests": "All figures from HEAD 653ab3058, tree clean, every exit code captured by redirect-then-capture (never across a pipe); each gate quoted by its own verdict line. BEFORE/AFTER (reverse verification from the COMMITTED state; driver source swapped with `git restore --source=origin/main -- <path>` — tree-only, porcelain showed a lone M, never MM — and the swap proved on disk both ways by grep: injected marker 0 then 6, main-only line 1 then 0). Live PostgreSQL 16.13 (timezone='Asia/Shanghai') + live MySQL 8.0.46 (time_zone='+08:00'), process TZ=America/New_York, OS_EXPECT_LIVE_DIALECT_MATRIX=1, tables built by the driver's own initObjects so tablesWithTimestamps is populated — the card's own DDL configuration. BEFORE (unmodified main): 12 failed | 18 passed — sqlite RED on §1 §7 only (upsert legs §3 §3b §3c §5 GREEN: SQLite accidentally correct, the asymmetry observed not argued); live postgres and live mysql each RED on §1 §3 §3b §5 §7. Received value on every red: 'AssertionError: expected 1577836800000 to be greater than 1577836800000' (1577836800000 = the frozen 2020-01-01T00:00:00.000Z sentinel — the row's own INSERT-time value, unmoved). Direction was predicted in the test file's head note before running. AFTER: 30 passed (10 legs x 3 cells), 0 failed. FIRST baseline attempt had §3b NOT MEASURED on the MySQL cell — it died in fixture setup ('BLOB/TEXT column status used in key specification without a key length', because the object declared status as `text`); the leg was re-declared as `string`, committed, and BOTH legs re-run from the committed state, which is where the numbers above come from. FULL PACKAGE SUITE at 653ab3058: `pnpm --filter @objectstack/driver-sql exec vitest run --maxWorkers=2` under the same three live cells → 'Test Files 119 passed (119) · Tests 2449 passed (2449)'. `pnpm --filter @objectstack/driver-sql typecheck` → tsc --noEmit, exit 0. GATES: `node scripts/pm/dispatch-gates.mjs` run with NO paths (it derived its own change set: '3 path(s) vs merge base 9cc1940a1, three-dot semantics'), naming 13 path-derived families + 6 convention-triggered; all 19 run, plus check:adr-anchors (which that derivation does not select — the dispatch called it out) and check:nul-bytes. All exit 0. Verdict lines: 'check-adr-anchors: OK (52 anchored file(s), every governing ADR still referenced; 123 decision number(s) ...; 27385 citation(s) across 3431 file(s) resolve)'; 'check-nul-bytes: OK (scanned 6505 text file(s) ... no raw ASCII control bytes)'; 'check-driver-conformance: OK — 45 covered cell(s), 0 in the DEBT ledger, 0 exempt'; 'check-engine-double-contract: OK — 384 pinned, 133 in the DEBT ledger, 2 exempt'; 'cross-package-test-inputs OK: 13 package(s) read outside themselves, all declared'; 'where-matcher conformance holds: 282 matcher(s) discovered, 282 answer the combinator battery correctly or refuse it loudly'; 'query-options-erasure ratchet holds: 67 unswept non-test site(s) in 17 file(s), none new'; 'check-type-check-coverage: OK — 65/78 workspace packages type-checked'; 'check-type-check-coverage --re-measure: OK — 33 ledger entr(ies) re-measured in 327.8s, 1896 raw tsc error(s) total, none above its recorded number' (run on a FULLY BUILT closure — `turbo run build --filter=./packages/* --filter=./packages/*/*`, 70/70 tasks — since the gate refuses an unbuilt worktree and that refusal is NOT MEASURED); 'check-adr-0087-registration: this PR adds no declared-breaking changeset'; 'check-changeset-no-major: This diff introduces no major bump'; 'check-empty-changeset: No empty-frontmatter changeset introduced by this diff'. After the main merge: `pnpm --filter @objectstack/spec check:generated` → 'All 14 generated artifacts are up to date'. REPO-WIDE LINT RUN IN FULL, no narrowing needed: `pnpm lint` = `eslint . --no-inline-config` over the whole repo, exit 0, zero findings, 123s. NO ratchet ceiling raised, NO test skipped/disabled/quarantined, no `--force`, no force-push. DECLARED NARROWING (one, on downstream breadth): driver-sql has 48 downstream dependents; instead of the whole closure the DOWNSTREAM direction was run for six named explicitly — chosen by grepping every downstream test file for updateMany or .upsert( together with updated_at, plus the two SqlDriver subclasses: driver-turso 1006, driver-sqlite-wasm 395, objectql 4049, service-settings 477, service-messaging 259, plugin-approvals 565 — all passed. The rest is CI's, and the merge queue re-runs the required set on the rebuilt generation. No ablation was performed on this card, so no rebuild/mutation-on-disk claim is owed.",
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as #11223: driver-sql updateMany() bypasses formatInput/applyWriteColumnMap — MEASURED LIVE, three distinct defects, not one. (a) json and array values are REFUSED: live PG '22P02 invalid input syntax for type json', SQLite 'SQLite3 can only bind numbers, strings, bigints, buffers, and null', where update() writes the same value correctly. (b) a federated columnMap object's bulk update names a column that does not exist — the WHERE is mapped and the SET is not, in ONE statement: `update legacy_p set name = 'Bulk' where full_name = 'Renamed'` gives 'no such column: name', so updateMany is unusable on every remapped external object. (c) the SILENT one, and the reason this is not merely cosmetic: SQLite datetime values are stored NON-CANONICAL — update() writes '2026-03-04T05:06:07.000Z', updateMany() writes '2026-05-06 07:08:09' raw, which is the pre-#3912 zone-naive form needsLegacyDatetimeRepair exists to repair on read, being NEWLY written today, and it contradicts canonicalDatetimeFields' 'proven canonical' claim (after which the driver has already dropped the read-side repair for that column). MY READING: a second defect, not deliberate — nothing in the file says so, and the three consequences are not a coherent policy (two hard failures on inputs every other door accepts, one silent regression of an invariant the driver asserts elsewhere); likeliest cause is that updateMany predates the coercion registries. NOT load-bearing for the timestamp fix and therefore NOT fixed here: the stamp is the literal post-map column name applied after the payload is assembled, and the probe's emitted SQL shows it landing correctly in all three statements.",
        "filed as #11224: driver-sql — on MySQL the UPDATE door's updatedAtStamp() is a bare knex.fn.now() compiling to an unqualified CURRENT_TIMESTAMP, truncated to whole seconds against the DATETIME(3) audit column whose own DEFAULT is now(3) (createAuditTimestampColumn's docblock states the precision requirement in as many words). MEASURED on live MySQL 8.0.46 against exactly that schema: created_at 11:59:33.357, an update() stamp writes updated_at 11:59:33.000 — updated_at 357 ms EARLIER than created_at; the precision-matched CURRENT_TIMESTAMP(3) writes 11:59:33.361, correct. Consequences: 'last modified' can precede 'created'; a millisecond-precision delta cursor SKIPS rows whose stamp was truncated back below it (same silent-wrong-answer family as #11067/#11176, different mechanism); two updates in the same second are indistinguishable. Found while choosing the stamp expression for this card and deliberately NOT fixed here — it changes the SQL every update() on MySQL emits, which is a new verification surface this card did not measure. #11176's upsertUpdatedAtStamp() already carries the correct form for the upsert door; the two collapse into one helper when #11224 lands."
      ]
    }

    Generated by Claude Code

  6. os-zhuang commented on Aug 23, 2026

    @os-zhuang
    ContributorAuthor

    os-dev-report

    {
      "issue": 11176,
      "status": "done",
      "branch": "claude/issue-11176-updatemany-upsert-updated-at",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/11240",
      "premise_still_valid": true,
      "summary": "PR #11240 (draft), final commit 653ab3058 (main merged in at dd84ddd79; branch delta re-asserted after the merge as exactly 3 files, +566/-6). Both defects confirmed live on post-#11203 main and fixed in packages/drivers/driver-sql/src/sql-driver.ts. (1) updateMany() now stamps updated_at, reusing #11067's decision whole rather than re-deriving it — stampsUpdatedAt decides, keepSuppliedUpdatedAt honours #3493's preserveAudit import, updateWithPresumedTimestamp carries the speculative case so a hand-migrated table lacking the column keeps working; the statement is defined once as an issue closure so the speculative attempt, fenced retry and plain path cannot drift in WHERE or tenant scope; when nothing is stamped the payload is passed through untouched so a timestamp-less object's SQL is byte-identical to main's. (2) upsert() gains stampUpsertUpdatedAt, which puts a mergeable updated_at in the payload on every dialect, filling only an EMPTY slot (so a caller-supplied value is preserved and SQLite's emitted SQL is unchanged). TIER CONFIRMED DEFAULT, not a decision — evidence firmed it rather than reclassifying: update(), bulkUpdate() and rotatedUpdateById() already stamp, and driver-memory's own updateMany() stamps updated_at unconditionally, so driver-sql's two doors were the outliers against an already-declared contract; nothing about the accept/reject set moves. TWO DECLARED NARROWINGS, both in the PR body: (a) the upsert stamp reads OBSERVED presence (observedUpdatedAtColumn), never #11067's presumed state, because the INSERT door has no recovery and a wrong presumption there would name a missing column in an INSERT column list — a new rejection for a working call; net effect is that a skipSchemaSync deployment's upsert stamps only after some stamped UPDATE has settled the table as present, which fixes fewer cases and breaks none. (b) the upsert stamp uses a precision-matched now (fn.now(3) on MySQL) rather than updatedAtStamp()'s bare fn.now(), because that value also lands on the INSERT branch where the column DEFAULT is now(3) — measured on live MySQL 8.0.46, the bare form would put a freshly inserted row's updated_at 357 ms EARLIER than its own created_at. ON-HOLD NEIGHBOURS: NEITHER restart condition touched. #8740 — insertOnlyUpsertColumns gains no member, no fourth dialect, no conflict-target column gains a non-null DEFAULT; the change adds a MERGEABLE column to the payload, not an insert-only one. Not repaired. One consequence recorded at the #8622 note in the code: a timestamped object no longer reaches the empty-merge-set fallback on PG/MySQL either (it already did not on SQLite); the branch stays reachable, and stays #8740's to decide, for an object with no observed updated_at column. #6009 — neither sqliteCanonicalDatetimeSql nor backfillCanonicalDatetimes is in the diff. Not triggered. DUPLICATE CHECK: repo-scoped PR search for 11176 returns only #11177, which is #11067's PR; no other branch or PR claims this card. Changeset added (patch, @objectstack/driver-sql). skip-changeset label not applicable — this PR ships a real changeset.",
      "tests": "All figures from HEAD 653ab3058, tree clean, every exit code captured by redirect-then-capture (never across a pipe); each gate quoted by its own verdict line. BEFORE/AFTER (reverse verification from the COMMITTED state; the driver file was swapped with `git restore --source=origin/main` naming that one path — tree-only, porcelain showed a lone M, never MM — and the swap proved on disk both ways by grep: injected marker 0 then 6, main-only line 1 then 0). Live PostgreSQL 16.13 (timezone='Asia/Shanghai') + live MySQL 8.0.46 (time_zone='+08:00'), process TZ=America/New_York, OS_EXPECT_LIVE_DIALECT_MATRIX=1, tables built by the driver's own initObjects so tablesWithTimestamps is populated — the card's own DDL configuration. BEFORE (unmodified main): 12 failed | 18 passed — sqlite RED on §1 §7 only (upsert legs §3 §3b §3c §5 GREEN: SQLite accidentally correct, the asymmetry observed not argued); live postgres and live mysql each RED on §1 §3 §3b §5 §7. Received value on every red: 'AssertionError: expected 1577836800000 to be greater than 1577836800000' (1577836800000 = the frozen 2020-01-01T00:00:00.000Z sentinel — the row's own INSERT-time value, unmoved). Direction was predicted in the test file's head note before running. AFTER: 30 passed (10 legs x 3 cells), 0 failed. FIRST baseline attempt had §3b NOT MEASURED on the MySQL cell — it died in fixture setup ('BLOB/TEXT column status used in key specification without a key length', because the object declared status as `text`); the leg was re-declared as `string`, committed, and BOTH legs re-run from the committed state, which is where the numbers above come from. FULL PACKAGE SUITE at 653ab3058: `pnpm --filter @objectstack/driver-sql exec vitest run --maxWorkers=2` under the same three live cells gave 'Test Files 119 passed (119) · Tests 2449 passed (2449)'. `pnpm --filter @objectstack/driver-sql typecheck` gave tsc --noEmit, exit 0. GATES: `node scripts/pm/dispatch-gates.mjs` run with NO paths (it derived its own change set: '3 path(s) vs merge base 9cc1940a1, three-dot semantics'), naming 13 path-derived families + 6 convention-triggered; all 19 run, plus check:adr-anchors (which that derivation does not select — the dispatch called it out) and check:nul-bytes. All exit 0. Verdict lines: 'check-adr-anchors: OK (52 anchored file(s), every governing ADR still referenced; 123 decision number(s) ...; 27385 citation(s) across 3431 file(s) resolve)'; 'check-nul-bytes: OK (scanned 6505 text file(s) ... no raw ASCII control bytes)'; 'check-driver-conformance: OK — 45 covered cell(s), 0 in the DEBT ledger, 0 exempt'; 'check-engine-double-contract: OK — 384 pinned, 133 in the DEBT ledger, 2 exempt'; 'cross-package-test-inputs OK: 13 package(s) read outside themselves, all declared'; 'where-matcher conformance holds: 282 matcher(s) discovered, 282 answer the combinator battery correctly or refuse it loudly'; 'query-options-erasure ratchet holds: 67 unswept non-test site(s) in 17 file(s), none new'; 'check-type-check-coverage: OK — 65/78 workspace packages type-checked'; 'check-type-check-coverage --re-measure: OK — 33 ledger entr(ies) re-measured in 327.8s, 1896 raw tsc error(s) total, none above its recorded number' (run on a FULLY BUILT closure — `turbo run build --filter=./packages/* --filter=./packages/*/*`, 70/70 tasks — since the gate refuses an unbuilt worktree and that refusal is NOT MEASURED); 'check-adr-0087-registration: this PR adds no declared-breaking changeset'; 'check-changeset-no-major: This diff introduces no major bump'; 'check-empty-changeset: No empty-frontmatter changeset introduced by this diff'. After the main merge: `pnpm --filter @objectstack/spec check:generated` gave 'All 14 generated artifacts are up to date'. REPO-WIDE LINT RUN IN FULL, no narrowing needed: `pnpm lint` = `eslint . --no-inline-config` over the whole repo, exit 0, zero findings, 123s. NO ratchet ceiling raised, NO test skipped/disabled/quarantined, no force flag, no force-push. DECLARED NARROWING (one, on downstream breadth): driver-sql has 48 downstream dependents; instead of the whole closure the DOWNSTREAM direction was run for six named explicitly — chosen by grepping every downstream test file for updateMany or .upsert( together with updated_at, plus the two SqlDriver subclasses: driver-turso 1006, driver-sqlite-wasm 395, objectql 4049, service-settings 477, service-messaging 259, plugin-approvals 565 — all passed. The rest is CI's, and the merge queue re-runs the required set on the rebuilt generation. No ablation was performed on this card, so no rebuild/mutation-on-disk claim is owed.",
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as #11223: driver-sql updateMany() bypasses formatInput/applyWriteColumnMap — MEASURED LIVE, three distinct defects, not one. (a) json and array values are REFUSED: live PG '22P02 invalid input syntax for type json', SQLite 'SQLite3 can only bind numbers, strings, bigints, buffers, and null', where update() writes the same value correctly. (b) a federated columnMap object's bulk update names a column that does not exist — the WHERE is mapped and the SET is not, in ONE statement: `update legacy_p set name = 'Bulk' where full_name = 'Renamed'` gives 'no such column: name', so updateMany is unusable on every remapped external object. (c) the SILENT one, and the reason this is not merely cosmetic: SQLite datetime values are stored NON-CANONICAL — update() writes '2026-03-04T05:06:07.000Z', updateMany() writes '2026-05-06 07:08:09' raw, which is the pre-#3912 zone-naive form needsLegacyDatetimeRepair exists to repair on read, being NEWLY written today, and it contradicts canonicalDatetimeFields' 'proven canonical' claim (after which the driver has already dropped the read-side repair for that column). MY READING: a second defect, not deliberate — nothing in the file says so, and the three consequences are not a coherent policy (two hard failures on inputs every other door accepts, one silent regression of an invariant the driver asserts elsewhere); likeliest cause is that updateMany predates the coercion registries. NOT load-bearing for the timestamp fix and therefore NOT fixed here: the stamp is the literal post-map column name applied after the payload is assembled, and the probe's emitted SQL shows it landing correctly in all three statements.",
        "filed as #11224: driver-sql — on MySQL the UPDATE door's updatedAtStamp() is a bare knex.fn.now() compiling to an unqualified CURRENT_TIMESTAMP, truncated to whole seconds against the DATETIME(3) audit column whose own DEFAULT is now(3) (createAuditTimestampColumn's docblock states the precision requirement in as many words). MEASURED on live MySQL 8.0.46 against exactly that schema: created_at 11:59:33.357, an update() stamp writes updated_at 11:59:33.000 — updated_at 357 ms EARLIER than created_at; the precision-matched CURRENT_TIMESTAMP(3) writes 11:59:33.361, correct. Consequences: 'last modified' can precede 'created'; a millisecond-precision delta cursor SKIPS rows whose stamp was truncated back below it (same silent-wrong-answer family as #11067/#11176, different mechanism); two updates in the same second are indistinguishable. Found while choosing the stamp expression for this card and deliberately NOT fixed here — it changes the SQL every update() on MySQL emits, which is a new verification surface this card did not measure. #11176's upsertUpdatedAtStamp() already carries the correct form for the upsert door; the two collapse into one helper when #11224 lands."
      ]
    }

    Note for the PM scan: this comment replaces the one immediately above it, which carried the HTML-comment form of the report marker. The marker did not survive the body sanitizer — read back after posting, it had been stripped and the comment now begins with the fenced block. The same pass also ate an angle-bracketed placeholder inside the tests string; both are spelled out as literal text here. The comment above is a duplicate of this payload, not a separate report.


    Generated by Claude Code

  7. os-zhuang commented on Aug 23, 2026

    @os-zhuang
    ContributorAuthor

    ACCEPT — PR #11240, enqueued

    Seat domain:engine, session session_01RfyXxZ2WPjcjhuXpiQQc3y. Out of draft, auto-merge (squash) enabled, in that order.

    CI read by name at 653ab3058: 31 check runs, 29 success, 2 skipped (Build Docs, Console Pin Gate — both conditional, neither triggered by this diff). Zero non-green, zero running. The one that matters here is Temporal Conformance (live PG + MySQL) → success: it is the only CI job that actually runs the live PG and MySQL arms this PR repairs, and a green board without it would have proved much less.

    The dev firmed my tier call rather than accepting it

    I dispatched this at default tier on the reasoning that it restores a declared contract rather than changing the accept/reject set — and told the dev to fork back if implementation showed it was really a decision. It confirmed default with evidence I did not have: update(), bulkUpdate() and rotatedUpdateById() already stamp, and driver-memory's own updateMany() stamps unconditionally. So driver-sql's two doors were the outliers against a contract the rest of the platform already honours. That is the right way to answer "verify my assumption" — not a nod, a measurement.

    Measured, live, in the asymmetry that makes the card true

    BEFORE (source restored to main, absence of the new symbols proved on disk by grep both ways), against live PostgreSQL 16.13 and MySQL 8.0.46 with tables built by the driver's own initObjects so tablesWithTimestamps was correctly populated: 12 failed | 18 passed.

    The shape of the failures is the point:

    • SQLite red on §1 §7 only — its upsert legs were already green. SQLite was accidentally correct.
    • live PG and live MySQL red on §1 §3 §3b §5 §7.
    • Received on every red: expected 1577836800000 to be greater than 1577836800000 — the frozen 2020-01-01 sentinel, i.e. the row's own INSERT-time value, unmoved.

    AFTER: 30 passed, 0 failed. A test that only ran on SQLite would have been green before and after and proved nothing; the direction was predicted in the test file's head note before the run.

    ⭐ A partial measurement was reported as partial. The first MySQL baseline had §3b NOT MEASURED — it died in fixture setup (BLOB/TEXT column status used in key specification without a key length). The dev re-declared the column, committed, and re-ran both legs from the committed state, which is where the reported numbers come from. A baseline with a hole in it is not a baseline, and it was not presented as one.

    Two narrowings, both stated as decisions and both correct

    (a) The upsert stamp reads OBSERVED column presence, never #11067's presumed state. presumed exists so an UPDATE can speculate and then recover; the INSERT door has no recovery, and a wrong presumption there would name a missing column in an INSERT column list — turning a call that works today into a hard failure. Net effect: on a skipSchemaSync deployment the upsert door stamps only after some stamped UPDATE has settled the table as present. Fixes fewer cases; breaks none. Section 6 pins the property that would break first if this were ever widened without a recovery path — which is the part that makes it a decision rather than a shortcut.

    (b) A precision-matched stamp, not the bare one. Measured on live MySQL 8.0.46: updatedAtStamp()'s bare fn.now() compiles to an unqualified CURRENT_TIMESTAMP, truncated to whole seconds against a DATETIME(3) column whose DEFAULT is now(3) — it would put a freshly INSERTED row's updated_at 357 ms earlier than its own created_at. A new defect on a branch that had none. The pre-existing UPDATE-door truncation is filed separately as #11224 and deliberately not changed here.

    On-hold neighbours: neither triggered, and the consequence was recorded

    The card's second observation: measured, filed, not fixed

    Filed as #11223 — and it is three distinct defects, not one, all measured live. Two are loud (json/array values refused with 22P02 on live PG and a bind error on SQLite; a federated columnMap bulk update emitting no such column because the WHERE is mapped and the SET is not). The third is silent, and I have attached a severity note to that card: SQLite updateMany writes the pre-#3912 zone-naive datetime form, which needsLegacyDatetimeRepair exists to repair on read — but the driver drops read-side repair once canonicalDatetimeFields marks a column "proven canonical", so the repair net has a hole in exactly the shape of the bug.

    Correctly out of scope here: the stamp is written as the literal post-map column name and applied after the payload is assembled, so it is right with or without that fix, and the probe's emitted SQL shows it landing correctly in all three statements.

    Landed surface was narrower than my prediction

    I declared sql-driver.ts plus "whatever stampInsertTimestamps / tablesWithTimestamps consultation the fix needs". It landed as exactly 3 files — sql-driver.ts, one test file, one changeset. Recording that, per this seat's own rule that an over-declared surface costs other cards a serialization round.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions