@proteinjs/db-driver-spanner 1.19.0 → 1.20.1

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.
@@ -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,237 @@
1
+ import { IntegerColumn, Record, StringColumn, Table, TableChanges, withRecordColumns } from '@proteinjs/db';
2
+ import { SpannerDriver } from '@proteinjs/db-driver-spanner';
3
+ import { getDropTestTable } from './util/getDropTestTable';
4
+ import { SpannerEmulatorProvisioner } from './util/SpannerEmulatorProvisioner';
5
+ import '../generated/test/index';
6
+
7
+ /**
8
+ * The concurrent-schema-reconcile race (closed by TableManager.reconcileConcurrentSchemaChange +
9
+ * SpannerSchemaOperations.isAlreadyExistsError).
10
+ *
11
+ * On a schema-changing release the migration Job, booting pods, and multiple replicas all run
12
+ * Db.init -> loadTables at once. loadTable/loadTables is check-then-act: two actors both observe a
13
+ * column/table as absent and both issue the CREATE/ALTER; Spanner serializes the DDL so the object
14
+ * lands EXACTLY once, and the loser's operation fails with a duplicate-name / duplicate-column
15
+ * error. Before the fix the loser rethrew — a booting pod exited (CrashLoopBackOff) and the
16
+ * migration Job exited 1 (a spurious migration-gate failure).
17
+ *
18
+ * The tolerance is not a blanket swallow: it fires only for the already-exists error CLASS and only
19
+ * after RE-READING the live schema and confirming it matches the INTENDED definition.
20
+ */
21
+
22
+ const spannerConfig = {
23
+ projectId: 'proteinjs-test',
24
+ instanceName: 'proteinjs-test',
25
+ databaseName: 'test',
26
+ };
27
+
28
+ const spannerDriver = new SpannerDriver(spannerConfig);
29
+
30
+ interface ReconcileRow extends Record {
31
+ name?: string;
32
+ }
33
+
34
+ interface GrownRow extends ReconcileRow {
35
+ nickname?: string;
36
+ }
37
+
38
+ /** Base table, `name` only — the pre-change schema. */
39
+ const baseTable = (): Table<ReconcileRow> =>
40
+ new (class extends Table<ReconcileRow> {
41
+ name = 'db_test_reconcile';
42
+ columns = withRecordColumns<ReconcileRow>({
43
+ name: new StringColumn('name'),
44
+ });
45
+ })();
46
+
47
+ /** Same table redeclared with an added `nickname` column — the alter under contention. */
48
+ const grownTable = (): Table<GrownRow> =>
49
+ new (class extends Table<GrownRow> {
50
+ name = 'db_test_reconcile';
51
+ columns = withRecordColumns<GrownRow>({
52
+ name: new StringColumn('name'),
53
+ nickname: new StringColumn('nickname'),
54
+ });
55
+ })();
56
+
57
+ interface ConflictRow extends Record {
58
+ qty?: any;
59
+ }
60
+
61
+ /** `qty` declared as a STRING — the definition the "winner" lands. */
62
+ const conflictStringTable = (): Table<ConflictRow> =>
63
+ new (class extends Table<ConflictRow> {
64
+ name = 'db_test_reconcile_conflict';
65
+ columns = withRecordColumns<ConflictRow>({
66
+ qty: new StringColumn('qty'),
67
+ });
68
+ })();
69
+
70
+ /** Same table+column redeclared as INT64 — a GENUINE conflict with what landed. */
71
+ const conflictIntegerTable = (): Table<ConflictRow> =>
72
+ new (class extends Table<ConflictRow> {
73
+ name = 'db_test_reconcile_conflict';
74
+ columns = withRecordColumns<ConflictRow>({
75
+ qty: new IntegerColumn('qty'),
76
+ });
77
+ })();
78
+
79
+ interface TableManagerInternals {
80
+ getTableChanges(table: Table<any>): Promise<TableChanges>;
81
+ reconcileConcurrentSchemaChange(tables: Table<any>[], error: unknown): Promise<void>;
82
+ }
83
+
84
+ type SpannerTableManager = ReturnType<SpannerDriver['getTableManager']>;
85
+
86
+ /** House-style access to reconcile internals: a typed cast on the instance, not a public method. */
87
+ const internals = (tableManager: SpannerTableManager) => tableManager as unknown as TableManagerInternals;
88
+
89
+ /** House-style access to the driver-specific classifier. */
90
+ const classifier = (tableManager: SpannerTableManager) =>
91
+ tableManager.schemaOperations as unknown as { isAlreadyExistsError(error: unknown): boolean };
92
+
93
+ describe('Concurrent schema reconcile', () => {
94
+ const dropTable = getDropTestTable(spannerDriver);
95
+ const tableManager = spannerDriver.getTableManager();
96
+
97
+ beforeAll(async () => {
98
+ await SpannerEmulatorProvisioner.ensureProvisioned(spannerConfig);
99
+ }, 60000);
100
+
101
+ beforeEach(async () => {
102
+ await dropTable(baseTable());
103
+ await dropTable(conflictStringTable());
104
+ }, 60000);
105
+
106
+ afterAll(async () => {
107
+ await dropTable(baseTable());
108
+ await dropTable(conflictStringTable());
109
+ SpannerEmulatorProvisioner.release();
110
+ }, 60000);
111
+
112
+ afterEach(() => {
113
+ jest.restoreAllMocks();
114
+ });
115
+
116
+ test('two actors adding the SAME new column both succeed; the column lands once with the intended type', async () => {
117
+ // Winner: create the base table, then add `nickname` for real.
118
+ await tableManager.loadTable(baseTable());
119
+ await tableManager.loadTable(grownTable());
120
+
121
+ // Loser: a second reconciler whose PLANNING read is forced stale (nickname still absent), so it
122
+ // issues the real `ALTER TABLE ... ADD COLUMN nickname` that the backend rejects as a
123
+ // duplicate. mockImplementationOnce affects ONLY the planning read; the reconcile's own
124
+ // verification re-read sees the true (post-winner) schema.
125
+ const loserTm = spannerDriver.getTableManager();
126
+ const realGetColumnMetadata = loserTm.schemaMetadata.getColumnMetadata.bind(loserTm.schemaMetadata);
127
+ jest.spyOn(loserTm.schemaMetadata, 'getColumnMetadata').mockImplementationOnce(async (table) => {
128
+ const columnMetadata = await realGetColumnMetadata(table);
129
+ delete columnMetadata['nickname'];
130
+ return columnMetadata;
131
+ });
132
+
133
+ // OUTCOME: the loser's Db.init-equivalent does NOT reject — the duplicate DDL error is
134
+ // reconciled to success. (Pre-fix, this rejects with "Duplicate column name" — the red run.)
135
+ await expect(loserTm.loadTable(grownTable())).resolves.toBeUndefined();
136
+
137
+ // The column exists exactly once, with the intended type (StringColumn defaults to STRING(255)).
138
+ const columnMetadata = await tableManager.schemaMetadata.getColumnMetadata(grownTable());
139
+ expect(columnMetadata['nickname']).toBeDefined();
140
+ expect(columnMetadata['nickname'].type).toBe('STRING(255)');
141
+
142
+ // And the reconciled schema is truly up to date: a clean pass issues ZERO DDL.
143
+ const runUpdateSchemaSpy = jest.spyOn(spannerDriver, 'runUpdateSchema');
144
+ await tableManager.loadTable(grownTable());
145
+ expect(runUpdateSchemaSpy).not.toHaveBeenCalled();
146
+ }, 60000);
147
+
148
+ test('two actors creating the SAME absent table both succeed; the table lands once', async () => {
149
+ // Winner: create the table for real.
150
+ await tableManager.loadTable(baseTable());
151
+
152
+ // Loser: a second reconciler forced to see the table as ABSENT at planning, so it issues the
153
+ // real `CREATE TABLE` that the backend rejects as a duplicate name.
154
+ const loserTm = spannerDriver.getTableManager();
155
+ jest.spyOn(loserTm.schemaMetadata, 'tableExists').mockImplementationOnce(async () => false);
156
+
157
+ // OUTCOME: the loser's create does NOT reject. (Pre-fix, this rejects with "Duplicate name in
158
+ // schema" — the red run.)
159
+ await expect(loserTm.loadTable(baseTable())).resolves.toBeUndefined();
160
+
161
+ expect(await tableManager.tableExists(baseTable())).toBe(true);
162
+ }, 60000);
163
+
164
+ test('GENUINE CONFLICT: an already-exists error whose live definition differs from intent STILL throws', async () => {
165
+ // Winner landed `qty` as STRING(MAX).
166
+ await tableManager.loadTable(conflictStringTable());
167
+
168
+ // Produce a REAL duplicate-column error by adding `qty` again as INT64 (a loser intending a
169
+ // different type). It IS the already-exists class...
170
+ let duplicateError: unknown;
171
+ try {
172
+ await spannerDriver.runUpdateSchema('ALTER TABLE `db_test_reconcile_conflict` ADD COLUMN `qty` INT64');
173
+ } catch (error) {
174
+ duplicateError = error;
175
+ }
176
+ expect(duplicateError).toBeDefined();
177
+ expect(classifier(tableManager).isAlreadyExistsError(duplicateError)).toBe(true);
178
+
179
+ // ...but the live schema (qty STRING(MAX)) does NOT match the intended definition (qty INT64),
180
+ // so reconcile must RETHROW the original error rather than mask a genuine conflict.
181
+ // (Bite: turn reconcile into a blanket swallow and this rejection disappears.)
182
+ await expect(
183
+ internals(tableManager).reconcileConcurrentSchemaChange([conflictIntegerTable()], duplicateError)
184
+ ).rejects.toBe(duplicateError);
185
+ }, 60000);
186
+
187
+ test('UNRELATED ERROR CLASS: a non-already-exists DDL error propagates unchanged', async () => {
188
+ // A table that already matches the live schema — verification alone would find nothing to do.
189
+ await tableManager.loadTable(baseTable());
190
+
191
+ // An error from a different class (INVALID_ARGUMENT: index on a missing column).
192
+ let unrelatedError: unknown;
193
+ try {
194
+ await spannerDriver.runUpdateSchema('CREATE INDEX db_test_reconcile_bad ON db_test_reconcile(no_such_col)');
195
+ } catch (error) {
196
+ unrelatedError = error;
197
+ }
198
+ expect(unrelatedError).toBeDefined();
199
+ expect(classifier(tableManager).isAlreadyExistsError(unrelatedError)).toBe(false);
200
+
201
+ // reconcile must NOT touch this — it is not the already-exists class, so it propagates
202
+ // unchanged. (Bite: broaden the classifier to match every error and this rejection disappears,
203
+ // because verification finds no pending changes on the up-to-date table and would swallow it.)
204
+ await expect(internals(tableManager).reconcileConcurrentSchemaChange([baseTable()], unrelatedError)).rejects.toBe(
205
+ unrelatedError
206
+ );
207
+ }, 60000);
208
+
209
+ test('classifier matches ONLY the already-exists class — by code family AND message class', () => {
210
+ const isAlreadyExists = (code: number | undefined, message: string) =>
211
+ classifier(tableManager).isAlreadyExistsError({ code, message });
212
+
213
+ // Matched: the duplicate/already-exists phrasings under the ALREADY_EXISTS code family {6, 9}.
214
+ expect(isAlreadyExists(9, '9 FAILED_PRECONDITION: Duplicate column name db_test.qty.')).toBe(true);
215
+ expect(isAlreadyExists(9, '9 FAILED_PRECONDITION: Duplicate name in schema: db_test.')).toBe(true);
216
+ expect(isAlreadyExists(6, '6 ALREADY_EXISTS: Table db_test already exists')).toBe(true);
217
+
218
+ // NOT matched — other code-9 (FAILED_PRECONDITION) errors that must never be swallowed.
219
+ expect(
220
+ isAlreadyExists(
221
+ 9,
222
+ '9 FAILED_PRECONDITION: a concurrent schema change operation or read-write transaction is already in progress'
223
+ )
224
+ ).toBe(false);
225
+ expect(isAlreadyExists(9, '9 FAILED_PRECONDITION: Index backfill failed: uniqueness violation')).toBe(false);
226
+
227
+ // NOT matched — right message, wrong code family.
228
+ expect(isAlreadyExists(3, '3 INVALID_ARGUMENT: Duplicate column name db_test.qty.')).toBe(false);
229
+ expect(isAlreadyExists(5, '5 NOT_FOUND: Table not found: db_test')).toBe(false);
230
+
231
+ // NOT matched — reconcile-layer errors are plain Errors with no `code`.
232
+ expect(classifier(tableManager).isAlreadyExistsError(new Error('Unable to change column types in Spanner'))).toBe(
233
+ false
234
+ );
235
+ expect(classifier(tableManager).isAlreadyExistsError(undefined)).toBe(false);
236
+ });
237
+ });