Skip to content

fix(driver-sql): advance updated_at on updateMany() and on upsert()'s merge branch - #11240

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-11176-updatemany-upsert-updated-at
Aug 23, 2026
Merged

fix(driver-sql): advance updated_at on updateMany() and on upsert()'s merge branch#11240
os-zhuang merged 2 commits into
mainfrom
claude/issue-11176-updatemany-upsert-updated-at

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes#11176

Two write doors in packages/drivers/driver-sql/src/sql-driver.ts left "last modified" reading the row's previous value, on every deployment — DDL-managed or not. That is what separates them from #11067, whose defect needed skipSchemaSync; #11067's fix (merged as #11177) is in the base here and neither introduces nor absorbs these.

The two defects

1. updateMany() stamped nothing. No stampsUpdatedAt consultation, no updated_at. update(), bulkUpdate() and rotatedUpdateById() all stamp; this door did not. A bulk edit therefore left every row it touched reading its creation time.

2. upsert()'s merge branch did not advance it on Postgres and MySQL. The merge set is derived from the KEYS of the formatted payload, so a column absent from the payload is absent from ON CONFLICT ... DO UPDATE. stampInsertTimestamps is the only thing that put updated_at there, and it returns early on any non-SQLite dialect (if (!this.isSqlite ...) return) because the column DEFAULT already stores a zone-aware instant on insert. A DEFAULT does not re-fire on the conflict path. SQLite was accidentally correct; Postgres and MySQL were not — the merge site's own comment claimed otherwise ("Everything else (incl. updated_at) merges as before"), and that comment is corrected in place.

Nothing errored either way, which is why it went unnoticed: list-view sorts, delta/incremental sync, cache invalidation and audit answers simply read a stale updated_at. A bulk status change, and a sync/import that upserts, are exactly the operations most likely to be feeding a downstream delta consumer.

Measured, live, before and after

PostgreSQL 16.13 (timezone='Asia/Shanghai') and 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 correctly populated. Baseline taken with the driver source restored to main (git restore --source=origin/main, absence of the new symbols proved on disk by grep before the run) and the new test file present:

BEFORE (unmodified main) 12 failed | 18 passed
sqlite §3 §3b §3c §5 GREEN <- upsert legs already correct here
live postgres §3 §3b §5 RED
live mysql §3 §3b §5 RED
all three §1 §7 RED <- updateMany stamped nothing
received on every red: expected 1577836800000 to be greater than 1577836800000
(1577836800000 = the frozen 2020-01-01 sentinel)
AFTER 30 passed | 0 failed

The direction was predicted before running and is recorded in the test file's head note. The asymmetry is the point: a test that only ran on SQLite would have been green before and after.

What the fix does

Two deliberate narrowings, both stated because they are decisions

a. The upsert stamp reads OBSERVED presence, never #11067's presumed state (observedUpdatedAtColumn, not stampsUpdatedAt). presumed exists so an UPDATE can speculate and then RECOVER; the upsert door has no such 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. That fixes fewer cases; it breaks none. Section 6 of the test pins the property that would break first if this were ever widened without a recovery.

b. The upsert stamp uses upsertUpdatedAtStamp(), not updatedAtStamp(), and the difference is one digit of precision on MySQL. The value lands in the INSERT payload too, and updatedAtStamp()'s bare knex.fn.now() compiles to an unqualified CURRENT_TIMESTAMP that MySQL truncates to whole seconds — against a DATETIME(3) column whose DEFAULT is now(3). Using it here would make a freshly INSERTED row's updated_at read up to 999 ms EARLIER than its created_at: a new defect on a branch that had none. Section 4 measures this. The UPDATE door's own truncation is pre-existing, is NOT changed here, and is filed as #11224.

On-hold neighbours: neither restart condition fired

The card's second observation: measured, reported, deliberately not fixed

The card asked whether updateMany passing data through without formatInput / applyWriteColumnMap is deliberate or a second defect. It is a second defect — three of them — and it is filed as #11223, not fixed here: it changes which VALUES a caller may write, not whether updated_at advances. Measured live:

  • json / array values are REFUSED on live Postgres (22P02 invalid input syntax for type json) and on SQLite ("SQLite3 can only bind numbers, strings, bigints, buffers, and null"), where update() writes them correctly.
  • 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.
  • SQLite datetime values are stored NON-CANONICAL, silentlyupdate() writes "2026-03-04T05:06:07.000Z", updateMany() writes "2026-05-06 07:08:09" raw. That is the pre-[17.0.0-rc.0] SQLite datetime window filters return empty: filter comparands coerced to epoch-ms while writes store ISO TEXT #3912 zone-naive form needsLegacyDatetimeRepair exists to repair on read, being newly written today, and it contradicts canonicalDatetimeFields' "proven canonical" claim.

It is not load-bearing for this change: the stamp is written as the literal post-map column name (the spelling created_at carries everywhere in this file) and is applied after the payload is assembled, so it is correct with or without that fix. The probe output shows it landing correctly in all three statements.

Verification

All measurements at 653ab3058 (this branch's head, main merged in at dd84ddd79; branch delta vs main re-asserted as exactly the three intended files after the merge). Every exit code captured by redirect-then-capture, never across a pipe.

pnpm --filter @objectstack/driver-sql exec vitest run --maxWorkers=2
TZ=America/New_York, live PG 16.13 + live MySQL 8.0.46, OS_EXPECT_LIVE_DIALECT_MATRIX=1
-> Test Files 119 passed (119) · Tests 2449 passed (2449)
pnpm --filter @objectstack/driver-sql typecheck -> tsc --noEmit, exit 0
pnpm lint (eslint . --no-inline-config, whole repo) -> exit 0, no findings

node scripts/pm/dispatch-gates.mjs (no paths — it derived its own 3-path change set from merge base 9cc1940a1) named 13 path-derived families and 6 convention-triggered ones; all were run, plus check:adr-anchors (which that derivation does not select) and check:nul-bytes. Each gate's own verdict line, not a bare exit code:

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-test-source-alias OK -- 72 packages with tests scanned; 61 registered ...
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 ... (170 refuse).
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 (plus the root).
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.
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.
plus: check:changeset-gate-self-tests, check:objectui-changeset, check:published-files,
check:slot-lookup, check:type-source-resolution, check-ci-filter-parity,
check-plugin-teardown-shape, docs-audit/check-affected-docs -- all exit 0
All 14 generated artifacts are up to date (spec check:generated, after the main merge)

No ratchet ceiling was raised, no test skipped, disabled or quarantined.

Declared narrowing on downstream coverage.driver-sql has 48 downstream dependents; rather than run that whole closure locally, the DOWNSTREAM direction (--filter naming each dependent explicitly) was run for the six selected 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 remaining dependents are CI's; the merge queue additionally rebuilds this PR as merged onto the current main and re-runs the required workflows on that generation.


Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/api/client-sdk.mdx(via createMany (sdk), data.createMany (sdk), data.updateMany (sdk), updateMany (sdk))
  • content/docs/api/data-api.mdx(via updateMany (symbol), createMany (sdk), updateMany (sdk), /:object/createMany (route), /:object/updateMany (route))
  • content/docs/automation/webhooks.mdx(via updateMany (symbol), updateMany (sdk))
  • content/docs/data-modeling/drivers.mdx(via SqlDriver (symbol))
  • content/docs/data-modeling/index.mdx(via SqlDriver (symbol))
  • content/docs/plugins/packages.mdx(via SqlDriver (symbol))
  • content/docs/protocol/kernel/http-protocol.mdx(via updateMany (symbol), createMany (sdk), updateMany (sdk))
  • content/docs/protocol/kernel/index.mdx(via SqlDriver (symbol))
  • content/docs/protocol/kernel/lifecycle.mdx(via SqlDriver (symbol))
  • content/docs/protocol/knowledge.mdx(via updateMany (symbol), updateMany (sdk))
  • content/docs/protocol/objectql/query-syntax.mdx(via SqlDriver (symbol))

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

  • content/docs/releases/implementation-status.mdx(via updateMany (symbol), createMany (sdk), updateMany (sdk), /:object/createMany (route), /:object/updateMany (route))
  • content/docs/releases/v16.mdx(via updateMany (symbol), createMany (sdk), updateMany (sdk))
  • content/docs/releases/v17.mdx(via SqlDriver (symbol), updateMany (symbol), updateMany (sdk))

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 45 of 221 client-bound route-ledger rows — the other 176 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run: node scripts/docs-audit/affected-docs.mjs --bridge-coverage

Coarse fallback — 9 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 38cf397ea9b1b5aea338805e7559f70576b88fb1packageMentionDocs.

Which tree this was computed on

This run read content/docs from 12af2830372c802bc8b2dd6d458b75b1e68dbf08 — the merge of head 653ab3058ba24284698da43a5086c03fefad42eb into base 38cf397ea9b1b5aea338805e7559f70576b88fb1, 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 12af2830372c802bc8b2dd6d458b75b1e68dbf08 && git checkout 12af2830372c802bc8b2dd6d458b75b1e68dbf08
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 38cf397ea9b1b5aea338805e7559f70576b88fb1 653ab3058ba24284698da43a5086c03fefad42eb && git checkout -B drift-repro 38cf397ea9b1b5aea338805e7559f70576b88fb1 && git merge --no-ff 653ab3058ba24284698da43a5086c03fefad42eb
node scripts/docs-audit/affected-docs.mjs --json 38cf397ea9b1b5aea338805e7559f70576b88fb1

⚠️ 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 38cf397ea9b1b5aea338805e7559f70576b88fb1 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 23, 2026
@os-zhuang
os-zhuang marked this pull request as ready for review August 23, 2026 05:18
@os-zhuang
os-zhuang added this pull request to the merge queueAug 23, 2026
Merged via the queue into main with commit 824a996Aug 23, 2026
32 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-11176-updatemany-upsert-updated-at branch August 23, 2026 05:33
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-zhuang@claude