@proteinjs/db-driver-spanner 1.18.0 → 1.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/dist/generated/index.js +1 -1
  3. package/dist/generated/index.js.map +1 -1
  4. package/dist/generated/test/index.js +1 -1
  5. package/dist/generated/test/index.js.map +1 -1
  6. package/dist/src/SpannerDriver.d.ts +52 -3
  7. package/dist/src/SpannerDriver.d.ts.map +1 -1
  8. package/dist/src/SpannerDriver.js +141 -33
  9. package/dist/src/SpannerDriver.js.map +1 -1
  10. package/dist/src/SpannerSchemaOperations.d.ts +9 -1
  11. package/dist/src/SpannerSchemaOperations.d.ts.map +1 -1
  12. package/dist/src/SpannerSchemaOperations.js +88 -124
  13. package/dist/src/SpannerSchemaOperations.js.map +1 -1
  14. package/dist/test/BatchedDdl.test.d.ts +2 -0
  15. package/dist/test/BatchedDdl.test.d.ts.map +1 -0
  16. package/dist/test/BatchedDdl.test.js +496 -0
  17. package/dist/test/BatchedDdl.test.js.map +1 -0
  18. package/dist/test/DmlRetrySafety.test.d.ts +2 -0
  19. package/dist/test/DmlRetrySafety.test.d.ts.map +1 -0
  20. package/dist/test/DmlRetrySafety.test.js +472 -0
  21. package/dist/test/DmlRetrySafety.test.js.map +1 -0
  22. package/dist/test/RecordIterator.test.d.ts +2 -0
  23. package/dist/test/RecordIterator.test.d.ts.map +1 -0
  24. package/dist/test/RecordIterator.test.js +14 -0
  25. package/dist/test/RecordIterator.test.js.map +1 -0
  26. package/dist/test/RunPendingMigrations.test.d.ts +2 -0
  27. package/dist/test/RunPendingMigrations.test.d.ts.map +1 -0
  28. package/dist/test/RunPendingMigrations.test.js +354 -0
  29. package/dist/test/RunPendingMigrations.test.js.map +1 -0
  30. package/dist/test/SpannerOperationDeadline.test.js +1 -1
  31. package/dist/test/SpannerOperationDeadline.test.js.map +1 -1
  32. package/generated/index.ts +1 -1
  33. package/generated/test/index.ts +1 -1
  34. package/package.json +6 -6
  35. package/src/SpannerDriver.ts +110 -18
  36. package/src/SpannerSchemaOperations.ts +69 -55
  37. package/test/BatchedDdl.test.ts +293 -0
  38. package/test/DmlRetrySafety.test.ts +305 -0
  39. package/test/RecordIterator.test.ts +16 -0
  40. package/test/RunPendingMigrations.test.ts +201 -0
  41. package/test/SpannerOperationDeadline.test.ts +1 -1
