Repository navigation
Commit be5a83c
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](https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
1 parent ef96c9e commit be5a83c
3 files changed
Lines changed: 473 additions & 10 deletions
File tree
- .changeset
- packages/drivers/driver-sql/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
Lines changed: 257 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
| 249 | + | |
| 250 | + | |
| 251 | + | |
| 252 | + | |
| 253 | + | |
| 254 | + | |
| 255 | + | |
| 256 | + | |
| 257 | + | |
0 commit comments