@proteinjs/db-driver-spanner 1.12.3 → 1.13.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.
- package/dist/generated/index.js +1 -1
- package/dist/generated/index.js.map +1 -1
- package/dist/generated/test/index.d.ts.map +1 -1
- package/dist/generated/test/index.js +3 -1
- package/dist/generated/test/index.js.map +1 -1
- package/dist/src/SpannerDriver.d.ts +12 -0
- package/dist/src/SpannerDriver.d.ts.map +1 -1
- package/dist/src/SpannerDriver.js +62 -13
- package/dist/src/SpannerDriver.js.map +1 -1
- package/dist/src/SpannerLivenessMonitor.d.ts.map +1 -1
- package/dist/src/SpannerLivenessMonitor.js +11 -2
- package/dist/src/SpannerLivenessMonitor.js.map +1 -1
- package/dist/test/ServiceUpdateVerbs.test.d.ts +2 -0
- package/dist/test/ServiceUpdateVerbs.test.d.ts.map +1 -0
- package/dist/test/ServiceUpdateVerbs.test.js +325 -0
- package/dist/test/ServiceUpdateVerbs.test.js.map +1 -0
- package/dist/test/SessionRecordTableQuery.test.d.ts +2 -0
- package/dist/test/SessionRecordTableQuery.test.d.ts.map +1 -0
- package/dist/test/SessionRecordTableQuery.test.js +185 -0
- package/dist/test/SessionRecordTableQuery.test.js.map +1 -0
- package/dist/test/SpannerLivenessMonitor.test.js +23 -0
- package/dist/test/SpannerLivenessMonitor.test.js.map +1 -1
- package/dist/test/index.d.ts +1 -0
- package/dist/test/index.d.ts.map +1 -1
- package/dist/test/index.js +1 -0
- package/dist/test/index.js.map +1 -1
- package/dist/test/util/serviceUpdateVerbsTestTables.d.ts +45 -0
- package/dist/test/util/serviceUpdateVerbsTestTables.d.ts.map +1 -0
- package/dist/test/util/serviceUpdateVerbsTestTables.js +100 -0
- package/dist/test/util/serviceUpdateVerbsTestTables.js.map +1 -0
- package/generated/index.ts +1 -1
- package/generated/test/index.ts +3 -1
- package/package.json +5 -5
- package/src/SpannerDriver.ts +35 -6
- package/src/SpannerLivenessMonitor.ts +12 -2
- package/test/ServiceUpdateVerbs.test.ts +188 -0
- package/test/SessionRecordTableQuery.test.ts +118 -0
- package/test/SpannerLivenessMonitor.test.ts +15 -0
- package/test/index.ts +1 -0
- package/test/util/serviceUpdateVerbsTestTables.ts +55 -0
package/src/SpannerDriver.ts
CHANGED
|
@@ -219,9 +219,14 @@ export class SpannerDriver implements DbDriver {
|
|
|
219
219
|
'spanner dml transaction',
|
|
220
220
|
'(runTransactionAsync)',
|
|
221
221
|
this.getSpannerDb().runTransactionAsync(async (transaction) => {
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
222
|
+
try {
|
|
223
|
+
const rowCount = await this.executeDml(generateStatement, transaction);
|
|
224
|
+
await transaction.commit();
|
|
225
|
+
return rowCount;
|
|
226
|
+
} catch (error) {
|
|
227
|
+
await this.rollbackQuietly(transaction);
|
|
228
|
+
throw error;
|
|
229
|
+
}
|
|
225
230
|
})
|
|
226
231
|
);
|
|
227
232
|
}
|
|
@@ -277,13 +282,37 @@ export class SpannerDriver implements DbDriver {
|
|
|
277
282
|
'spanner transaction',
|
|
278
283
|
'(runTransactionAsync)',
|
|
279
284
|
this.getSpannerDb().runTransactionAsync(async (transaction) => {
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
285
|
+
try {
|
|
286
|
+
const result = await fn(transaction);
|
|
287
|
+
await transaction.commit();
|
|
288
|
+
return result;
|
|
289
|
+
} catch (error) {
|
|
290
|
+
await this.rollbackQuietly(transaction);
|
|
291
|
+
throw error;
|
|
292
|
+
}
|
|
283
293
|
})
|
|
284
294
|
);
|
|
285
295
|
}
|
|
286
296
|
|
|
297
|
+
/**
|
|
298
|
+
* Release a transaction whose work errored. The client library's runner does NOT roll back on
|
|
299
|
+
* non-retryable errors — it just rethrows — so a thrown `fn` (e.g. an application rollback, or
|
|
300
|
+
* a failed statement) left the read-write transaction OPEN until session timeout. Real Spanner
|
|
301
|
+
* tolerates that (locks expire); the emulator serializes on its single read-write transaction,
|
|
302
|
+
* so one leaked transaction blocks every subsequent DDL with FAILED_PRECONDITION ("a
|
|
303
|
+
* read-write transaction is already in progress") — poisoning whole test runs. Rollback of an
|
|
304
|
+
* already-invalid transaction (e.g. ABORTED, about to be retried by the runner with a fresh
|
|
305
|
+
* transaction) is expected to fail; that failure is logged and swallowed so the ORIGINAL error
|
|
306
|
+
* — the one that carries retry semantics — always propagates.
|
|
307
|
+
*/
|
|
308
|
+
private async rollbackQuietly(transaction: Transaction): Promise<void> {
|
|
309
|
+
try {
|
|
310
|
+
await transaction.rollback();
|
|
311
|
+
} catch (rollbackError: any) {
|
|
312
|
+
this.logger.debug({ message: `Rollback after transaction error failed`, obj: { rollbackError } });
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
287
316
|
/**
|
|
288
317
|
* Stall diagnostics (2026-07-10 flow-hang investigation): background flow tasks intermittently
|
|
289
318
|
* wedge between model calls with every model-layer guard silent — the remaining awaits on that
|
|
@@ -4,6 +4,16 @@ import { Logger } from '@proteinjs/logger';
|
|
|
4
4
|
/** grpc status codes that indicate connectivity trouble rather than an application error */
|
|
5
5
|
const CONNECTIVITY_GRPC_CODES = [4 /* DEADLINE_EXCEEDED */, 14 /* UNAVAILABLE */];
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* Restart-requested exit code — the supervision contract shared with @proteinjs/build's
|
|
9
|
+
* serve-package (ServePackageSupervisor.RESTART_REQUEST_EXIT_CODE): a supervised process
|
|
10
|
+
* exiting with this code is respawned with bounded backoff instead of being treated as a plain
|
|
11
|
+
* failure (which the supervisor mirrors, i.e. stays down). Orchestrators that restart on any
|
|
12
|
+
* nonzero exit (systemd, Kubernetes) treat it like any other failure code, so it is safe
|
|
13
|
+
* everywhere. Hard-coded by design: the contract crosses a process boundary, like a signal.
|
|
14
|
+
*/
|
|
15
|
+
const RESTART_REQUEST_EXIT_CODE = 86;
|
|
16
|
+
|
|
7
17
|
export class SpannerLivenessMonitor {
|
|
8
18
|
private static readonly PROBE_SQL = 'SELECT 1';
|
|
9
19
|
private static readonly PROBE_TIMEOUT_MS = 10_000;
|
|
@@ -56,7 +66,7 @@ export class SpannerLivenessMonitor {
|
|
|
56
66
|
}
|
|
57
67
|
}
|
|
58
68
|
this.logger.error({
|
|
59
|
-
message: `Db unreachable after sustained probing; exiting so supervision
|
|
69
|
+
message: `Db unreachable after sustained probing; exiting restart-requested (code ${RESTART_REQUEST_EXIT_CODE}) so supervision respawns into a valid state`,
|
|
60
70
|
});
|
|
61
71
|
this.exit();
|
|
62
72
|
} finally {
|
|
@@ -76,6 +86,6 @@ export class SpannerLivenessMonitor {
|
|
|
76
86
|
}
|
|
77
87
|
|
|
78
88
|
private exit(): void {
|
|
79
|
-
process.exit(
|
|
89
|
+
process.exit(RESTART_REQUEST_EXIT_CODE);
|
|
80
90
|
}
|
|
81
91
|
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { SpannerDriver } from '@proteinjs/db-driver-spanner';
|
|
2
|
+
import {
|
|
3
|
+
ArrayMembershipUpdate,
|
|
4
|
+
computeArrayMembershipOps,
|
|
5
|
+
Db,
|
|
6
|
+
isTable,
|
|
7
|
+
PreservedPath,
|
|
8
|
+
ReferenceArray,
|
|
9
|
+
Table,
|
|
10
|
+
tableByName,
|
|
11
|
+
} from '@proteinjs/db';
|
|
12
|
+
import { TransactionContext } from '@proteinjs/db-transaction-context';
|
|
13
|
+
import { getDropTestTable } from './util/getDropTestTable';
|
|
14
|
+
import { SpannerEmulatorProvisioner } from './util/SpannerEmulatorProvisioner';
|
|
15
|
+
import {
|
|
16
|
+
ServiceVerbsDoc,
|
|
17
|
+
ServiceVerbsDocTable,
|
|
18
|
+
serviceVerbsScopeContext as scope,
|
|
19
|
+
} from './util/serviceUpdateVerbsTestTables';
|
|
20
|
+
import '../generated/test/index';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The RMW update verbs (`updateArrayMembership`, `updatePreserving`) over the DbService RPC
|
|
24
|
+
* path, against real Spanner transaction semantics.
|
|
25
|
+
*
|
|
26
|
+
* The service path differs from bespoke server code in three load-bearing ways, each pinned here:
|
|
27
|
+
* - ONE long-lived `Db` instance serves every request (ServiceRouter builds its executor map
|
|
28
|
+
* once), so the verbs' self-wrapped transactions must not couple concurrent callers through
|
|
29
|
+
* instance state.
|
|
30
|
+
* - args cross a serialization boundary: `Table` as `{ tableName }` resolved via `tableByName`,
|
|
31
|
+
* the op payloads (`ArrayMembershipUpdate`, `PreservedPath`) as plain JSON.
|
|
32
|
+
* - authorization is the `TableServiceAuth.canAccess(methodName, args)` gate plus scoped/column
|
|
33
|
+
* query injection inside the verb's read-modify-write — an out-of-scope row must behave as
|
|
34
|
+
* nonexistent.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
const spannerDriver = new SpannerDriver({
|
|
38
|
+
projectId: 'proteinjs-test',
|
|
39
|
+
instanceName: 'proteinjs-test',
|
|
40
|
+
databaseName: 'test',
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe('DbService RMW update verbs (updateArrayMembership / updatePreserving)', () => {
|
|
44
|
+
const docTable = new ServiceVerbsDocTable() as Table<ServiceVerbsDoc>;
|
|
45
|
+
const dropTable = getDropTestTable(spannerDriver);
|
|
46
|
+
// Service-singleton shaped: one shared instance, default table resolution (tableByName).
|
|
47
|
+
const db = new Db<ServiceVerbsDoc>(spannerDriver, undefined, new TransactionContext());
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The wire forms the Serializer produces for DbService args: `Table` crosses as its name and is
|
|
51
|
+
* resolved via `tableByName` server-side (TableSerializer); the op payloads are plain JSON.
|
|
52
|
+
*/
|
|
53
|
+
const overWire = (args: any[]) =>
|
|
54
|
+
args.map((arg) => (isTable(arg) ? tableByName((arg as Table<any>).name) : JSON.parse(JSON.stringify(arg))));
|
|
55
|
+
|
|
56
|
+
/** Invoke like ServiceExecutor does: deserialize wire args, run the canAccess gate, call the method. */
|
|
57
|
+
const rpc = async (methodName: string, args: any[]) => {
|
|
58
|
+
const wireArgs = overWire(args);
|
|
59
|
+
if (!db.serviceMetadata!.auth!.canAccess!(methodName, wireArgs)) {
|
|
60
|
+
throw new Error(`User not authorized to run service: DbService.${methodName}`);
|
|
61
|
+
}
|
|
62
|
+
return await (db as any)[methodName](...wireArgs);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const memberIds = async (id: string) => {
|
|
66
|
+
const row = await db.get(docTable, { id });
|
|
67
|
+
return row?.members?._ids;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
beforeAll(async () => {
|
|
71
|
+
await SpannerEmulatorProvisioner.ensureProvisioned({
|
|
72
|
+
projectId: 'proteinjs-test',
|
|
73
|
+
instanceName: 'proteinjs-test',
|
|
74
|
+
databaseName: 'test',
|
|
75
|
+
});
|
|
76
|
+
await dropTable(docTable);
|
|
77
|
+
await spannerDriver.getTableManager().loadTable(docTable);
|
|
78
|
+
}, 60000);
|
|
79
|
+
|
|
80
|
+
afterAll(async () => {
|
|
81
|
+
await dropTable(docTable);
|
|
82
|
+
await SpannerEmulatorProvisioner.release();
|
|
83
|
+
}, 30000);
|
|
84
|
+
|
|
85
|
+
beforeEach(() => {
|
|
86
|
+
scope.current = 'scope-a';
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('membership ops apply against committed truth, not the caller list snapshot', async () => {
|
|
90
|
+
const doc = await db.insert(docTable, {
|
|
91
|
+
title: 'd1',
|
|
92
|
+
members: new ReferenceArray(docTable.name, ['a', 'b', 'c']),
|
|
93
|
+
});
|
|
94
|
+
// Another writer's committed wholesale update adds `d` — the caller's snapshot is now stale.
|
|
95
|
+
await db.update(docTable, { id: doc.id, members: new ReferenceArray(docTable.name, ['a', 'b', 'c', 'd']) });
|
|
96
|
+
|
|
97
|
+
// The caller computed remove(b) against the stale base [a, b, c].
|
|
98
|
+
const update: ArrayMembershipUpdate = {
|
|
99
|
+
recordId: doc.id,
|
|
100
|
+
columnPropertyName: 'members',
|
|
101
|
+
ops: computeArrayMembershipOps(['a', 'b', 'c'], ['a', 'c']),
|
|
102
|
+
};
|
|
103
|
+
expect(await rpc('updateArrayMembership', [docTable, update])).toBe(1);
|
|
104
|
+
|
|
105
|
+
// Both writers' effects survive; a wholesale write of the stale list would have erased `d`.
|
|
106
|
+
expect(await memberIds(doc.id)).toEqual(['a', 'c', 'd']);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('a scoped caller cannot touch another scope’s row via updateArrayMembership', async () => {
|
|
110
|
+
const doc = await db.insert(docTable, { title: 'd2', members: new ReferenceArray(docTable.name, ['a', 'b']) });
|
|
111
|
+
|
|
112
|
+
scope.current = 'scope-b';
|
|
113
|
+
const removeA: ArrayMembershipUpdate = {
|
|
114
|
+
recordId: doc.id,
|
|
115
|
+
columnPropertyName: 'members',
|
|
116
|
+
ops: [{ op: 'remove', id: 'a' }],
|
|
117
|
+
};
|
|
118
|
+
expect(await rpc('updateArrayMembership', [docTable, removeA])).toBe(0);
|
|
119
|
+
|
|
120
|
+
scope.current = 'scope-a';
|
|
121
|
+
expect(await memberIds(doc.id)).toEqual(['a', 'b']);
|
|
122
|
+
|
|
123
|
+
// The owning scope can perform the identical op.
|
|
124
|
+
expect(await rpc('updateArrayMembership', [docTable, removeA])).toBe(1);
|
|
125
|
+
expect(await memberIds(doc.id)).toEqual(['b']);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('a scoped caller cannot touch another scope’s row via updatePreserving', async () => {
|
|
129
|
+
const doc = await db.insert(docTable, { title: 'd3', body: { content: 'v1', style: { color: 'blue' } } });
|
|
130
|
+
|
|
131
|
+
scope.current = 'scope-b';
|
|
132
|
+
const preserve: PreservedPath[] = [{ columnPropertyName: 'body', paths: ['content'], whenType: 'string' }];
|
|
133
|
+
const foreignWrite = { id: doc.id, body: { content: 'hijack', style: { color: 'red' } } };
|
|
134
|
+
expect(await rpc('updatePreserving', [docTable, foreignWrite, preserve])).toBe(0);
|
|
135
|
+
|
|
136
|
+
scope.current = 'scope-a';
|
|
137
|
+
const row = await db.get(docTable, { id: doc.id });
|
|
138
|
+
expect(row.body).toEqual({ content: 'v1', style: { color: 'blue' } });
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('updatePreserving over the service preserves the committed sub-path while writing owned paths', async () => {
|
|
142
|
+
const doc = await db.insert(docTable, { title: 'd4', body: { content: 'v1', style: { color: 'blue' } } });
|
|
143
|
+
// The owning writer's committed text save; the structural writer's payload still carries v1.
|
|
144
|
+
await db.update(docTable, { id: doc.id, body: { content: 'v2', style: { color: 'blue' } } });
|
|
145
|
+
|
|
146
|
+
const preserve: PreservedPath[] = [{ columnPropertyName: 'body', paths: ['content'], whenType: 'string' }];
|
|
147
|
+
const stalePayload = { id: doc.id, body: { content: 'v1', style: { color: 'red' } } };
|
|
148
|
+
expect(await rpc('updatePreserving', [docTable, stalePayload, preserve])).toBe(1);
|
|
149
|
+
|
|
150
|
+
const row = await db.get(docTable, { id: doc.id });
|
|
151
|
+
expect(row.body).toEqual({ content: 'v2', style: { color: 'red' } });
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('concurrent RPCs on the shared service instance get isolated transactions', async () => {
|
|
155
|
+
const doc = await db.insert(docTable, { title: 'd5', members: new ReferenceArray(docTable.name, ['seed']) });
|
|
156
|
+
|
|
157
|
+
// The service singleton serves concurrent requests; each self-wrapped RMW transaction must be
|
|
158
|
+
// isolated (no cross-request transaction bleed through shared instance state), and every
|
|
159
|
+
// membership add must land. Issuance is STAGGERED so later requests arrive while an earlier
|
|
160
|
+
// request's transaction is open — the shape that makes shared instance state bleed: a
|
|
161
|
+
// simultaneous burst would let every call check the (still unset) transaction state before
|
|
162
|
+
// any transaction opens, masking the coupling.
|
|
163
|
+
const addedIds = ['m1', 'm2', 'm3', 'm4', 'm5', 'm6'];
|
|
164
|
+
const inFlight: Promise<unknown>[] = [];
|
|
165
|
+
for (const id of addedIds) {
|
|
166
|
+
inFlight.push(
|
|
167
|
+
rpc('updateArrayMembership', [
|
|
168
|
+
docTable,
|
|
169
|
+
{ recordId: doc.id, columnPropertyName: 'members', ops: [{ op: 'add', id, afterId: 'seed' }] },
|
|
170
|
+
])
|
|
171
|
+
);
|
|
172
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
173
|
+
}
|
|
174
|
+
await Promise.all(inFlight);
|
|
175
|
+
|
|
176
|
+
expect([...((await memberIds(doc.id)) ?? [])].sort()).toEqual([...addedIds, 'seed'].sort());
|
|
177
|
+
}, 60000);
|
|
178
|
+
|
|
179
|
+
test('updateArrayMembership rejects a non-ReferenceArrayColumn target', async () => {
|
|
180
|
+
const doc = await db.insert(docTable, { title: 'd6' });
|
|
181
|
+
await expect(
|
|
182
|
+
rpc('updateArrayMembership', [
|
|
183
|
+
docTable,
|
|
184
|
+
{ recordId: doc.id, columnPropertyName: 'title', ops: [{ op: 'add', id: 'x', afterId: null }] },
|
|
185
|
+
])
|
|
186
|
+
).rejects.toThrow('requires a ReferenceArrayColumn');
|
|
187
|
+
});
|
|
188
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import moment from 'moment';
|
|
2
|
+
import { SpannerDriver } from '@proteinjs/db-driver-spanner';
|
|
3
|
+
import { Db, DateColumn, QueryBuilderFactory, Record, StringColumn, Table, withRecordColumns } from '@proteinjs/db';
|
|
4
|
+
import { TransactionContext } from '@proteinjs/db-transaction-context';
|
|
5
|
+
import { getDropTestTable } from './util/getDropTestTable';
|
|
6
|
+
import { SpannerEmulatorProvisioner } from './util/SpannerEmulatorProvisioner';
|
|
7
|
+
import '../generated/test/index';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Proves the generic record-table query path over the session table shape.
|
|
11
|
+
*
|
|
12
|
+
* The admin Sessions record table (settings menu → Sessions) runs EXACTLY this query through
|
|
13
|
+
* DbService: rows written by DbSessionStore (system db, Table layer), read back with the
|
|
14
|
+
* QueryTableLoader query — sort `updated desc`, paginate. When it showed zero rows on a server
|
|
15
|
+
* with live sessions (2026-08), the suspects were the query shape itself: a physical table the
|
|
16
|
+
* Table def can't read, scope filtering dropping every row, or a default sort on an unpopulated
|
|
17
|
+
* column. This test pins the query shape against real Spanner semantics: store-shaped rows come
|
|
18
|
+
* back, all of them, newest-first — so an empty result at that surface means the query never ran
|
|
19
|
+
* (it was denied; the UI rendered the failure as "no rows"), not that the data is unreadable.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
interface SessionShape extends Record {
|
|
23
|
+
sessionId: string;
|
|
24
|
+
session: string;
|
|
25
|
+
expires: Date;
|
|
26
|
+
userEmail: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Mirrors @proteinjs/user SessionTable column-for-column (namespaced test table name). */
|
|
30
|
+
class SessionShapeTable extends Table<SessionShape> {
|
|
31
|
+
name = 'db_test_session_record_table_query';
|
|
32
|
+
columns = withRecordColumns<SessionShape>({
|
|
33
|
+
sessionId: new StringColumn('session_id'),
|
|
34
|
+
session: new StringColumn('serialized_session', {}, 4000),
|
|
35
|
+
expires: new DateColumn('expires'),
|
|
36
|
+
userEmail: new StringColumn('user_email'),
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const table = new SessionShapeTable();
|
|
41
|
+
const getTable = (tableName: string) => {
|
|
42
|
+
if (tableName === table.name) {
|
|
43
|
+
return table;
|
|
44
|
+
}
|
|
45
|
+
throw new Error(`Unexpected table lookup in test: ${tableName}`);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const spannerDriver = new SpannerDriver(
|
|
49
|
+
{
|
|
50
|
+
projectId: 'proteinjs-test',
|
|
51
|
+
instanceName: 'proteinjs-test',
|
|
52
|
+
databaseName: 'test',
|
|
53
|
+
},
|
|
54
|
+
getTable
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* What DbSessionStore serializes into a row (shape from a real dev session). `updated` is
|
|
59
|
+
* stamped explicitly for deterministic newest-first assertions: the insert-time default takes
|
|
60
|
+
* moment() per row, and rows landing in the same millisecond would make the order ambiguous.
|
|
61
|
+
*/
|
|
62
|
+
const storeShapedRow = (n: number, updatedMs: number) => ({
|
|
63
|
+
sessionId: `test-session-${n}`,
|
|
64
|
+
session: JSON.stringify({
|
|
65
|
+
cookie: { originalMaxAge: 5184000000, expires: '2026-10-01T00:00:00.000Z', httpOnly: true, path: '/' },
|
|
66
|
+
passport: { user: `user-${n}@test.local` },
|
|
67
|
+
}),
|
|
68
|
+
expires: new Date(Date.now() + 5184000000),
|
|
69
|
+
userEmail: `user-${n}@test.local`,
|
|
70
|
+
updated: moment(updatedMs),
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('Session-shaped record table query (the admin Sessions table path)', () => {
|
|
74
|
+
const dropTable = getDropTestTable(spannerDriver);
|
|
75
|
+
// Writes as the session store writes (system db), reads as the record table reads.
|
|
76
|
+
const systemDb = new Db(spannerDriver, getTable, new TransactionContext(), true);
|
|
77
|
+
const db = new Db(spannerDriver, getTable, new TransactionContext());
|
|
78
|
+
|
|
79
|
+
beforeAll(async () => {
|
|
80
|
+
await SpannerEmulatorProvisioner.ensureProvisioned({
|
|
81
|
+
projectId: 'proteinjs-test',
|
|
82
|
+
instanceName: 'proteinjs-test',
|
|
83
|
+
databaseName: 'test',
|
|
84
|
+
});
|
|
85
|
+
await dropTable(table);
|
|
86
|
+
await spannerDriver.getTableManager().loadTable(table);
|
|
87
|
+
}, 60000);
|
|
88
|
+
|
|
89
|
+
afterAll(async () => {
|
|
90
|
+
await dropTable(table);
|
|
91
|
+
await SpannerEmulatorProvisioner.release();
|
|
92
|
+
}, 30000);
|
|
93
|
+
|
|
94
|
+
test('store-written session rows all come back through the record-table query, newest-first', async () => {
|
|
95
|
+
// Insert like DbSessionStore.insertOrUpdate: system db, store-shaped fields, staggered updates.
|
|
96
|
+
const base = Date.now() - 60_000;
|
|
97
|
+
for (let n = 1; n <= 3; n++) {
|
|
98
|
+
await systemDb.insert(table, storeShapedRow(n, base + n * 1000) as any);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// EXACTLY QueryTableLoader.load's query: sort updated desc, paginate the first window.
|
|
102
|
+
const qb = new QueryBuilderFactory()
|
|
103
|
+
.createQueryBuilder<SessionShape>(table)
|
|
104
|
+
.sort([{ field: 'updated', desc: true }])
|
|
105
|
+
.paginate({ start: 0, end: 10 });
|
|
106
|
+
const rows = await db.query(table, qb);
|
|
107
|
+
|
|
108
|
+
expect(rows.map((row) => row.sessionId)).toEqual(['test-session-3', 'test-session-2', 'test-session-1']);
|
|
109
|
+
// Round-trip fidelity of the store-shaped fields the table displays.
|
|
110
|
+
expect(rows[0].userEmail).toBe('user-3@test.local');
|
|
111
|
+
expect(JSON.parse(rows[0].session).passport.user).toBe('user-3@test.local');
|
|
112
|
+
expect(rows[0].expires).toBeTruthy();
|
|
113
|
+
|
|
114
|
+
// The pagination variant of the loader also asks for the row count.
|
|
115
|
+
const countQb = new QueryBuilderFactory().createQueryBuilder<SessionShape>(table);
|
|
116
|
+
expect(await db.getRowCount(table, countQb)).toBe(3);
|
|
117
|
+
}, 60000);
|
|
118
|
+
});
|
|
@@ -80,6 +80,21 @@ describe('SpannerLivenessMonitor', () => {
|
|
|
80
80
|
);
|
|
81
81
|
});
|
|
82
82
|
|
|
83
|
+
test('the sustained-failure exit is RESTART-REQUESTED (code 86) so supervision respawns instead of staying down', async () => {
|
|
84
|
+
// The serve-package contract (ServePackageSupervisor.RESTART_REQUEST_EXIT_CODE): 86 asks
|
|
85
|
+
// the supervisor for a respawn with backoff; a plain exit(1) is mirrored and stays down —
|
|
86
|
+
// observed as a dev server dead all night after a transient network outage.
|
|
87
|
+
exitSpy.mockRestore();
|
|
88
|
+
const processExitSpy = jest.spyOn(process, 'exit').mockImplementation((() => undefined) as never);
|
|
89
|
+
probeSpy.mockRejectedValue(new Error('14 UNAVAILABLE: fake'));
|
|
90
|
+
|
|
91
|
+
const check = internals.verifyLiveness();
|
|
92
|
+
await jest.advanceTimersByTimeAsync(ALL_PROBE_DELAYS_MS);
|
|
93
|
+
await check;
|
|
94
|
+
|
|
95
|
+
expect(processExitSpy).toHaveBeenCalledWith(86);
|
|
96
|
+
});
|
|
97
|
+
|
|
83
98
|
test('burst coalescing: reportError while a check is in flight triggers one probe cycle', async () => {
|
|
84
99
|
probeSpy.mockResolvedValue(undefined);
|
|
85
100
|
|
package/test/index.ts
CHANGED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ObjectColumn,
|
|
3
|
+
Record,
|
|
4
|
+
ReferenceArray,
|
|
5
|
+
ReferenceArrayColumn,
|
|
6
|
+
StringColumn,
|
|
7
|
+
Table,
|
|
8
|
+
withRecordColumns,
|
|
9
|
+
} from '@proteinjs/db';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Tables for `ServiceUpdateVerbs.test.ts` (the service-path RMW update verbs:
|
|
13
|
+
* `updateArrayMembership` / `updatePreserving`).
|
|
14
|
+
*
|
|
15
|
+
* Defined here rather than in the test file so the reflection build registers them as `Table`
|
|
16
|
+
* loadables — `tableByName` (which the db-service singleton and the `Table` wire serializer both
|
|
17
|
+
* resolve through) must be able to find them for the test's RPC-boundary simulation.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Mutable stand-in for the ambient caller identity a scoped column reads (in production, the
|
|
22
|
+
* session's user id). Tests reassign `current` to act as different callers.
|
|
23
|
+
*/
|
|
24
|
+
export const serviceVerbsScopeContext = { current: 'scope-a' };
|
|
25
|
+
|
|
26
|
+
export type ServiceVerbsDocBody = { content?: string; style?: { color?: string } };
|
|
27
|
+
|
|
28
|
+
export interface ServiceVerbsDoc extends Record {
|
|
29
|
+
title: string;
|
|
30
|
+
scope?: string;
|
|
31
|
+
/** Self-referencing children list — the reference-array shape membership ops target. */
|
|
32
|
+
members?: ReferenceArray<ServiceVerbsDoc>;
|
|
33
|
+
/** Plain-JSON column with sub-path ownership split across writers (content vs style). */
|
|
34
|
+
body?: ServiceVerbsDocBody;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Mirrors the generic scoped-record mechanism: scope forced on insert, immutable, injected into every non-system query. */
|
|
38
|
+
export class ServiceVerbsDocTable extends Table<ServiceVerbsDoc> {
|
|
39
|
+
public name = 'db_test_service_update_verbs_doc';
|
|
40
|
+
public columns = withRecordColumns<ServiceVerbsDoc>({
|
|
41
|
+
title: new StringColumn('title'),
|
|
42
|
+
scope: new StringColumn('scope', {
|
|
43
|
+
defaultValue: async () => serviceVerbsScopeContext.current,
|
|
44
|
+
forceDefaultValue: (runAsSystem) => !runAsSystem,
|
|
45
|
+
immutable: (runAsSystem) => !runAsSystem,
|
|
46
|
+
addToQuery: async (qb, runAsSystem) => {
|
|
47
|
+
if (!runAsSystem) {
|
|
48
|
+
qb.condition({ field: 'scope', operator: 'IN', value: [serviceVerbsScopeContext.current] });
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
}),
|
|
52
|
+
members: new ReferenceArrayColumn('members', 'db_test_service_update_verbs_doc', false),
|
|
53
|
+
body: new ObjectColumn<ServiceVerbsDocBody>('body'),
|
|
54
|
+
});
|
|
55
|
+
}
|