@@ -0,0 +1,293 @@
1
+ import { Database, Spanner } from '@google-cloud/spanner';
2
+ import { Logger } from '@proteinjs/logger';
3
+ import { SpannerDriver } from '@proteinjs/db-driver-spanner';
4
+ import { getTables, Record, StringColumn, Table, withRecordColumns } from '@proteinjs/db';
5
+ import { getDropTestTable } from './util/getDropTestTable';
6
+ import { SpannerEmulatorProvisioner } from './util/SpannerEmulatorProvisioner';
7
+ import '../generated/test/index';
8
+
9
+ const spannerConfig = {
10
+ projectId: 'proteinjs-test',
11
+ instanceName: 'proteinjs-test',
12
+ databaseName: 'test',
13
+ };
14
+
15
+ const spannerDriver = new SpannerDriver(spannerConfig);
16
+
17
+ interface BatchDdlParent extends Record {
18
+ name?: string;
19
+ }
20
+
21
+ interface BatchDdlChild extends Record {
22
+ label?: string;
23
+ parentId?: string;
24
+ }
25
+
26
+ /** Parent with a declared index — its create is CREATE TABLE + CREATE INDEX. */
27
+ const parentTable = (): Table<BatchDdlParent> => {
28
+ return new (class extends Table<BatchDdlParent> {
29
+ name = 'db_test_batchddl_parent';
30
+ columns = withRecordColumns<BatchDdlParent>({
31
+ name: new StringColumn('name'),
32
+ });
33
+ indexes = [{ name: 'db_test_batchddl_parent_name_index', columns: ['name'] as (keyof BatchDdlParent)[] }];
34
+ })();
35
+ };
36
+
37
+ /** Child whose FK references the parent — creation order across the two tables is load-bearing. */
38
+ const childTable = (): Table<BatchDdlChild> => {
39
+ return new (class extends Table<BatchDdlChild> {
40
+ name = 'db_test_batchddl_child';
41
+ columns = withRecordColumns<BatchDdlChild>({
42
+ label: new StringColumn('label'),
43
+ parentId: new StringColumn('parent_id', { references: { table: 'db_test_batchddl_parent' } }),
44
+ });
45
+ indexes = [{ name: 'db_test_batchddl_child_label_index', columns: ['label'] as (keyof BatchDdlChild)[] }];
46
+ })();
47
+ };
48
+
49
+ /** Parent redeclared with an extra column + extra index — the alter pass under test. */
50
+ const grownParentTable = (): Table<BatchDdlParent & { nickname?: string }> => {
51
+ return new (class extends Table<BatchDdlParent & { nickname?: string }> {
52
+ name = 'db_test_batchddl_parent';
53
+ columns = withRecordColumns<BatchDdlParent & { nickname?: string }>({
54
+ name: new StringColumn('name'),
55
+ nickname: new StringColumn('nickname'),
56
+ });
57
+ indexes = [
58
+ { name: 'db_test_batchddl_parent_name_index', columns: ['name'] as (keyof BatchDdlParent)[] },
59
+ { name: 'db_test_batchddl_parent_nickname_index', columns: ['nickname'] as any },
60
+ ];
61
+ })();
62
+ };
63
+
64
+ describe('Batched DDL', () => {
65
+ const dropTable = getDropTestTable(spannerDriver);
66
+ const tableManager = spannerDriver.getTableManager();
67
+
68
+ beforeAll(async () => {
69
+ await SpannerEmulatorProvisioner.ensureProvisioned(spannerConfig);
70
+ await dropTable(childTable());
71
+ await dropTable(parentTable());
72
+ }, 60000);
73
+
74
+ afterAll(async () => {
75
+ await dropTable(childTable());
76
+ await dropTable(parentTable());
77
+ SpannerEmulatorProvisioner.release();
78
+ }, 60000);
79
+
80
+ afterEach(() => {
81
+ jest.restoreAllMocks();
82
+ });
83
+
84
+ test('a new table lands via one runUpdateSchema call (CREATE TABLE + its indexes in one batch)', async () => {
85
+ const parent = parentTable();
86
+ const spy = jest.spyOn(spannerDriver, 'runUpdateSchema');
87
+
88
+ await tableManager.loadTable(parent);
89
+
90
+ // Call-shape at the driver seam: the table's whole schema rides ONE schema-update operation.
91
+ expect(spy).toHaveBeenCalledTimes(1);
92
+
93
+ // Outcome: the schema actually landed (INFORMATION_SCHEMA-backed metadata).
94
+ expect(await tableManager.tableExists(parent)).toBe(true);
95
+ const indexes = await tableManager.schemaMetadata.getIndexes(parent);
96
+ expect(indexes['db_test_batchddl_parent_name_index']).toEqual(['name']);
97
+
98
+ await dropTable(parent);
99
+ }, 60000);
100
+
101
+ test('an alter pass (add column + add index) lands via one runUpdateSchema call', async () => {
102
+ await tableManager.loadTable(parentTable());
103
+ const grown = grownParentTable();
104
+ const spy = jest.spyOn(spannerDriver, 'runUpdateSchema');
105
+
106
+ await tableManager.loadTable(grown);
107
+
108
+ expect(spy).toHaveBeenCalledTimes(1);
109
+
110
+ const columnMetadata = await tableManager.schemaMetadata.getColumnMetadata(grown);
111
+ expect(columnMetadata['nickname']).toBeDefined();
112
+ const indexes = await tableManager.schemaMetadata.getIndexes(grown);
113
+ expect(indexes['db_test_batchddl_parent_nickname_index']).toEqual(['nickname']);
114
+
115
+ await dropTable(grown);
116
+ }, 60000);
117
+
118
+ test('parent and FK child land in one ordered batch, and the FK is live', async () => {
119
+ const parent = parentTable();
120
+ const child = childTable();
121
+ const spy = jest.spyOn(spannerDriver, 'runUpdateSchema');
122
+
123
+ // The one-LRO claim is pinned at the CLIENT seam, not just the driver seam: a
124
+ // runUpdateSchema that quietly looped per-statement operations would still count 1 on the
125
+ // driver spy but N here.
126
+ const updateSchemaSpy = jest.spyOn(Database.prototype, 'updateSchema');
127
+
128
+ await tableManager.schemaOperations.createTables([parent, child]);
129
+
130
+ // One schema-update operation carrying the whole set: 2 CREATE TABLE + 2 CREATE INDEX,
131
+ // parent's CREATE before the child's (the child's inline FK resolves against it in-batch).
132
+ expect(spy).toHaveBeenCalledTimes(1);
133
+ expect(updateSchemaSpy).toHaveBeenCalledTimes(1);
134
+ expect(updateSchemaSpy.mock.calls[0][0]).toHaveLength(4);
135
+ const statements = spy.mock.calls[0][0] as string[];
136
+ expect(Array.isArray(statements)).toBe(true);
137
+ expect(statements).toHaveLength(4);
138
+ const parentCreateIndex = statements.findIndex((sql) => sql.includes('CREATE TABLE `db_test_batchddl_parent`'));
139
+ const childCreateIndex = statements.findIndex((sql) => sql.includes('CREATE TABLE `db_test_batchddl_child`'));
140
+ expect(parentCreateIndex).toBeGreaterThanOrEqual(0);
141
+ expect(childCreateIndex).toBeGreaterThan(parentCreateIndex);
142
+
143
+ // Outcome: both tables live, the child's foreign key and index actually exist.
144
+ expect(await tableManager.tableExists(parent)).toBe(true);
145
+ expect(await tableManager.tableExists(child)).toBe(true);
146
+ const foreignKeys = await tableManager.schemaMetadata.getForeignKeys(child);
147
+ expect(foreignKeys['parent_id']).toEqual({
148
+ referencedTableName: 'db_test_batchddl_parent',
149
+ referencedColumnName: 'id',
150
+ });
151
+ const childIndexes = await tableManager.schemaMetadata.getIndexes(child);
152
+ expect(childIndexes['db_test_batchddl_child_label_index']).toEqual(['label']);
153
+
154
+ await dropTable(child);
155
+ await dropTable(parent);
156
+ }, 60000);
157
+
158
+ test('a wrong-ordered batch (FK child before parent) is REJECTED by the backend — ordering is load-bearing', async () => {
159
+ const parent = parentTable();
160
+ const child = childTable();
161
+ // House-style access to the statement assembler: typed cast on the instance, not a public method.
162
+ const ops = tableManager.schemaOperations as unknown as { createTableStatements(table: Table<any>): string[] };
163
+ const childFirst = [...ops.createTableStatements(child), ...ops.createTableStatements(parent)];
164
+
165
+ // The child's CREATE TABLE carries an inline FK to a parent that is not yet in the
166
+ // projected schema at its position in the batch — validation rejects the batch and NOTHING
167
+ // applies. This is the failure our ordered assembly exists to prevent.
168
+ await expect(spannerDriver.runUpdateSchema(childFirst)).rejects.toThrow();
169
+ expect(await tableManager.tableExists(child)).toBe(false);
170
+ expect(await tableManager.tableExists(parent)).toBe(false);
171
+ }, 60000);
172
+
173
+ test('a schema-invalid statement mid-batch rejects the WHOLE batch upfront — nothing applied (validation phase)', async () => {
174
+ const parent = parentTable();
175
+ const ops = tableManager.schemaOperations as unknown as { createTableStatements(table: Table<any>): string[] };
176
+ const [createParentSql, createNameIndexSql] = ops.createTableStatements(parent);
177
+ // Statement 2 parses but is schema-invalid (index on a column the table does not have).
178
+ // Schema-shape errors are caught in the batch's upfront VALIDATION pass — run in order
179
+ // against the projected schema (the error names the missing column, so statement 1's table
180
+ // WAS in the validation context) — and reject the batch before anything applies. Strictly
181
+ // SAFER than the old serial path, which would have left statement 1's table behind.
182
+ const badIndexSql = 'CREATE INDEX db_test_batchddl_parent_bogus_index ON db_test_batchddl_parent(no_such_column)';
183
+
184
+ await expect(spannerDriver.runUpdateSchema([createParentSql, badIndexSql, createNameIndexSql])).rejects.toThrow(
185
+ /no_such_column/
186
+ );
187
+
188
+ expect(await tableManager.tableExists(parent)).toBe(false);
189
+ }, 60000);
190
+
191
+ test('a data-dependent failure mid-batch leaves EARLIER statements applied, LATER unapplied (apply phase)', async () => {
192
+ const parent = parentTable();
193
+ await tableManager.loadTable(parent);
194
+ // Two rows with the same `name`: schema validation cannot see this — only the APPLY phase
195
+ // (index backfill) can fail on it.
196
+ const client = new Spanner({ projectId: spannerConfig.projectId });
197
+ const database = client.instance(spannerConfig.instanceName).database(spannerConfig.databaseName);
198
+ database.on('error', () => undefined);
199
+ try {
200
+ await database.table(parent.name).insert([
201
+ { id: 'dup-1', name: 'dup', created: new Date(), updated: new Date() },
202
+ { id: 'dup-2', name: 'dup', created: new Date(), updated: new Date() },
203
+ ]);
204
+ } finally {
205
+ await database.close().catch(() => undefined);
206
+ client.close();
207
+ }
208
+
209
+ const statements = [
210
+ 'CREATE INDEX db_test_batchddl_parent_pre_index ON db_test_batchddl_parent(name, id)',
211
+ 'CREATE UNIQUE INDEX db_test_batchddl_parent_dup_unique ON db_test_batchddl_parent(name)',
212
+ 'CREATE INDEX db_test_batchddl_parent_post_index ON db_test_batchddl_parent(id, name)',
213
+ ];
214
+ const logErrorSpy = jest.spyOn(Logger.prototype, 'error');
215
+ await expect(spannerDriver.runUpdateSchema(statements)).rejects.toThrow(/uniqueness violation/);
216
+
217
+ // The failure LOG must carry the backend's reason too. Apply-phase LRO errors put it in
218
+ // `error.message` and leave `error.details` UNDEFINED — logging details alone records an
219
+ // empty reason for exactly the failure class that leaves partial schema state behind.
220
+ const failureLog = logErrorSpy.mock.calls.find(
221
+ ([entry]) => entry.message === 'Failed when executing schema update'
222
+ );
223
+ expect(failureLog).toBeDefined();
224
+ expect(String((failureLog![0].obj as { errorDetails?: unknown }).errorDetails)).toMatch(/uniqueness violation/);
225
+
226
+ // Honest partial-failure semantics of the apply phase: NOT atomic. Statement 1 stays
227
+ // applied; statement 3, ordered after the failure, is cancelled.
228
+ const indexes = await tableManager.schemaMetadata.getIndexes(parent);
229
+ expect(indexes['db_test_batchddl_parent_pre_index']).toEqual(['name', 'id']);
230
+ expect(indexes['db_test_batchddl_parent_dup_unique']).toBeUndefined();
231
+ expect(indexes['db_test_batchddl_parent_post_index']).toBeUndefined();
232
+
233
+ await dropTable(parent);
234
+ }, 60000);
235
+
236
+ test('loadTables creates the whole absent set via one runUpdateSchema call; a second pass issues none', async () => {
237
+ const registeredTables = getTables();
238
+ // This package registers the migration table (@proteinjs/db) and the service-verbs doc table.
239
+ expect(registeredTables.length).toBeGreaterThanOrEqual(2);
240
+ for (const table of [...registeredTables].reverse()) {
241
+ await dropTable(table);
242
+ }
243
+
244
+ const spy = jest.spyOn(spannerDriver, 'runUpdateSchema');
245
+ await tableManager.loadTables();
246
+ expect(spy).toHaveBeenCalledTimes(1);
247
+ for (const table of registeredTables) {
248
+ expect(await tableManager.tableExists(table)).toBe(true);
249
+ }
250
+
251
+ // Reconcile pass on an up-to-date schema issues zero DDL.
252
+ spy.mockClear();
253
+ await tableManager.loadTables();
254
+ expect(spy).not.toHaveBeenCalled();
255
+ }, 60000);
256
+
257
+ test('createDb with ddl births a queryable database; dropDb removes it', async () => {
258
+ const databaseName = 'batchddl-born';
259
+ if (await spannerDriver.dbExists(databaseName)) {
260
+ await spannerDriver.dropDb(databaseName);
261
+ }
262
+
263
+ await spannerDriver.createDb(databaseName, {
264
+ ddl: [
265
+ 'CREATE TABLE born_row (id STRING(36) NOT NULL, label STRING(MAX)) PRIMARY KEY (id)',
266
+ 'CREATE INDEX born_row_label_index ON born_row(label)',
267
+ ],
268
+ });
269
+ expect(await spannerDriver.dbExists(databaseName)).toBe(true);
270
+
271
+ // Queryability check rides a dedicated client: SpannerDriver's process-wide Database handle
272
+ // is pinned to the suite's database, and this test is about the NEW database.
273
+ const client = new Spanner({ projectId: spannerConfig.projectId });
274
+ const database = client.instance(spannerConfig.instanceName).database(databaseName);
275
+ database.on('error', () => undefined);
276
+ try {
277
+ await database.table('born_row').insert({ id: 'r1', label: 'born' });
278
+ const [rows] = await database.run({ sql: 'SELECT id, label FROM born_row', json: true });
279
+ expect(rows).toEqual([{ id: 'r1', label: 'born' }]);
280
+ const [indexRows] = await database.run({
281
+ sql: `SELECT i.INDEX_NAME FROM INFORMATION_SCHEMA.INDEXES i WHERE i.TABLE_NAME = 'born_row' AND i.INDEX_NAME = 'born_row_label_index'`,
282
+ json: true,
283
+ });
284
+ expect(indexRows).toHaveLength(1);
285
+ } finally {
286
+ await database.close().catch(() => undefined);
287
+ client.close();
288
+ }
289
+
290
+ await spannerDriver.dropDb(databaseName);
291
+ expect(await spannerDriver.dbExists(databaseName)).toBe(false);
292
+ }, 60000);
293
+ });
@@ -0,0 +1,305 @@
1
+ import { PassThrough } from 'stream';
2
+ import { Db, Record, StringColumn, Table, tableByName, withRecordColumns } from '@proteinjs/db';
3
+ import { TransactionContext } from '@proteinjs/db-transaction-context';
4
+ import { SpannerDriver } from '@proteinjs/db-driver-spanner';
5
+ import { registerTestUser, clearTestUser } from '@proteinjs/db/test';
6
+ import { SourceRepository } from '@proteinjs/reflection';
7
+ import { getDropTestTable } from './util/getDropTestTable';
8
+ import { SpannerEmulatorProvisioner } from './util/SpannerEmulatorProvisioner';
9
+ import '../generated/test/index';
10
+
11
+ /**
12
+ * DML retry safety (the 2026-08-13 splice incident class): a DML request whose response is LOST
13
+ * in transport must NEVER be transparently re-sent by the client. On the pre-fix streaming DML
14
+ * path (`runUpdate` → ExecuteStreamingSql), gax wraps every attempt in retry-request, which
15
+ * silently replays on ANY pre-response error. Seqno replay protection only covers the
16
+ * same-transaction geometry; the replay geometries it cannot cover are where the incident lived:
17
+ * an inline-begin replay BEGINS A FRESH TRANSACTION per attempt (abandoned applied-but-
18
+ * uncommitted siblings churn locks and collide with rows committed by other paths — the
19
+ * spurious `6 ALREADY_EXISTS` under pool pressure), and the stream-resumption layer re-mints a
20
+ * NEW seqno into the SAME transaction after inline-begin learned its id (unprotected even on
21
+ * real Spanner).
22
+ *
23
+ * These tests drive the retry deterministically by injecting loss at the TRANSPORT SEAM — the
24
+ * resolved gRPC stub method, which the gapic layer looks up per attempt
25
+ * (`stub[methodName].apply`), i.e. the exact function every transparent retry re-invokes.
26
+ *
27
+ * Contract under test (the fix): DML rides the unary ExecuteBatchDml RPC, and under the emulator
28
+ * the call is single-attempt (`retry: null`) — a loss surfaces as the loss itself, after at most
29
+ * ONE wire execution.
30
+ */
31
+
32
+ interface RetryAnchor extends Record {
33
+ name: string;
34
+ }
35
+
36
+ interface RetryTarget extends Record {
37
+ name: string;
38
+ }
39
+
40
+ class RetryAnchorTestTable extends Table<RetryAnchor> {
41
+ name = 'db_test_dml_retry_anchor';
42
+ columns = withRecordColumns<RetryAnchor>({
43
+ name: new StringColumn('name'),
44
+ });
45
+ }
46
+
47
+ class RetryTargetTestTable extends Table<RetryTarget> {
48
+ name = 'db_test_dml_retry_target';
49
+ columns = withRecordColumns<RetryTarget>({
50
+ name: new StringColumn('name'),
51
+ });
52
+ }
53
+
54
+ const anchorTable: Table<RetryAnchor> = new RetryAnchorTestTable();
55
+ const targetTable: Table<RetryTarget> = new RetryTargetTestTable();
56
+ // Local tables — not in any reflection source graph, so thread getTable explicitly (the
57
+ // TransactionSafety pattern); other lookups resolve normally.
58
+ const getTable = (tableName: string) => {
59
+ if (tableName === anchorTable.name) {
60
+ return anchorTable;
61
+ }
62
+ if (tableName === targetTable.name) {
63
+ return targetTable;
64
+ }
65
+ return tableByName(tableName);
66
+ };
67
+ const spannerConfig = {
68
+ projectId: 'proteinjs-test',
69
+ instanceName: 'proteinjs-test',
70
+ databaseName: 'test',
71
+ };
72
+ const spannerDriver = new SpannerDriver(spannerConfig, getTable);
73
+
74
+ const INJECTED_LOSS = 'injected response loss';
75
+ const targetInsertSqlMarker = `INSERT INTO \`db_test_dml_retry_target\``;
76
+ const isTargetInsertSql = (sql: string) => sql.includes(targetInsertSqlMarker);
77
+
78
+ type StubPatch = { attempts: () => number; restore: () => void };
79
+ /**
80
+ * 'applied-then-lost': the first attempt reaches the emulator (the statement EXECUTES
81
+ * server-side) but its response is dropped — the incident's loss shape. 'lost-in-flight': the
82
+ * first attempt never reaches the emulator — a transparent replay makes the op silently
83
+ * SUCCEED behind a "failed" attempt (the smoking gun that a replay layer exists).
84
+ */
85
+ type LossMode = 'applied-then-lost' | 'lost-in-flight';
86
+
87
+ /** The resolved gRPC stub the gapic layer re-invokes per attempt — the transport seam. */
88
+ const getSpannerStub = async (): Promise<any> => {
89
+ const spanner = (SpannerDriver as unknown as { SPANNER?: any }).SPANNER;
90
+ if (!spanner) {
91
+ throw new Error('SpannerDriver.SPANNER not initialized — run an op first');
92
+ }
93
+ const gapicClient = spanner.clients_.get('SpannerClient');
94
+ if (!gapicClient) {
95
+ throw new Error('SpannerClient gapic client not created — run a data op first');
96
+ }
97
+ return await gapicClient.spannerStub;
98
+ };
99
+
100
+ const makeLossError = (): Error => {
101
+ const error: any = new Error(INJECTED_LOSS);
102
+ error.code = 14; // UNAVAILABLE — the shape of a dropped connection / lost response
103
+ return error;
104
+ };
105
+
106
+ /** Inject loss on the FIRST matching ExecuteStreamingSql attempt; later attempts pass through. */
107
+ const patchStreamingLoss = (stub: any, isTargetSql: (sql: string) => boolean, mode: LossMode): StubPatch => {
108
+ const original = stub.executeStreamingSql;
109
+ let attempts = 0;
110
+ stub.executeStreamingSql = function (this: any, ...args: any[]) {
111
+ const request = args[0];
112
+ if (typeof request?.sql !== 'string' || !isTargetSql(request.sql)) {
113
+ return original.apply(this, args);
114
+ }
115
+ attempts += 1;
116
+ if (attempts > 1) {
117
+ return original.apply(this, args);
118
+ }
119
+ const wrapper: any = new PassThrough({ objectMode: true });
120
+ let injected = false;
121
+ const inject = () => {
122
+ if (injected) {
123
+ return;
124
+ }
125
+ injected = true;
126
+ wrapper.emit('error', makeLossError());
127
+ };
128
+ if (mode === 'lost-in-flight') {
129
+ wrapper.cancel = () => undefined;
130
+ setImmediate(inject);
131
+ return wrapper;
132
+ }
133
+ const real = original.apply(this, args);
134
+ wrapper.cancel = () => real.cancel?.();
135
+ real.on('data', () => undefined); // drain — the response is consumed and dropped
136
+ real.on('metadata', () => undefined);
137
+ real.on('error', inject);
138
+ real.on('status', inject);
139
+ real.on('end', inject);
140
+ return wrapper;
141
+ };
142
+ return { attempts: () => attempts, restore: () => (stub.executeStreamingSql = original) };
143
+ };
144
+
145
+ /** Inject loss on the FIRST matching ExecuteBatchDml attempt; later attempts pass through. */
146
+ const patchUnaryLoss = (stub: any, isTargetSql: (sql: string) => boolean, mode: LossMode): StubPatch => {
147
+ const original = stub.executeBatchDml;
148
+ let attempts = 0;
149
+ stub.executeBatchDml = function (this: any, ...args: any[]) {
150
+ const request = args[0];
151
+ const statements: any[] = request?.statements ?? [];
152
+ if (!statements.some((statement) => typeof statement?.sql === 'string' && isTargetSql(statement.sql))) {
153
+ return original.apply(this, args);
154
+ }
155
+ attempts += 1;
156
+ if (attempts > 1) {
157
+ return original.apply(this, args);
158
+ }
159
+ const callback = args[args.length - 1];
160
+ if (typeof callback !== 'function') {
161
+ throw new Error('expected trailing callback on unary stub call');
162
+ }
163
+ if (mode === 'lost-in-flight') {
164
+ setImmediate(() => callback(makeLossError()));
165
+ return { cancel: () => undefined };
166
+ }
167
+ const lossyCallback = () => callback(makeLossError());
168
+ return original.apply(this, [...args.slice(0, -1), lossyCallback]);
169
+ };
170
+ return { attempts: () => attempts, restore: () => (stub.executeBatchDml = original) };
171
+ };
172
+
173
+ /** Pass-through counter for DML on the streaming transport — must stay ZERO post-fix. */
174
+ const patchStreamingDmlCounter = (stub: any, isTargetSql: (sql: string) => boolean): StubPatch => {
175
+ const original = stub.executeStreamingSql;
176
+ let attempts = 0;
177
+ stub.executeStreamingSql = function (this: any, ...args: any[]) {
178
+ if (typeof args[0]?.sql === 'string' && isTargetSql(args[0].sql)) {
179
+ attempts += 1;
180
+ }
181
+ return original.apply(this, args);
182
+ };
183
+ return { attempts: () => attempts, restore: () => (stub.executeStreamingSql = original) };
184
+ };
185
+
186
+ describe('DML retry safety (a lost response must never re-execute a statement)', () => {
187
+ const dropTable = getDropTestTable(spannerDriver);
188
+ const db = new Db(spannerDriver, getTable, new TransactionContext());
189
+
190
+ beforeAll(async () => {
191
+ registerTestUser();
192
+ // HERMETIC two-table world (the TransactionSafety pattern): the delete path's
193
+ // reverse-cascade scan walks getTables(); scope the registry to the local tables.
194
+ (SourceRepository.get() as unknown as { objectCache: { [key: string]: unknown[] } }).objectCache[
195
+ '@proteinjs/db/Table'
196
+ ] = [anchorTable, targetTable];
197
+ await SpannerEmulatorProvisioner.ensureProvisioned(spannerConfig);
198
+ await spannerDriver.createDbIfNotExists();
199
+ await spannerDriver.getTableManager().loadTable(anchorTable);
200
+ await spannerDriver.getTableManager().loadTable(targetTable);
201
+ // Warm the data client so the gapic SpannerClient + resolved stub exist to patch.
202
+ await db.query(targetTable, { name: 'warmup' });
203
+ }, 60000);
204
+
205
+ afterAll(async () => {
206
+ await dropTable(targetTable);
207
+ await dropTable(anchorTable);
208
+ await SpannerEmulatorProvisioner.release();
209
+ delete (SourceRepository.get() as unknown as { objectCache: { [key: string]: unknown[] } }).objectCache[
210
+ '@proteinjs/db/Table'
211
+ ];
212
+ clearTestUser();
213
+ }, 60000);
214
+
215
+ afterEach(async () => {
216
+ // Tests assert their own outcomes; this just keeps rows from leaking across tests.
217
+ for (const table of [targetTable, anchorTable]) {
218
+ const leftovers = await db.query(table as Table<any>, {});
219
+ for (const row of leftovers) {
220
+ await db.delete(table as Table<any>, { id: row.id } as any);
221
+ }
222
+ }
223
+ }, 30000);
224
+
225
+ test('in-transaction DML, response lost after the emulator applied it: the loss surfaces (no self-collision), one wire execution, nothing commits', async () => {
226
+ const stub = await getSpannerStub();
227
+ const streamingPatch = patchStreamingLoss(stub, isTargetInsertSql, 'applied-then-lost');
228
+ const unaryPatch = patchUnaryLoss(stub, isTargetInsertSql, 'applied-then-lost');
229
+ let outcome: unknown;
230
+ try {
231
+ outcome = await db
232
+ .runTransaction(async () => {
233
+ // Statement 1 establishes the transaction id; statement 2 is the target DML whose
234
+ // response is lost AFTER the emulator applied it. Pre-fix, retry-request transparently
235
+ // replayed it (2 wire attempts; the op only survived because same-txn same-seqno
236
+ // replays happen to be absorbed by backend replay protection — the inline-begin
237
+ // geometry has no such shield). The contract: ONE wire execution, the LOSS surfaces.
238
+ await db.insert(anchorTable, { name: 'RetryAnchorA' });
239
+ await db.insert(targetTable, { name: 'RetryTargetA' });
240
+ })
241
+ .then(() => 'resolved' as const)
242
+ .catch((error: Error) => error);
243
+ } finally {
244
+ streamingPatch.restore();
245
+ unaryPatch.restore();
246
+ }
247
+
248
+ // The surfaced failure is the injected loss — NOT a replay self-collision (6 ALREADY_EXISTS).
249
+ expect(outcome).toBeInstanceOf(Error);
250
+ expect(String((outcome as Error).message)).toContain(INJECTED_LOSS);
251
+ expect((outcome as any).code).not.toBe(6);
252
+ // Exactly one wire execution of the target DML across both transports.
253
+ expect(streamingPatch.attempts() + unaryPatch.attempts()).toBe(1);
254
+ // The failed transaction committed nothing.
255
+ expect(await db.query(targetTable, { name: 'RetryTargetA' })).toHaveLength(0);
256
+ expect(await db.query(anchorTable, { name: 'RetryAnchorA' })).toHaveLength(0);
257
+ }, 30000);
258
+
259
+ test('standalone DML (inline begin), request lost in flight: the loss surfaces, one wire attempt, no row materializes behind the failure', async () => {
260
+ const stub = await getSpannerStub();
261
+ const streamingPatch = patchStreamingLoss(stub, isTargetInsertSql, 'lost-in-flight');
262
+ const unaryPatch = patchUnaryLoss(stub, isTargetInsertSql, 'lost-in-flight');
263
+ let outcome: unknown;
264
+ try {
265
+ outcome = await db
266
+ .insert(targetTable, { name: 'RetryTargetB' })
267
+ .then(() => 'resolved' as const)
268
+ .catch((error: Error) => error);
269
+ } finally {
270
+ streamingPatch.restore();
271
+ unaryPatch.restore();
272
+ }
273
+
274
+ // The op fails with the injected loss — it is NOT silently healed by a transparent replay
275
+ // (a replay resolves the op and materializes a row behind a "failed" attempt).
276
+ expect(outcome).toBeInstanceOf(Error);
277
+ expect(String((outcome as Error).message)).toContain(INJECTED_LOSS);
278
+ expect(streamingPatch.attempts() + unaryPatch.attempts()).toBe(1);
279
+ expect(await db.query(targetTable, { name: 'RetryTargetB' })).toHaveLength(0);
280
+ }, 30000);
281
+
282
+ test('happy path: DML rides the unary RPC (streaming carries none), commits exactly once with faithful row counts', async () => {
283
+ const stub = await getSpannerStub();
284
+ const isTargetDmlSql = (sql: string) =>
285
+ sql.includes('`db_test_dml_retry_target`') && !sql.trim().startsWith('SELECT');
286
+ const streamingCounter = patchStreamingDmlCounter(stub, isTargetDmlSql);
287
+ try {
288
+ const inserted = await db.runTransaction(async () => {
289
+ await db.insert(anchorTable, { name: 'RetryAnchorC' });
290
+ return await db.insert(targetTable, { name: 'RetryTargetC' });
291
+ });
292
+
293
+ const committed = await db.query(targetTable, { name: 'RetryTargetC' });
294
+ expect(committed).toHaveLength(1);
295
+ // Row counts flow faithfully through the unary path.
296
+ expect(await db.update(targetTable, { name: 'RetryTargetC-updated' }, { id: inserted.id })).toBe(1);
297
+ expect(await db.delete(targetTable, { id: inserted.id })).toBe(1);
298
+ // No DML (insert/update/delete) ever touched the streaming transport, whose retry
299
+ // layers transparently replay lost responses.
300
+ expect(streamingCounter.attempts()).toBe(0);
301
+ } finally {
302
+ streamingCounter.restore();
303
+ }
304
+ }, 30000);
305
+ });
@@ -0,0 +1,16 @@
1
+ import { recordIteratorTests } from '@proteinjs/db/test';
2
+ import { SpannerDriver } from '@proteinjs/db-driver-spanner';
3
+ import { getDropTestTable } from './util/getDropTestTable';
4
+ import { TransactionContext } from '@proteinjs/db-transaction-context';
5
+ import '../generated/test/index';
6
+
7
+ const spannerDriver = new SpannerDriver({
8
+ projectId: 'proteinjs-test',
9
+ instanceName: 'proteinjs-test',
10
+ databaseName: 'test',
11
+ });
12
+
13
+ describe(
14
+ 'RecordIterator cursor-window tests',
15
+ recordIteratorTests(spannerDriver, new TransactionContext(), getDropTestTable(spannerDriver))
16
+ );