From bf927bb4b9ce8678c65ea003dc15f7175ed0cd83 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 20:02:41 +0000 Subject: [PATCH 1/3] fix(driver-sql): create and bulkCreate answer the stored row on MySQL 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 --- packages/drivers/driver-sql/src/sql-driver.ts | 213 +++++++++++++++++- 1 file changed, 203 insertions(+), 10 deletions(-) diff --git a/packages/drivers/driver-sql/src/sql-driver.ts b/packages/drivers/driver-sql/src/sql-driver.ts index 23b5775d51d..44cf13b2100 100644 --- a/packages/drivers/driver-sql/src/sql-driver.ts +++ b/packages/drivers/driver-sql/src/sql-driver.ts @@ -1109,6 +1109,45 @@ function rawStatementFaultError(cause: unknown): Error { return err; } +/** + * [#21227] The refusal {@link SqlDriver.readBackInsertedRows} raises when an + * INSERT was accepted but a row it wrote is not there to be read back. + * + * `create` and `bulkCreate` answer the stored record (`IDataDriver.create`). + * On a dialect whose INSERT returns no rows the driver reads what it wrote + * back by the id it wrote, and only a row removed between the two statements + * (a concurrent delete, a trigger) can be missing. There is then no stored + * record to answer. Answering the caller's payload in its place would look + * like a success while answering a row that is not stored, and would hide a + * read-back keyed on the wrong column for good, so the door refuses instead. + * + * `DATABASE_ERROR` / 500, the pair this file's other terminals declare for a + * fault the request did not cause. The message is composed and names only the + * object the caller passed. The ids, which are the driver's own values (the + * caller's `id` / `_id` or a minted nanoid, never a business column), travel + * under a non-enumerable `cause` for the server log, as in + * {@link backendStatementFaultError}. + */ +function insertedRowsNotReadBackError(object: string, missingIds: unknown[], writtenCount: number): Error { + const err = new Error( + `The database accepted the insert into object '${object}', but ${missingIds.length} of its ` + + `${writtenCount} row(s) could not be read back by the id this driver wrote, so the stored ` + + 'record cannot be answered. This database returns no rows from an INSERT, so the driver ' + + 'reads each written row back by its id, and a row removed between the two statements (a ' + + 'concurrent delete or a trigger) leaves nothing to read. The write was not retried, because ' + + 're-issuing it could duplicate a row that did land.', + ) as Error & { code?: string; status?: number }; + err.code = StandardErrorCode.enum.DATABASE_ERROR; + err.status = 500; + Object.defineProperty(err, 'cause', { + value: new Error(`no row carries the written id(s) ${JSON.stringify(missingIds)} after the insert`), + enumerable: false, + writable: true, + configurable: true, + }); + return err; +} + /** * [#9354] How long a widening ALTER waits for a metadata lock, in seconds. * @@ -5830,6 +5869,38 @@ export class SqlDriver implements IDataDriver { return SqlDriver.MYSQL_EMIT_CLIENTS.has(SqlDriver.clientSpelling(this.config)); } + /** + * [#21227] Whether this dialect's `INSERT … RETURNING *` answers the rows it + * STORED. + * + * `create` and `bulkCreate` answer the inserted record (`IDataDriver.create`). + * Where this is true they take it from the statement, in one round trip. + * Where it is false the statement answers no rows, and both doors read back + * what they wrote, by the ids they wrote ({@link readBackInsertedRows}). + * + * True for the SQLite and PostgreSQL families. knex compiles `RETURNING` for + * both, and the row it answers is the stored one, server-side column defaults + * included (measured on better-sqlite3 and on live PostgreSQL 16.14). + * + * False for the MySQL family, which has no `RETURNING`. knex's MySQL compiler + * drops the clause with a `.returning() is not supported by mysql` warning and + * answers `[insertId]`: ONE element whatever the row count, and `0` for this + * driver's string primary key. Measured on live MySQL 8.0.46 before this + * change: `create` answered `0`, a three-row `bulkCreate` answered `[0]`, and + * every row was stored. The auth adapter answers what `create` answers, so + * sign-up failed on MySQL with the user row stored and no account row. + * + * False, too, for a client this driver recognises as neither family (a Client + * constructor, a wire-only spelling such as `redshift`): reading back is + * correct on every dialect, and `RETURNING` is only the shortcut a dialect + * known to answer the stored row is given. The SQLite and PostgreSQL doors + * therefore pay no extra round trip, and the MySQL doors pay one SELECT per + * statement (per `create`, and per `bulkCreate` batch). + */ + protected get insertReturnsStoredRows(): boolean { + return this.isSqlite || this.isPostgres; + } + /** * Per-granularity native SQL bucket support, computed from dialect. * @@ -7142,10 +7213,15 @@ export class SqlDriver implements IDataDriver { /** * [#15267] Declared as `IDataDriver.create()` declares it: the inserted - * record, `formatOutput(...)` over the `returning('*')` row. The annotation + * record, `formatOutput(...)` over the stored row. The annotation * used to be an explicit `Promise`, so the published `.d.ts` let a * caller read any member off the result; it is the contract's type now, * pinned by `sql-driver-doors-declared-types.test.ts`. + * + * [#21227] The stored row comes from the statement's own `returning('*')` + * where the dialect answers one, and from {@link readBackInsertedRows} + * where it does not ({@link insertReturnsStoredRows}: the MySQL family). The + * type was the contract's all along; on MySQL the VALUE was the insert id. */ async create(object: string, data: Record, options?: DriverOptions): Promise> { const { _id, ...rest } = data; @@ -7186,8 +7262,11 @@ export class SqlDriver implements IDataDriver { this.stampInsertTimestamps(object, formatted); try { - const result = await builder.insert(formatted).returning('*'); - return this.formatOutput(object, result[0]); + if (this.insertReturnsStoredRows) { + const result = await builder.insert(formatted).returning('*'); + return this.formatOutput(object, result[0]); + } + await builder.insert(formatted); } catch (error) { // #11627: on a table whose UNIQUE index is carried by a hash shadow, // `ER_DUP_ENTRY` quotes a binary digest and names the shadow index, so @@ -7212,10 +7291,108 @@ export class SqlDriver implements IDataDriver { // rather than burning a second number for nothing. delete toInsert[reservation.field]; } + continue; } + + // [#21227] Reached only when the INSERT landed and answered no rows. The + // read sits OUTSIDE the try on purpose: a fault in it is not an insert + // failure, so it must never reach the collision re-seed above, whose + // retry would re-issue a write that already landed. + const [stored] = await this.readBackInsertedRows( + object, + this.rotationWriteTarget(object) ?? object, + [toInsert], + options, + ); + return stored; } } + /** + * [#21227] Read back the rows an INSERT on this call just wrote, by the ids + * it wrote: what `create` and `bulkCreate` answer on a dialect whose INSERT + * answers no rows ({@link insertReturnsStoredRows}). + * + * One row per written row, in the written order (`IN (…)` promises no + * order), each through `formatOutput()`, the same presentation + * `returning('*')` gets on the other dialects and `update`'s own read-back + * gets on every dialect. + * + * # The read key: the written id, which this driver always holds + * + * Every row reaches the INSERT carrying the id this driver gave it: the + * caller's `id`, else its `_id`, else a nanoid minted in `create` / + * `bulkCreate` before the statement is built. The id is never asked of the + * database, so no insert id is read: the managed `id` column is a + * `varchar(255)` PRIMARY KEY with no AUTO_INCREMENT, and the insert id + * MySQL reports for it is `0`. The column is resolved through + * {@link remoteColumn}, so an external object whose `columnMap` renames + * `id` is read by its physical column, as its INSERT was written. + * + * # The table: the write target + * + * The table the statement wrote, a rotation shard included, so the read + * looks where the row landed rather than at the base name. + * + * # Tenant scope: the tenants the rows were WRITTEN under + * + * Routed through {@link applyTenantScope} like every read door in this class + * (`check:tenant-chokepoint`), scoped as `upsert`'s identity probe + * (`assertMergeLandedOnSuppliedIdentity`) scopes its own read: to the tenant + * each row was written under. On an ordinary tenanted call that is the + * caller's org, which `injectTenantOnInsert` stamped. On an admin write that + * names a tenant in the row data (a documented authority: explicit values + * are never overwritten), a read scoped to the caller's ACTIVE org would miss + * a row that really landed. A batch may name several tenants, so the scope is + * their union through `tenantIds`, which `applyTenantScope` already compiles + * as `IN (…) OR IS NULL`; the caller's own `tenantIds` membership set is + * replaced, not widened. With no tenant field, or no tenant on the rows and + * none on the call, `applyTenantScope` leaves the read unscoped by its own + * contract. The ids are this call's own and `id` is the PRIMARY KEY, so the + * read cannot answer a row this call did not write, in any organization. + * + * # A row that is not there + * + * Refused with {@link insertedRowsNotReadBackError}, never answered with the + * payload: see that function for why. + */ + private async readBackInsertedRows( + object: string, + writeTable: string, + written: Record[], + options?: DriverOptions, + ): Promise[]> { + if (written.length === 0) return []; + const idColumn = this.remoteColumn(object, 'id', 'id'); + const tenantField = this.resolveTenantField(object); + const writtenTenants = tenantField + ? [ + ...new Set( + written + .map((row) => row[tenantField]) + .filter((value) => value !== undefined && value !== null && value !== '') + .map(String), + ), + ] + : []; + const scopeOptions: DriverOptions = { + ...options, + tenantId: writtenTenants[0] ?? options?.tenantId, + tenantIds: writtenTenants.length > 0 ? writtenTenants : undefined, + }; + const builder = this.getBuilder(writeTable, options); + this.applyTenantScope(builder, object, scopeOptions); + const stored: Record[] = await builder.whereIn( + idColumn, + written.map((row) => row.id), + ); + const byId = new Map>(); + for (const row of stored) byId.set(String(row[idColumn]), row); + const missing = written.filter((row) => !byId.has(String(row.id))).map((row) => row.id); + if (missing.length > 0) throw insertedRowsNotReadBackError(object, missing, written.length); + return written.map((row) => this.formatOutput(object, byId.get(String(row.id)))); + } + /** * Ensure the sequence-counter table exists. Idempotent and cheap after * the first call (cached via `sequencesTableReady`). @@ -9185,13 +9362,16 @@ export class SqlDriver implements IDataDriver { const builder = this.getBuilder(this.rotationWriteTarget(object) ?? object, options); try { - const result = await builder.insert(formattedRows).returning('*'); - // Read-back parity with create(): JSON columns come back as their stored - // strings from `returning('*')` — decode them so batch callers see the - // same shapes single-insert callers do. - return Array.isArray(result) - ? result.map((r) => this.formatOutput(object, r)) - : result; + if (this.insertReturnsStoredRows) { + const result = await builder.insert(formattedRows).returning('*'); + // Read-back parity with create(): JSON columns come back as their stored + // strings from `returning('*')` — decode them so batch callers see the + // same shapes single-insert callers do. + return Array.isArray(result) + ? result.map((r) => this.formatOutput(object, r)) + : result; + } + await builder.insert(formattedRows); } catch (error) { if (!mayRetry || attempt >= AUTONUMBER_COLLISION_RETRIES) throw error; const colliding = await this.collidingAutoNumberReservations(error, reservationsPerRow.flat(), options); @@ -9223,7 +9403,20 @@ export class SqlDriver implements IDataDriver { if (stale.has(this.autoNumberCounterKey(reservation))) delete rows[i][reservation.field]; } } + continue; } + + // [#21227] Reached only when the INSERT landed and answered no rows (the + // MySQL family answered `[insertId]`: one element for the whole batch, + // which the engine's one-result-per-row guard then refused after every + // row had been stored). ONE read for the batch, by the ids written, in + // the written order. Outside the try for the reason `create` gives. + return this.readBackInsertedRows( + object, + this.rotationWriteTarget(object) ?? object, + rows, + options, + ); } } From f42d0354c397c3afc1d785805037fb5a6adee73d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 20:06:45 +0000 Subject: [PATCH 2/3] test(driver-sql): pin create and bulkCreate answering the stored row per dialect Claude-Session: https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp Co-authored-by: Claude --- ...er-21227-create-answers-stored-row.test.ts | 257 ++++++++++++++++++ 1 file changed, 257 insertions(+) create mode 100644 packages/drivers/driver-sql/src/sql-driver-21227-create-answers-stored-row.test.ts diff --git a/packages/drivers/driver-sql/src/sql-driver-21227-create-answers-stored-row.test.ts b/packages/drivers/driver-sql/src/sql-driver-21227-create-answers-stored-row.test.ts new file mode 100644 index 00000000000..62528ec076c --- /dev/null +++ b/packages/drivers/driver-sql/src/sql-driver-21227-create-answers-stored-row.test.ts @@ -0,0 +1,257 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#21227] `create` and `bulkCreate` answer the rows they STORED, on every + * dialect: one record per written row, in the written order. + * + * # The defect + * + * Both doors ran `builder.insert(...).returning('*')` and answered what the + * statement answered. MySQL has no `RETURNING`: knex's MySQL compiler drops the + * clause with a `.returning() is not supported by mysql` warning and answers + * `[insertId]`, which is `0` for this driver's string primary key and ONE + * element whatever the row count. Measured on live MySQL 8.0.46 at `62b90d74`, + * every row stored correctly each time: + * + * | call | answered | + * |:--|:--| + * | `create` (generated id) | `0` | + * | `create` (supplied id) | `0` | + * | `bulkCreate`, 3 rows | `[0]` (length 1) | + * | `bulkCreate`, 1 row | `[0]` | + * + * One layer up, the auth adapter answers what `create` answers, so sign-up on + * MySQL answered `400 FAILED_TO_CREATE_USER` with the user row stored and no + * account row; and the engine's one-result-per-row guard refuses a multi-row + * `bulkCreate` AFTER every row has landed. + * + * # What is asserted, per cell + * + * The answer is compared with the row read back through the driver's own + * `findOne`, and it must carry `done: false`, a value only the column's + * DEFAULT supplies (the payload never names `done`): an answer that echoed the + * payload fails here, and so does `0`. + * + * Cells: SQLite always; live PostgreSQL and MySQL where provisioned (the + * `Temporal Conformance (live PG + MySQL)` job runs this package against both) + * and declared un-run otherwise. SQLite and PostgreSQL answer from `RETURNING`, + * unchanged by the fix, and are the control: they passed before it and must + * pass after it, in ONE statement each. MySQL reads back, in two. + * + * One more cell, always run: SQLite with the read-back path forced + * (`insertReturnsStoredRows` overridden to `false`), so the read-back's own + * logic (written order, the written tenant scope, the caller's transaction, + * the refusal when a written row is gone) is measured on every CI run, not + * only on the job that has a MySQL server. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { SqlDriver, type SqlDriverConfig } from '../src/index.js'; +import { DIALECT_CELLS, declareDialectCell, type DialectCell } from './live-dialect-matrix.testkit.js'; + +const TABLE = 'os21227_stored_row'; + +const OBJECT = { + name: TABLE, + fields: { + organization_id: { type: 'string' }, + title: { type: 'string' }, + qty: { type: 'number' }, + // Filled by the column DEFAULT, never by a payload in this file: the field + // that tells a stored row from an echo of what was sent. + done: { type: 'boolean', defaultValue: false }, + meta: { type: 'json' }, + }, +} as any; + +/** SQLite, with the read-back path the MySQL family takes. */ +class ReadBackPathSqlDriver extends SqlDriver { + protected override get insertReturnsStoredRows(): boolean { + return false; + } +} + +interface Face { + label: string; + make(): SqlDriver; + /** Whether this face answers by reading back (two statements) rather than from RETURNING. */ + readsBack: boolean; + /** Whether a trigger can delete the row its own INSERT wrote (SQLite only). */ + sqliteTriggers: boolean; +} + +function suite(face: Face) { + describe(`sql-driver — create / bulkCreate answer the stored row (${face.label}) [#21227]`, () => { + let driver: SqlDriver; + const knex = () => driver.getKnex(); + + /** Statements this test issued against the fixture table, in order. */ + let statements: string[] = []; + const record = (q: { sql?: string }) => { + const sql = String(q?.sql ?? ''); + if (sql.includes(TABLE)) statements.push(sql.trim().split(/\s+/)[0].toLowerCase()); + }; + + const stored = async (id: unknown) => { + const row = await driver.findOne(TABLE, { where: { id } }); + expect(row, `row ${String(id)} was not stored`).not.toBeNull(); + return row!; + }; + + beforeEach(async () => { + driver = face.make(); + await knex().schema.dropTableIfExists(TABLE); + await driver.initObjects([OBJECT]); + statements = []; + knex().on('query', record); + }); + + afterEach(async () => { + knex().removeListener('query', record); + await knex().schema.dropTableIfExists(TABLE); + await driver.disconnect(); + }); + + it('measures the path it claims to (the cell is not vacuous)', () => { + expect((driver as any).insertReturnsStoredRows).toBe(!face.readsBack); + }); + + it('create with a generated id answers the stored row', async () => { + const answer = await driver.create(TABLE, { title: 'generated', qty: 1, meta: { k: [1, 2] } }); + + expect(typeof answer.id).toBe('string'); + expect(answer.id).not.toBe(''); + expect(answer.done).toBe(false); + expect(answer.meta).toEqual({ k: [1, 2] }); + expect(answer).toEqual(await stored(answer.id)); + }); + + it('create with a supplied id answers that row, in one statement or two by path', async () => { + const answer = await driver.create(TABLE, { id: 'given-1', title: 'supplied', qty: 2 }); + + expect(answer).toEqual(await stored('given-1')); + expect(answer.title).toBe('supplied'); + expect(answer.done).toBe(false); + // The findOne above is the test's own read; the door's statements precede it. + expect(statements.slice(0, face.readsBack ? 2 : 1)).toEqual(face.readsBack ? ['insert', 'select'] : ['insert']); + expect(statements[face.readsBack ? 2 : 1]).toBe('select'); // the test's findOne + }); + + it('bulkCreate answers one stored row per written row, in the written order', async () => { + const answer = await driver.bulkCreate(TABLE, [ + { title: 'b1' }, + { id: 'given-b2', title: 'b2', qty: 2 }, + { title: 'b3', qty: 3 }, + ]); + + expect(answer).toHaveLength(3); + expect(answer.map((r) => r.title)).toEqual(['b1', 'b2', 'b3']); + expect(answer[1].id).toBe('given-b2'); + // The batch is ONE insert, and on the read-back path ONE read. + expect(statements).toEqual(face.readsBack ? ['insert', 'select'] : ['insert']); + for (const row of answer) { + expect(row.done).toBe(false); + expect(row).toEqual(await stored(row.id)); + } + }); + + it('bulkCreate of one row answers one stored row', async () => { + const answer = await driver.bulkCreate(TABLE, [{ id: 'only', title: 'single' }]); + + expect(answer).toHaveLength(1); + expect(answer[0]).toEqual(await stored('only')); + }); + + it('a tenanted create answers the row stamped with the caller tenant', async () => { + const answer = await driver.create(TABLE, { id: 't-own', title: 'own' }, { tenantId: 'org_a' }); + + expect(answer.organization_id).toBe('org_a'); + expect(answer).toEqual(await stored('t-own')); + }); + + it('an admin write naming another tenant answers the row it stored, not a refusal', async () => { + // `injectTenantOnInsert` never overwrites an explicit tenant, so the row + // lands under org_b while the call carries org_a. A read-back scoped to + // the caller's ACTIVE org would miss it. + const answer = await driver.create( + TABLE, + { id: 't-named', title: 'named', organization_id: 'org_b' }, + { tenantId: 'org_a' }, + ); + expect(answer.organization_id).toBe('org_b'); + expect(answer).toEqual(await stored('t-named')); + + const batch = await driver.bulkCreate( + TABLE, + [ + { id: 't-b1', title: 'stamped' }, + { id: 't-b2', title: 'named', organization_id: 'org_c' }, + ], + { tenantId: 'org_a' }, + ); + expect(batch.map((r) => [r.id, r.organization_id])).toEqual([ + ['t-b1', 'org_a'], + ['t-b2', 'org_c'], + ]); + }); + + it('inside a caller transaction, create and bulkCreate answer the uncommitted rows and the rollback keeps', async () => { + const trx = await driver.beginTransaction(); + let single!: Record; + let batch!: Record[]; + try { + single = await driver.create(TABLE, { id: 'tx-1', title: 'in tx' }, { transaction: trx }); + batch = await driver.bulkCreate(TABLE, [{ id: 'tx-2', title: 'in tx' }, { id: 'tx-3', title: 'in tx' }], { + transaction: trx, + }); + } finally { + await driver.rollback(trx); + } + expect(single.id).toBe('tx-1'); + expect(single.done).toBe(false); + expect(batch.map((r) => r.id)).toEqual(['tx-2', 'tx-3']); + expect(await driver.find(TABLE, {})).toEqual([]); + }); + + if (face.readsBack && face.sqliteTriggers) { + it('a written row that is gone before the read-back is refused, once, and never re-issued', async () => { + await knex().raw( + `create trigger os21227_vanish after insert on ${TABLE} begin delete from ${TABLE} where id = new.id; end`, + ); + statements = []; + + const err = await driver.create(TABLE, { id: 'gone', title: 'vanishes' }).then( + () => null, + (e: unknown) => e as { code?: string; status?: number }, + ); + expect(err).not.toBeNull(); + expect(err!.code).toBe('DATABASE_ERROR'); + expect(err!.status).toBe(500); + // One insert, one read, no retry: re-issuing would duplicate a row that landed. + expect(statements).toEqual(['insert', 'select']); + }); + } + }); +} + +for (const cell of DIALECT_CELLS) { + declareDialectCell(cell, 'create / bulkCreate stored row (#21227)', (c: DialectCell) => + suite({ + label: c.label, + make: () => new SqlDriver(c.config()), + readsBack: c.id === 'mysql', + sqliteTriggers: c.id === 'sqlite', + }), + ); +} + +suite({ + label: 'sqlite, read-back path forced', + make: () => new ReadBackPathSqlDriver(dialectSqliteConfig()), + readsBack: true, + sqliteTriggers: true, +}); + +function dialectSqliteConfig(): SqlDriverConfig { + return DIALECT_CELLS.find((c) => c.id === 'sqlite')!.config(); +} From 453a5da0894fd4bd999f547fb4d7a867e52c4762 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 20:07:55 +0000 Subject: [PATCH 3/3] chore(changeset): driver-sql create and bulkCreate answer the stored row on MySQL Claude-Session: https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp Co-authored-by: Claude --- .changeset/21227-create-reads-back-stored-row.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .changeset/21227-create-reads-back-stored-row.md diff --git a/.changeset/21227-create-reads-back-stored-row.md b/.changeset/21227-create-reads-back-stored-row.md new file mode 100644 index 00000000000..e837d2e5dcb --- /dev/null +++ b/.changeset/21227-create-reads-back-stored-row.md @@ -0,0 +1,13 @@ +--- +'@objectstack/driver-sql': patch +--- + +On MySQL, `SqlDriver.create` and `SqlDriver.bulkCreate` now answer the rows they stored (#21227). + +Clause-②: no + +MySQL has no `INSERT … RETURNING`. knex drops the clause on the MySQL family and answers the insert id instead, so `create` answered `0` and `bulkCreate` answered a one-element array whatever the row count, although every row was stored. Callers that use the answer failed one layer up: on a MySQL datasource, sign-up answered `400 FAILED_TO_CREATE_USER` with the user stored and no account, the dev admin seed failed, and a multi-row `bulkCreate` through the engine was refused after its rows had landed. + +On the MySQL family both doors now read the rows back by the ids they wrote, under the tenant the rows were written with, inside the caller's transaction when there is one: one extra `SELECT` per `create` and per `bulkCreate` batch. SQLite and PostgreSQL still answer from `RETURNING`, with no extra statement and no change in what they answer. If a written row is gone before it can be read back (deleted in between by another statement or a trigger), the call throws `DATABASE_ERROR` (500) and does not retry the insert. + +Nothing to change in a project. Code that read the record from the result now gets it on MySQL as on the other dialects.