|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#29520 codemod] The "CRUD — IDataDriver core" section of `SqlDriver`: the |
| 5 | + * bodies of `create`, `readBackInsertedRows`, `sequenceKeyHash` moved out of |
| 6 | + * `sql-driver.ts` whole by `scripts/codemod-split-sql-driver.mjs`. |
| 7 | + * |
| 8 | + * A MOVE, not a redesign. Each function below is the body of the `SqlDriver` |
| 9 | + * method of the same name exactly as it stood: not re-indented, not |
| 10 | + * formatted, not edited. The method stays on the class with its docblock and |
| 11 | + * signature, so the published `dist/*.d.ts`, an instance spy and a subclass |
| 12 | + * override are what they were; the docblock there is the contract. |
| 13 | + * |
| 14 | + * The bodies reach the class through {@link CrudHost}: exactly the |
| 15 | + * `SqlDriver` members they read. Each method hands its body one literal of the |
| 16 | + * members that body reads, plus a `.call`: methods go as closures that call |
| 17 | + * them on the driver at call time (the builder factory as a bound reference), |
| 18 | + * so a subclass override or an instance spy is still the one reached; fields |
| 19 | + * and accessors go as GETTERS, read when the body reads them, as they were on |
| 20 | + * the class. |
| 21 | + * |
| 22 | + * The module imports `sql-driver.ts` for types only, so the two modules form |
| 23 | + * no evaluation cycle. `index.ts` re-exports nothing from this module. |
| 24 | + */ |
| 25 | + |
| 26 | +import type { DriverOptions } from '@objectstack/spec/data'; |
| 27 | +import { nanoid } from 'nanoid'; |
| 28 | +import { createHash } from 'node:crypto'; |
| 29 | +import { |
| 30 | + AUTONUMBER_COLLISION_RETRIES, DEFAULT_ID_LENGTH, insertedRowsNotReadBackError, |
| 31 | +} from './sql-driver-top-level.js'; |
| 32 | +import type { SqlDriver } from './sql-driver.js'; |
| 33 | + |
| 34 | +/** |
| 35 | + * The `SqlDriver` members the functions below read — every one of them, and |
| 36 | + * no other. Each is typed AS the class member (an indexed access on the |
| 37 | + * class), so a change to a member's signature on the class is a change here |
| 38 | + * too. `readonly` members are the getters. |
| 39 | + */ |
| 40 | +export interface CrudHost { |
| 41 | + applyTenantScope: SqlDriver['applyTenantScope']; |
| 42 | + applyWriteColumnMap: SqlDriver['applyWriteColumnMap']; |
| 43 | + auditMissingTenant: SqlDriver['auditMissingTenant']; |
| 44 | + collidingAutoNumberReservations: SqlDriver['collidingAutoNumberReservations']; |
| 45 | + decorateHashShadowDuplicate: SqlDriver['decorateHashShadowDuplicate']; |
| 46 | + fillAutoNumberFields: SqlDriver['fillAutoNumberFields']; |
| 47 | + formatInput: SqlDriver['formatInput']; |
| 48 | + formatOutput: SqlDriver['formatOutput']; |
| 49 | + getBuilder: SqlDriver['getBuilder']; |
| 50 | + injectTenantOnInsert: SqlDriver['injectTenantOnInsert']; |
| 51 | + readonly insertReturnsStoredRows: SqlDriver['insertReturnsStoredRows']; |
| 52 | + readBackInsertedRows: SqlDriver['readBackInsertedRows']; |
| 53 | + remoteColumn: SqlDriver['remoteColumn']; |
| 54 | + resolveTenantField: SqlDriver['resolveTenantField']; |
| 55 | + resyncSequenceToDataMax: SqlDriver['resyncSequenceToDataMax']; |
| 56 | + rotationWriteTarget: SqlDriver['rotationWriteTarget']; |
| 57 | + stampInsertTimestamps: SqlDriver['stampInsertTimestamps']; |
| 58 | +} |
| 59 | + |
| 60 | +/** The body of `SqlDriver#create`; the method's docblock there is its contract. */ |
| 61 | +export async function create(this: Pick<CrudHost, 'applyWriteColumnMap' | 'auditMissingTenant' | 'collidingAutoNumberReservations' | 'decorateHashShadowDuplicate' | 'fillAutoNumberFields' | 'formatInput' | 'formatOutput' | 'getBuilder' | 'injectTenantOnInsert' | 'insertReturnsStoredRows' | 'readBackInsertedRows' | 'resyncSequenceToDataMax' | 'rotationWriteTarget' | 'stampInsertTimestamps'>, object: string, data: Record<string, any>, options?: DriverOptions): Promise<Record<string, unknown>> { |
| 62 | + const { _id, ...rest } = data; |
| 63 | + const toInsert = { ...rest }; |
| 64 | + |
| 65 | + if (_id !== undefined && toInsert.id === undefined) { |
| 66 | + toInsert.id = _id; |
| 67 | + } else if (toInsert.id === undefined) { |
| 68 | + toInsert.id = nanoid(DEFAULT_ID_LENGTH); |
| 69 | + } |
| 70 | + |
| 71 | + this.auditMissingTenant(object, 'create', options, [toInsert]); |
| 72 | + this.injectTenantOnInsert(object, toInsert, options); |
| 73 | + |
| 74 | + // #5495. The autonumber counter is bootstrapped from the data table exactly |
| 75 | + // once, so any row that lands by a path bypassing `fillAutoNumberFields` |
| 76 | + // leaves it permanently behind `MAX` — and every create then collided, |
| 77 | + // burned a number and failed the request until it had ground past the |
| 78 | + // seeded range one 409 at a time. Re-seed and retry instead, but ONLY when |
| 79 | + // the collision is provably this counter's; see |
| 80 | + // {@link collidingAutoNumberReservations} for how that is decided and why |
| 81 | + // it cannot be decided from the error text alone. |
| 82 | + // |
| 83 | + // Retrying is confined to the no-caller-transaction case on purpose. Inside |
| 84 | + // a caller's transaction there is nothing to repair here — the sequence |
| 85 | + // UPDATE shares that transaction and rolls back with the refused INSERT, so |
| 86 | + // no number is burned (measured) — and there is something to break: on |
| 87 | + // Postgres a constraint failure aborts the whole transaction, so a retry |
| 88 | + // issued on it could not succeed anyway. The caller owns that retry. |
| 89 | + const mayRetry = options?.transaction === undefined; |
| 90 | + for (let attempt = 0; ; attempt++) { |
| 91 | + const reservations = await this.fillAutoNumberFields(object, toInsert, options); |
| 92 | + |
| 93 | + // Rotation (ADR-0057 P2): the base name is a read-only view — new rows |
| 94 | + // land in the current shard. |
| 95 | + const builder = this.getBuilder(this.rotationWriteTarget(object) ?? object, options); |
| 96 | + const formatted = this.applyWriteColumnMap(object, this.formatInput(object, toInsert)); |
| 97 | + this.stampInsertTimestamps(object, formatted); |
| 98 | + |
| 99 | + try { |
| 100 | + if (this.insertReturnsStoredRows) { |
| 101 | + const result = await builder.insert(formatted).returning('*'); |
| 102 | + return this.formatOutput(object, result[0]); |
| 103 | + } |
| 104 | + await builder.insert(formatted); |
| 105 | + } catch (error) { |
| 106 | + // #11627: on a table whose UNIQUE index is carried by a hash shadow, |
| 107 | + // `ER_DUP_ENTRY` quotes a binary digest and names the shadow index, so |
| 108 | + // neither the operator nor this driver can read WHICH value conflicted |
| 109 | + // — and a genuine duplicate is indistinguishable from a (near- |
| 110 | + // impossible) digest collision. Resolve it by reading the source |
| 111 | + // columns back, on the failure path only, and re-throw the SAME error |
| 112 | + // carrying whichever answer is true. Returns the error untouched when |
| 113 | + // this is not a shadow conflict. |
| 114 | + const shadowed = await this.decorateHashShadowDuplicate( |
| 115 | + this.rotationWriteTarget(object) ?? object, |
| 116 | + formatted, |
| 117 | + error, |
| 118 | + ); |
| 119 | + if (!mayRetry || attempt >= AUTONUMBER_COLLISION_RETRIES) throw shadowed; |
| 120 | + const colliding = await this.collidingAutoNumberReservations(error, reservations, options); |
| 121 | + if (colliding.length === 0) throw shadowed; |
| 122 | + for (const reservation of colliding) { |
| 123 | + await this.resyncSequenceToDataMax(reservation); |
| 124 | + // Clear only what collided, so the next pass regenerates exactly |
| 125 | + // those fields. A reservation that did NOT collide keeps its value |
| 126 | + // rather than burning a second number for nothing. |
| 127 | + delete toInsert[reservation.field]; |
| 128 | + } |
| 129 | + continue; |
| 130 | + } |
| 131 | + |
| 132 | + // [#21227] Reached only when the INSERT landed and answered no rows. The |
| 133 | + // read sits OUTSIDE the try on purpose: a fault in it is not an insert |
| 134 | + // failure, so it must never reach the collision re-seed above, whose |
| 135 | + // retry would re-issue a write that already landed. |
| 136 | + const [stored] = await this.readBackInsertedRows( |
| 137 | + object, |
| 138 | + this.rotationWriteTarget(object) ?? object, |
| 139 | + [toInsert], |
| 140 | + options, |
| 141 | + ); |
| 142 | + return stored; |
| 143 | + } |
| 144 | + } |
| 145 | + |
| 146 | +/** The body of `SqlDriver#readBackInsertedRows`; the method's docblock there is its contract. */ |
| 147 | +export async function readBackInsertedRows(this: Pick<CrudHost, 'applyTenantScope' | 'formatOutput' | 'getBuilder' | 'remoteColumn' | 'resolveTenantField'>, object: string, writeTable: string, written: Record<string, any>[], options?: DriverOptions): Promise<Record<string, unknown>[]> { |
| 148 | + if (written.length === 0) return []; |
| 149 | + const idColumn = this.remoteColumn(object, 'id', 'id'); |
| 150 | + const tenantField = this.resolveTenantField(object); |
| 151 | + const writtenTenants = tenantField |
| 152 | + ? [ |
| 153 | + ...new Set( |
| 154 | + written |
| 155 | + .map((row) => row[tenantField]) |
| 156 | + .filter((value) => value !== undefined && value !== null && value !== '') |
| 157 | + .map(String), |
| 158 | + ), |
| 159 | + ] |
| 160 | + : []; |
| 161 | + const everyRowCarriesTenant = |
| 162 | + tenantField !== null |
| 163 | + && written.every((row) => row[tenantField] !== undefined && row[tenantField] !== null && row[tenantField] !== ''); |
| 164 | + const scopeOptions: DriverOptions = everyRowCarriesTenant |
| 165 | + ? { ...options, tenantId: writtenTenants[0], tenantIds: writtenTenants } |
| 166 | + : { ...options }; |
| 167 | + const builder = this.getBuilder(writeTable, options); |
| 168 | + this.applyTenantScope(builder, object, scopeOptions); |
| 169 | + const stored: Record<string, any>[] = await builder.whereIn( |
| 170 | + idColumn, |
| 171 | + written.map((row) => row.id), |
| 172 | + ); |
| 173 | + const byId = new Map<string, Record<string, any>>(); |
| 174 | + for (const row of stored) byId.set(String(row[idColumn]), row); |
| 175 | + const missing = written.filter((row) => !byId.has(String(row.id))).map((row) => row.id); |
| 176 | + if (missing.length > 0) throw insertedRowsNotReadBackError(object, missing, written.length); |
| 177 | + return written.map((row) => this.formatOutput(object, byId.get(String(row.id)))); |
| 178 | + } |
| 179 | + |
| 180 | +/** The body of `SqlDriver#sequenceKeyHash`; the method's docblock there is its contract. */ |
| 181 | +export function sequenceKeyHash(object: string, tenantId: string, field: string, scope: string): string { |
| 182 | + return createHash('sha256') |
| 183 | + .update(`${object}\u001f${tenantId}\u001f${field}\u001f${scope}`) |
| 184 | + .digest('hex'); |
| 185 | + } |
0 commit comments