@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.
Files changed (40) hide show
  1. package/dist/generated/index.js +1 -1
  2. package/dist/generated/index.js.map +1 -1
  3. package/dist/generated/test/index.d.ts.map +1 -1
  4. package/dist/generated/test/index.js +3 -1
  5. package/dist/generated/test/index.js.map +1 -1
  6. package/dist/src/SpannerDriver.d.ts +12 -0
  7. package/dist/src/SpannerDriver.d.ts.map +1 -1
  8. package/dist/src/SpannerDriver.js +62 -13
  9. package/dist/src/SpannerDriver.js.map +1 -1
  10. package/dist/src/SpannerLivenessMonitor.d.ts.map +1 -1
  11. package/dist/src/SpannerLivenessMonitor.js +11 -2
  12. package/dist/src/SpannerLivenessMonitor.js.map +1 -1
  13. package/dist/test/ServiceUpdateVerbs.test.d.ts +2 -0
  14. package/dist/test/ServiceUpdateVerbs.test.d.ts.map +1 -0
  15. package/dist/test/ServiceUpdateVerbs.test.js +325 -0
  16. package/dist/test/ServiceUpdateVerbs.test.js.map +1 -0
  17. package/dist/test/SessionRecordTableQuery.test.d.ts +2 -0
  18. package/dist/test/SessionRecordTableQuery.test.d.ts.map +1 -0
  19. package/dist/test/SessionRecordTableQuery.test.js +185 -0
  20. package/dist/test/SessionRecordTableQuery.test.js.map +1 -0
  21. package/dist/test/SpannerLivenessMonitor.test.js +23 -0
  22. package/dist/test/SpannerLivenessMonitor.test.js.map +1 -1
  23. package/dist/test/index.d.ts +1 -0
  24. package/dist/test/index.d.ts.map +1 -1
  25. package/dist/test/index.js +1 -0
  26. package/dist/test/index.js.map +1 -1
  27. package/dist/test/util/serviceUpdateVerbsTestTables.d.ts +45 -0
  28. package/dist/test/util/serviceUpdateVerbsTestTables.d.ts.map +1 -0
  29. package/dist/test/util/serviceUpdateVerbsTestTables.js +100 -0
  30. package/dist/test/util/serviceUpdateVerbsTestTables.js.map +1 -0
  31. package/generated/index.ts +1 -1
  32. package/generated/test/index.ts +3 -1
  33. package/package.json +5 -5
  34. package/src/SpannerDriver.ts +35 -6
  35. package/src/SpannerLivenessMonitor.ts +12 -2
  36. package/test/ServiceUpdateVerbs.test.ts +188 -0
  37. package/test/SessionRecordTableQuery.test.ts +118 -0
  38. package/test/SpannerLivenessMonitor.test.ts +15 -0
  39. package/test/index.ts +1 -0
  40. package/test/util/serviceUpdateVerbsTestTables.ts +55 -0
@@ -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
- const rowCount = await this.executeDml(generateStatement, transaction);
223
- await transaction.commit();
224
- return rowCount;
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
- const result = await fn(transaction);
281
- await transaction.commit();
282
- return result;
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 can restart into a valid state`,
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(1);
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
@@ -1,2 +1,3 @@
1
1
  export * from './util/getDropTestTable';
2
2
  export * from './util/SpannerEmulatorProvisioner';
3
+ export * from './util/serviceUpdateVerbsTestTables';
@@ -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
+ }