@proteinjs/db 1.25.2 → 1.27.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 (63) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/LICENSE +21 -0
  3. package/dist/generated/index.js +7 -7
  4. package/dist/generated/index.js.map +1 -1
  5. package/dist/generated/test/index.js +5 -5
  6. package/dist/generated/test/index.js.map +1 -1
  7. package/dist/index.d.ts +2 -0
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +2 -0
  10. package/dist/index.js.map +1 -1
  11. package/dist/src/Db.d.ts +66 -6
  12. package/dist/src/Db.d.ts.map +1 -1
  13. package/dist/src/Db.js +242 -30
  14. package/dist/src/Db.js.map +1 -1
  15. package/dist/src/MigrationRunner.d.ts.map +1 -1
  16. package/dist/src/MigrationRunner.js +6 -0
  17. package/dist/src/MigrationRunner.js.map +1 -1
  18. package/dist/src/UpdatePreserving.d.ts +34 -0
  19. package/dist/src/UpdatePreserving.d.ts.map +1 -0
  20. package/dist/src/UpdatePreserving.js +72 -0
  21. package/dist/src/UpdatePreserving.js.map +1 -0
  22. package/dist/src/auth/TableAuth.d.ts +9 -0
  23. package/dist/src/auth/TableAuth.d.ts.map +1 -1
  24. package/dist/src/auth/TableAuth.js +36 -5
  25. package/dist/src/auth/TableAuth.js.map +1 -1
  26. package/dist/src/auth/TableServiceAuth.d.ts +7 -0
  27. package/dist/src/auth/TableServiceAuth.d.ts.map +1 -1
  28. package/dist/src/auth/TableServiceAuth.js +34 -0
  29. package/dist/src/auth/TableServiceAuth.js.map +1 -1
  30. package/dist/src/reference/ArrayMembershipOps.d.ts +69 -0
  31. package/dist/src/reference/ArrayMembershipOps.d.ts.map +1 -0
  32. package/dist/src/reference/ArrayMembershipOps.js +146 -0
  33. package/dist/src/reference/ArrayMembershipOps.js.map +1 -0
  34. package/dist/src/services/DbService.d.ts +38 -0
  35. package/dist/src/services/DbService.d.ts.map +1 -1
  36. package/dist/src/services/DbService.js.map +1 -1
  37. package/dist/src/transaction/Transaction.d.ts +19 -2
  38. package/dist/src/transaction/Transaction.d.ts.map +1 -1
  39. package/dist/src/transaction/Transaction.js +38 -0
  40. package/dist/src/transaction/Transaction.js.map +1 -1
  41. package/dist/src/transaction/TransactionContextFactory.d.ts +18 -1
  42. package/dist/src/transaction/TransactionContextFactory.d.ts.map +1 -1
  43. package/dist/test/ArrayMembershipOps.test.d.ts +2 -0
  44. package/dist/test/ArrayMembershipOps.test.d.ts.map +1 -0
  45. package/dist/test/ArrayMembershipOps.test.js +147 -0
  46. package/dist/test/ArrayMembershipOps.test.js.map +1 -0
  47. package/dist/test/TableServiceAuth.test.js +108 -4
  48. package/dist/test/TableServiceAuth.test.js.map +1 -1
  49. package/generated/index.ts +18 -21
  50. package/generated/test/index.ts +41 -44
  51. package/index.ts +2 -0
  52. package/package.json +6 -5
  53. package/src/Db.ts +202 -24
  54. package/src/MigrationRunner.ts +6 -0
  55. package/src/UpdatePreserving.ts +89 -0
  56. package/src/auth/TableAuth.ts +17 -4
  57. package/src/auth/TableServiceAuth.ts +36 -1
  58. package/src/reference/ArrayMembershipOps.ts +155 -0
  59. package/src/services/DbService.ts +38 -0
  60. package/src/transaction/Transaction.ts +52 -2
  61. package/src/transaction/TransactionContextFactory.ts +19 -1
  62. package/test/ArrayMembershipOps.test.ts +160 -0
  63. package/test/TableServiceAuth.test.ts +148 -4
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Commutative membership operations for `ReferenceArrayColumn` values.
3
+ *
4
+ * Motivation (the write-side lost-update class): a client writer that persists a
5
+ * reference-array column as a FULL ID LIST snapshots its in-memory state — when two
6
+ * such writes race (fire-and-forget issuance + driver-level abort/retry can commit
7
+ * an earlier-issued transaction later), the last commit wins wholesale and erases
8
+ * the other writer's committed membership change. Expressing membership changes as
9
+ * ops (add/remove/move) applied read-modify-write against COMMITTED truth inside a
10
+ * transaction makes concurrent writes converge instead of clobber:
11
+ * `remove(x)` + `remove(y)` in any commit order removes both; `add(x)` + `add(y)`
12
+ * keeps both.
13
+ *
14
+ * `applyArrayMembershipOps` is the single applier used by `Db.updateArrayMembership`
15
+ * (server-side RMW) — and by any test that needs the committed rule as importable
16
+ * truth. `computeArrayMembershipOps` derives the minimal op set from a
17
+ * before/after id-list pair, for callers whose op layer only knows list states.
18
+ */
19
+
20
+ export type ArrayMembershipOp =
21
+ /** Insert `id` after `afterId` (`null` = at the head). If `id` is already present it is repositioned. */
22
+ | { op: 'add'; id: string; afterId: string | null }
23
+ /** Remove `id`. No-op when absent. */
24
+ | { op: 'remove'; id: string }
25
+ /** Reposition `id` after `afterId` (`null` = at the head). No-op when `id` is absent (a concurrent remove wins). */
26
+ | { op: 'move'; id: string; afterId: string | null };
27
+
28
+ export type ArrayMembershipUpdate = {
29
+ /** The record whose array column is being updated. */
30
+ recordId: string;
31
+ /** Property name of the `ReferenceArrayColumn` on the table. */
32
+ columnPropertyName: string;
33
+ /** Ops applied in order against the committed id list. */
34
+ ops: ArrayMembershipOp[];
35
+ };
36
+
37
+ /**
38
+ * Apply membership ops to an id list. Pure; returns a new array and whether it
39
+ * differs from the input.
40
+ *
41
+ * Anchor resolution (`afterId`) against a diverged committed list is
42
+ * convergence-by-anchor: a missing anchor appends at the end (the anchor was
43
+ * concurrently removed — the element still lands in the list, order is
44
+ * best-effort), `afterId: null` inserts at the head.
45
+ */
46
+ export function applyArrayMembershipOps(
47
+ currentIds: string[],
48
+ ops: ArrayMembershipOp[]
49
+ ): { ids: string[]; changed: boolean } {
50
+ const ids = [...currentIds];
51
+ for (const op of ops) {
52
+ if (op.op === 'remove') {
53
+ const idx = ids.indexOf(op.id);
54
+ if (idx !== -1) {
55
+ ids.splice(idx, 1);
56
+ }
57
+ continue;
58
+ }
59
+
60
+ if (op.op === 'move' && ids.indexOf(op.id) === -1) {
61
+ // Move of a concurrently-removed element: the remove intent wins.
62
+ continue;
63
+ }
64
+
65
+ // add (insert or reposition) and move (reposition) share placement logic.
66
+ const existingIdx = ids.indexOf(op.id);
67
+ if (existingIdx !== -1) {
68
+ ids.splice(existingIdx, 1);
69
+ }
70
+ if (op.afterId === null) {
71
+ ids.unshift(op.id);
72
+ } else {
73
+ const anchorIdx = ids.indexOf(op.afterId);
74
+ if (anchorIdx === -1) {
75
+ ids.push(op.id);
76
+ } else {
77
+ ids.splice(anchorIdx + 1, 0, op.id);
78
+ }
79
+ }
80
+ }
81
+
82
+ const changed = ids.length !== currentIds.length || ids.some((id, i) => id !== currentIds[i]);
83
+ return { ids, changed };
84
+ }
85
+
86
+ /**
87
+ * Compute the op set that transforms `beforeIds` into `afterIds`.
88
+ *
89
+ * Removes first, then a single walk of `afterIds` emitting `add` for new
90
+ * elements and `move` for surviving elements whose relative order changed.
91
+ * Moves are minimized via the longest increasing subsequence of surviving
92
+ * elements (elements on the LIS stay put; everything else moves). Replaying
93
+ * the result on `beforeIds` yields exactly `afterIds`; replaying it on a
94
+ * DIVERGED committed list converges by anchor instead of clobbering.
95
+ */
96
+ export function computeArrayMembershipOps(beforeIds: string[], afterIds: string[]): ArrayMembershipOp[] {
97
+ const before = beforeIds;
98
+ const after = afterIds;
99
+ const beforeSet = new Set(before);
100
+ const afterSet = new Set(after);
101
+
102
+ const ops: ArrayMembershipOp[] = [];
103
+ for (const id of before) {
104
+ if (!afterSet.has(id)) {
105
+ ops.push({ op: 'remove', id });
106
+ }
107
+ }
108
+
109
+ // Surviving elements, in after-order, with their positions in `before`.
110
+ const surviving = after.filter((id) => beforeSet.has(id));
111
+ const beforeIndex = new Map(before.map((id, i) => [id, i] as const));
112
+ const stable = longestIncreasingSubsequence(surviving.map((id) => beforeIndex.get(id) as number));
113
+ const stableIds = new Set(stable.map((i) => surviving[i]));
114
+
115
+ for (let i = 0; i < after.length; i++) {
116
+ const id = after[i];
117
+ const afterId = i === 0 ? null : after[i - 1];
118
+ if (!beforeSet.has(id)) {
119
+ ops.push({ op: 'add', id, afterId });
120
+ } else if (!stableIds.has(id)) {
121
+ ops.push({ op: 'move', id, afterId });
122
+ }
123
+ }
124
+
125
+ return ops;
126
+ }
127
+
128
+ /** Indices (into the input array) of one longest strictly-increasing subsequence. */
129
+ function longestIncreasingSubsequence(values: number[]): number[] {
130
+ const tailIndices: number[] = [];
131
+ const prev: number[] = new Array(values.length).fill(-1);
132
+ for (let i = 0; i < values.length; i++) {
133
+ let lo = 0;
134
+ let hi = tailIndices.length;
135
+ while (lo < hi) {
136
+ const mid = (lo + hi) >> 1;
137
+ if (values[tailIndices[mid]] < values[i]) {
138
+ lo = mid + 1;
139
+ } else {
140
+ hi = mid;
141
+ }
142
+ }
143
+ if (lo > 0) {
144
+ prev[i] = tailIndices[lo - 1];
145
+ }
146
+ tailIndices[lo] = i;
147
+ }
148
+ const result: number[] = [];
149
+ let k = tailIndices.length > 0 ? tailIndices[tailIndices.length - 1] : -1;
150
+ while (k !== -1) {
151
+ result.unshift(k);
152
+ k = prev[k];
153
+ }
154
+ return result;
155
+ }
@@ -2,6 +2,8 @@ import { Service, serviceFactory } from '@proteinjs/service';
2
2
  import { Table } from '../Table';
3
3
  import { Record } from '../Record';
4
4
  import { QueryBuilder } from '@proteinjs/db-query';
5
+ import { ArrayMembershipUpdate } from '../reference/ArrayMembershipOps';
6
+ import { PreservedPath } from '../UpdatePreserving';
5
7
 
6
8
  export const getDbService = serviceFactory<DbService>('@proteinjs/db/DbService');
7
9
 
@@ -22,6 +24,42 @@ export interface DbService<R extends Record = Record> extends Service {
22
24
  get<T extends R>(table: Table<T>, query: Query<T>, options?: QueryOptions<T>): Promise<T>;
23
25
  insert<T extends R>(table: Table<T>, record: Omit<T, keyof R>): Promise<T>;
24
26
  update<T extends R>(table: Table<T>, record: Partial<T>, query?: Query<T>): Promise<number>;
27
+ /**
28
+ * Apply commutative membership ops (add/remove/move) to a `ReferenceArrayColumn`,
29
+ * read-modify-write against COMMITTED truth inside a server-side transaction, so concurrent
30
+ * membership writers converge instead of last-write-wins clobbering each other (the
31
+ * write-side lost-update class).
32
+ *
33
+ * Use when the column has a NAMED multi-writer split — more than one writer changes the
34
+ * list's membership concurrently. Use plain `update` when the column has a single writer,
35
+ * or when the intent genuinely is wholesale assignment of the entire list.
36
+ *
37
+ * Authorization is identical to `update`: the table's `update` grant gates the call, and
38
+ * scoped/column query injection applies to the server-side read-modify-write — a scoped
39
+ * caller can only touch rows they could already update (an out-of-scope record behaves as
40
+ * nonexistent: returns 0).
41
+ *
42
+ * @returns the update count (0 when the ops are a no-op against committed truth, or the
43
+ * record does not exist / is not visible to the caller)
44
+ */
45
+ updateArrayMembership<T extends R>(table: Table<T>, update: ArrayMembershipUpdate): Promise<number>;
46
+ /**
47
+ * Update with committed-truth preservation for column sub-paths the writer does not own:
48
+ * the payload's listed paths are overlaid with their committed values (read in the same
49
+ * server-side transaction), so this write commutes with the writers that own those paths.
50
+ * Plain-JSON columns only.
51
+ *
52
+ * Use when ownership of a column's VALUE is split across writers by sub-path (e.g. a
53
+ * structural editor op writes an object's styling while a debounced text save owns
54
+ * `content` — the structural payload's `content` is stale by construction). Use plain
55
+ * `update` when the writer owns the whole value it writes.
56
+ *
57
+ * Authorization is identical to `update` (update grant, scoped/column query injection,
58
+ * `serviceProtectedColumns` enforced on the payload).
59
+ *
60
+ * @returns the update count (0 when the record does not exist / is not visible to the caller)
61
+ */
62
+ updatePreserving<T extends R>(table: Table<T>, record: Partial<T>, preserve: PreservedPath[]): Promise<number>;
25
63
  delete<T extends R>(table: Table<T>, query: Query<T>): Promise<number>;
26
64
  query<T extends R>(table: Table<T>, query: Query<T>, options?: QueryOptions<T>): Promise<T[]>;
27
65
  getRowCount<T extends R>(table: Table<T>, query?: Query<T>): Promise<number>;
@@ -4,6 +4,8 @@ import { getTransactionRunner } from './TransactionRunner';
4
4
  import { addDefaultFieldValues, Table } from '../Table';
5
5
  import { isInstanceOf } from '@proteinjs/util';
6
6
  import { Condition, QueryBuilder } from '@proteinjs/db-query';
7
+ import { ArrayMembershipUpdate } from '../reference/ArrayMembershipOps';
8
+ import { PreservedPath } from '../UpdatePreserving';
7
9
 
8
10
  export type OperationQueue<R extends Record = Record> = {
9
11
  insert: (...args: Parameters<DbService<R>['insert']>) => Promise<R>;
@@ -12,8 +14,13 @@ export type OperationQueue<R extends Record = Record> = {
12
14
  };
13
15
 
14
16
  export type Operation<R extends Record = Record> = {
15
- name: 'insert' | 'update' | 'delete';
16
- args: Parameters<DbService<R>['insert']> | Parameters<DbService<R>['update']> | Parameters<DbService<R>['delete']>;
17
+ name: 'insert' | 'update' | 'delete' | 'updateArrayMembership' | 'updatePreserving';
18
+ args:
19
+ | Parameters<DbService<R>['insert']>
20
+ | Parameters<DbService<R>['update']>
21
+ | Parameters<DbService<R>['delete']>
22
+ | [Table<any>, ArrayMembershipUpdate]
23
+ | [Table<any>, Partial<R>, PreservedPath[]];
17
24
  };
18
25
 
19
26
  const hasAllProperties = (a: any, b: any) => Object.entries(b).every(([key, val]) => a[key] === val);
@@ -182,6 +189,49 @@ export class Transaction implements OperationQueue {
182
189
  this.ops.push({ name: 'delete', args });
183
190
  }
184
191
 
192
+ /**
193
+ * Queue a commutative array-membership update (see `Db.updateArrayMembership`).
194
+ *
195
+ * The op is applied read-modify-write against COMMITTED truth server-side, so it
196
+ * does not touch the local record cache (the caller's in-memory state is the
197
+ * client-side truth and was already mutated by the operation that queued this);
198
+ * `onUpdate` listeners do not fire for membership ops.
199
+ */
200
+ updateArrayMembership(table: Table<any>, update: ArrayMembershipUpdate): void {
201
+ if (update.ops.length === 0) {
202
+ return;
203
+ }
204
+
205
+ this.ops.push({ name: 'updateArrayMembership', args: [table, update] });
206
+ }
207
+
208
+ /**
209
+ * Queue an update with committed-truth preservation for the listed column
210
+ * sub-paths (see `Db.updatePreserving`). Cache semantics match `update`
211
+ * for the provided fields; the preservation itself happens server-side.
212
+ */
213
+ updatePreserving<R extends Record = Record>(table: Table<R>, record: Partial<R>, preserve: PreservedPath[]): void {
214
+ if (!record.id) {
215
+ throw new Error(`updatePreserving must be called with a record with an id property`);
216
+ }
217
+
218
+ const recordMap = this.recordMap(table.name);
219
+ const existingRecord = recordMap[record.id];
220
+ if (existingRecord) {
221
+ const prevRecord = { ...existingRecord };
222
+ Object.keys(record).forEach((key) => {
223
+ if (key !== 'id') {
224
+ existingRecord[key] = (record as any)[key];
225
+ }
226
+ });
227
+ if (this.onUpdate) {
228
+ this.onUpdate(table, prevRecord, existingRecord);
229
+ }
230
+ }
231
+
232
+ this.ops.push({ name: 'updatePreserving', args: [table, record, preserve] });
233
+ }
234
+
185
235
  /**
186
236
  * If a QueryBuilder contains a condition like this:
187
237
  * `{ field: 'id', operator: 'IN', value: string[] }`
@@ -3,11 +3,29 @@ import { Loadable, SourceRepository } from '@proteinjs/reflection';
3
3
  export const getDefaultTransactionContextFactory = () =>
4
4
  SourceRepository.get().object<DefaultTransactionContextFactory>('@proteinjs/db/DefaultTransactionContextFactory');
5
5
 
6
+ /** See {@link TransactionContextData.postCommitHooks} and `Db.runAfterCommit`. */
7
+ export type PostCommitHook = () => void | Promise<void>;
8
+
6
9
  export interface TransactionContextData {
7
10
  currentTransaction?: any;
11
+ /**
12
+ * Hooks queued to run after the current transaction COMMITS — and never on rollback: a hook
13
+ * queued for a write that gets rolled back dies with the write. Seeded by `Db.runTransaction`
14
+ * on the ambient context so every `Db` instance created inside the transaction (table
15
+ * watchers, nested helpers) shares one queue; registered via `Db.runAfterCommit`, drained by
16
+ * `Db.runTransaction` once the driver reports the commit durable.
17
+ */
18
+ postCommitHooks?: PostCommitHook[];
19
+ /**
20
+ * Set by `Db.runTransaction` when the transaction completes. Work spawned inside the
21
+ * transaction body but not awaited by it still holds this store by reference (the async
22
+ * context propagates) — the flag turns any later db operation from that escaped context
23
+ * into a loud, named error instead of silently handing the driver a finished transaction.
24
+ */
25
+ ended?: boolean;
8
26
  }
9
27
 
10
28
  export interface DefaultTransactionContextFactory extends Loadable {
11
29
  getTransactionContext(): TransactionContextData;
12
- runInContext<T>(transaction: any, fn: () => Promise<T>): Promise<T>;
30
+ runInContext<T>(context: TransactionContextData, fn: () => Promise<T>): Promise<T>;
13
31
  }
@@ -0,0 +1,160 @@
1
+ import {
2
+ applyArrayMembershipOps,
3
+ computeArrayMembershipOps,
4
+ ArrayMembershipOp,
5
+ } from '../src/reference/ArrayMembershipOps';
6
+ import { overlayPreservedPaths } from '../src/UpdatePreserving';
7
+
8
+ const replay = (before: string[], after: string[]) =>
9
+ applyArrayMembershipOps(before, computeArrayMembershipOps(before, after)).ids;
10
+
11
+ describe('computeArrayMembershipOps + applyArrayMembershipOps', () => {
12
+ test('replaying the computed ops on the before-list yields exactly the after-list', () => {
13
+ const cases: Array<[string[], string[]]> = [
14
+ [[], []],
15
+ [[], ['a']],
16
+ [['a'], []],
17
+ [
18
+ ['a', 'b', 'c'],
19
+ ['a', 'b', 'c'],
20
+ ],
21
+ [
22
+ ['a', 'b', 'c'],
23
+ ['a', 'c'],
24
+ ],
25
+ [
26
+ ['a', 'c'],
27
+ ['a', 'b', 'c'],
28
+ ],
29
+ [
30
+ ['a', 'b', 'c'],
31
+ ['c', 'b', 'a'],
32
+ ],
33
+ [
34
+ ['a', 'b', 'c', 'd', 'e'],
35
+ ['e', 'x', 'a', 'c'],
36
+ ],
37
+ [
38
+ ['a', 'b'],
39
+ ['x', 'y', 'z'],
40
+ ],
41
+ [
42
+ ['a', 'b', 'c', 'd'],
43
+ ['b', 'd', 'a', 'c'],
44
+ ],
45
+ ];
46
+ for (const [before, after] of cases) {
47
+ expect(replay(before, after)).toEqual(after);
48
+ }
49
+ });
50
+
51
+ test('no-op diff computes zero ops and apply reports unchanged', () => {
52
+ const ops = computeArrayMembershipOps(['a', 'b'], ['a', 'b']);
53
+ expect(ops).toEqual([]);
54
+ expect(applyArrayMembershipOps(['a', 'b'], ops).changed).toBe(false);
55
+ });
56
+
57
+ test('unchanged relative order of survivors emits no move ops (pure adds/removes)', () => {
58
+ const ops = computeArrayMembershipOps(['a', 'b', 'c', 'd'], ['a', 'c', 'd', 'e']);
59
+ expect(ops).toEqual([
60
+ { op: 'remove', id: 'b' },
61
+ { op: 'add', id: 'e', afterId: 'd' },
62
+ ]);
63
+ });
64
+
65
+ test('CONVERGENCE: concurrent removes of different ids both land regardless of commit order', () => {
66
+ // Client A removes x, client B removes y; each computed against the same base.
67
+ const base = ['x', 'y', 'z'];
68
+ const opsA = computeArrayMembershipOps(base, ['y', 'z']);
69
+ const opsB = computeArrayMembershipOps(base, ['x', 'z']);
70
+
71
+ const abOrder = applyArrayMembershipOps(applyArrayMembershipOps(base, opsA).ids, opsB).ids;
72
+ const baOrder = applyArrayMembershipOps(applyArrayMembershipOps(base, opsB).ids, opsA).ids;
73
+ expect(abOrder).toEqual(['z']);
74
+ expect(baOrder).toEqual(['z']);
75
+ });
76
+
77
+ test('CONVERGENCE: concurrent adds of different ids both survive regardless of commit order', () => {
78
+ const base = ['a'];
79
+ const opsA = computeArrayMembershipOps(base, ['a', 'p']); // A appends p
80
+ const opsB = computeArrayMembershipOps(base, ['a', 'q']); // B appends q
81
+
82
+ const abOrder = applyArrayMembershipOps(applyArrayMembershipOps(base, opsA).ids, opsB).ids;
83
+ const baOrder = applyArrayMembershipOps(applyArrayMembershipOps(base, opsB).ids, opsA).ids;
84
+ expect(new Set(abOrder)).toEqual(new Set(['a', 'p', 'q']));
85
+ expect(new Set(baOrder)).toEqual(new Set(['a', 'p', 'q']));
86
+ });
87
+
88
+ test('CONVERGENCE: the founder burst — three sequential deletes converge under ANY commit order', () => {
89
+ // In-memory the client deletes d, then e, then f (each delta computed
90
+ // against its own pre-state, as deleteThought does). The wire commits in a
91
+ // reordered sequence (Spanner abort/retry): every permutation must end at
92
+ // the fully-deleted list — the pre-fix full-list snapshots resurrect rows.
93
+ const s0 = ['a', 'd', 'e', 'f'];
94
+ const del1 = computeArrayMembershipOps(s0, ['a', 'e', 'f']);
95
+ const del2 = computeArrayMembershipOps(['a', 'e', 'f'], ['a', 'f']);
96
+ const del3 = computeArrayMembershipOps(['a', 'f'], ['a']);
97
+
98
+ const permutations: ArrayMembershipOp[][][] = [
99
+ [del1, del2, del3],
100
+ [del1, del3, del2],
101
+ [del2, del1, del3],
102
+ [del2, del3, del1],
103
+ [del3, del1, del2],
104
+ [del3, del2, del1],
105
+ ];
106
+ for (const order of permutations) {
107
+ let ids = s0;
108
+ for (const ops of order) {
109
+ ids = applyArrayMembershipOps(ids, ops).ids;
110
+ }
111
+ expect(ids).toEqual(['a']);
112
+ }
113
+ });
114
+
115
+ test('anchors: missing afterId appends at the end; null afterId inserts at the head; moves of removed ids are dropped', () => {
116
+ expect(applyArrayMembershipOps(['a', 'b'], [{ op: 'add', id: 'n', afterId: 'gone' }]).ids).toEqual(['a', 'b', 'n']);
117
+ expect(applyArrayMembershipOps(['a', 'b'], [{ op: 'add', id: 'n', afterId: null }]).ids).toEqual(['n', 'a', 'b']);
118
+ expect(applyArrayMembershipOps(['a', 'b'], [{ op: 'move', id: 'gone', afterId: 'a' }]).ids).toEqual(['a', 'b']);
119
+ expect(applyArrayMembershipOps(['a', 'b'], [{ op: 'add', id: 'b', afterId: null }]).ids).toEqual(['b', 'a']);
120
+ });
121
+ });
122
+
123
+ describe('overlayPreservedPaths', () => {
124
+ test('preserves the committed text content into a structural payload (the class-2 kill)', () => {
125
+ const committed = { content: 'Tab beta', type: 'body1' };
126
+ const incoming = { content: '', type: 'h6', fontSize: 12 };
127
+ expect(overlayPreservedPaths(committed, incoming, ['content'], 'string')).toEqual({
128
+ content: 'Tab beta',
129
+ type: 'h6',
130
+ fontSize: 12,
131
+ });
132
+ // The incoming payload object is not mutated.
133
+ expect(incoming.content).toBe('');
134
+ });
135
+
136
+ test('nested path (composite text object)', () => {
137
+ const committed = { content: { thoughtTypeId: 't', thoughtObject: { content: 'typed', type: 'body1' } } };
138
+ const incoming = { content: { thoughtTypeId: 't', thoughtObject: { content: '', type: 'h3' } } };
139
+ const out: any = overlayPreservedPaths(committed, incoming, ['content.thoughtObject.content'], 'string');
140
+ expect(out.content.thoughtObject).toEqual({ content: 'typed', type: 'h3' });
141
+ });
142
+
143
+ test('whenType guards shape transitions: committed non-string content is not dragged into a new shape', () => {
144
+ const committedComposite = { content: { thoughtTypeId: 't', thoughtObject: { content: 'x' } } };
145
+ const incomingPlain = { content: 'fresh', type: 'body1' };
146
+ expect(overlayPreservedPaths(committedComposite, incomingPlain, ['content'], 'string')).toEqual(incomingPlain);
147
+ });
148
+
149
+ test('missing committed path keeps the incoming value (facet-init seeding survives)', () => {
150
+ const committed = { someOtherField: 1 };
151
+ const incoming = { content: 'seeded from description', type: 'body1' };
152
+ expect(overlayPreservedPaths(committed, incoming, ['content'], 'string')).toEqual(incoming);
153
+ });
154
+
155
+ test('incoming payload lacking the path parent is left untouched', () => {
156
+ const committed = { content: { thoughtTypeId: 't', thoughtObject: { content: 'x' } } };
157
+ const incoming = { plain: true };
158
+ expect(overlayPreservedPaths(committed, incoming, ['content.thoughtObject.content'], 'string')).toEqual(incoming);
159
+ });
160
+ });
@@ -11,6 +11,10 @@ import { TableServiceAuth } from '../src/auth/TableServiceAuth';
11
11
  * authenticated user delete from read-only tables)
12
12
  * - `auth.serviceProtectedColumns`: columns that can never be SET via the service path,
13
13
  * rejected with a clean `ServiceError` (message passes through to the client verbatim)
14
+ * - denials cross the wire as a `ServiceError` naming the table and operation — a denied query
15
+ * must be distinguishable from an empty one at the surface that shows it (the admin Sessions
16
+ * table read "no rows" while every query behind it was denied, 2026-08). A boolean `false`
17
+ * here collapses into the generic run-service denial.
14
18
  *
15
19
  * `UserAuth` reads from a static repo; tests stub it directly per identity — no server needed.
16
20
  */
@@ -47,6 +51,18 @@ class ProtectedColumnTable extends Table<Doc> {
47
51
  });
48
52
  }
49
53
 
54
+ /**
55
+ * Mirrors the admin tables surfaced in the settings menu (user, invite, session, migration):
56
+ * NO auth block at all — the default-deny contract makes them admin-only through the service path.
57
+ */
58
+ class NoAuthTable extends Table<Doc> {
59
+ public name = 'no_auth_test';
60
+ public columns = withRecordColumns<Doc>({
61
+ title: new StringColumn('title'),
62
+ owner: new StringColumn('owner'),
63
+ });
64
+ }
65
+
50
66
  /** Read-only intent: only `query` granted (the canDelete-regression shape). */
51
67
  class ReadOnlyTable extends Table<Doc> {
52
68
  public name = 'read_only_test';
@@ -70,6 +86,18 @@ const setUser = (roles: string[]) => {
70
86
 
71
87
  const auth = () => new TableServiceAuth();
72
88
 
89
+ /** A denial must be a client-safe ServiceError naming the table and operation. */
90
+ const expectDenied = (fn: () => unknown, message: string) => {
91
+ let thrown: any;
92
+ try {
93
+ fn();
94
+ } catch (error) {
95
+ thrown = error;
96
+ }
97
+ expect(thrown?.name).toBe('ServiceError');
98
+ expect(thrown?.message).toBe(message);
99
+ };
100
+
73
101
  describe('TableServiceAuth — per-operation service grants', () => {
74
102
  afterEach(() => {
75
103
  (UserAuth as unknown as UserAuthInternals).userRepo = undefined;
@@ -81,9 +109,18 @@ describe('TableServiceAuth — per-operation service grants', () => {
81
109
  expect(auth().canAccess('query', [table, {}])).toBe(true);
82
110
  expect(auth().canAccess('get', [table, { id: 'x' }])).toBe(true);
83
111
  expect(auth().canAccess('getRowCount', [table, {}])).toBe(true);
84
- expect(auth().canAccess('insert', [table, { title: 't' }])).toBe(false);
85
- expect(auth().canAccess('update', [table, { id: 'x', title: 't' }])).toBe(false);
86
- expect(auth().canAccess('delete', [table, { id: 'x' }])).toBe(false);
112
+ expectDenied(
113
+ () => auth().canAccess('insert', [table, { title: 't' }]),
114
+ 'User is not authorized to insert records into table: server_written_test'
115
+ );
116
+ expectDenied(
117
+ () => auth().canAccess('update', [table, { id: 'x', title: 't' }]),
118
+ 'User is not authorized to update records in table: server_written_test'
119
+ );
120
+ expectDenied(
121
+ () => auth().canAccess('delete', [table, { id: 'x' }]),
122
+ 'User is not authorized to delete records from table: server_written_test'
123
+ );
87
124
  });
88
125
 
89
126
  it('admin: writes allowed on a server-written table', () => {
@@ -98,7 +135,114 @@ describe('TableServiceAuth — per-operation service grants', () => {
98
135
  setUser([]);
99
136
  const table = new ReadOnlyTable();
100
137
  expect(auth().canAccess('query', [table, {}])).toBe(true);
101
- expect(auth().canAccess('delete', [table, { id: 'x' }])).toBe(false);
138
+ expectDenied(
139
+ () => auth().canAccess('delete', [table, { id: 'x' }]),
140
+ 'User is not authorized to delete records from table: read_only_test'
141
+ );
142
+ });
143
+ });
144
+
145
+ describe('TableServiceAuth — default deny (no auth block)', () => {
146
+ afterEach(() => {
147
+ (UserAuth as unknown as UserAuthInternals).userRepo = undefined;
148
+ });
149
+
150
+ it('denies every operation to an authenticated non-admin, naming the table (never a silent false)', () => {
151
+ setUser([]);
152
+ const table = new NoAuthTable();
153
+ const queryDenial = 'User is not authorized to query table: no_auth_test';
154
+ expectDenied(() => auth().canAccess('query', [table, {}]), queryDenial);
155
+ expectDenied(() => auth().canAccess('get', [table, { id: 'x' }]), queryDenial);
156
+ expectDenied(() => auth().canAccess('getRowCount', [table, {}]), queryDenial);
157
+ expectDenied(
158
+ () => auth().canAccess('insert', [table, { title: 't' }]),
159
+ 'User is not authorized to insert records into table: no_auth_test'
160
+ );
161
+ expectDenied(
162
+ () => auth().canAccess('update', [table, { id: 'x', title: 't' }]),
163
+ 'User is not authorized to update records in table: no_auth_test'
164
+ );
165
+ expectDenied(
166
+ () => auth().canAccess('delete', [table, { id: 'x' }]),
167
+ 'User is not authorized to delete records from table: no_auth_test'
168
+ );
169
+ });
170
+
171
+ it('allows every operation for an admin', () => {
172
+ setUser(['admin']);
173
+ const table = new NoAuthTable();
174
+ expect(auth().canAccess('query', [table, {}])).toBe(true);
175
+ expect(auth().canAccess('get', [table, { id: 'x' }])).toBe(true);
176
+ expect(auth().canAccess('getRowCount', [table, {}])).toBe(true);
177
+ expect(auth().canAccess('insert', [table, { title: 't' }])).toBe(true);
178
+ expect(auth().canAccess('update', [table, { id: 'x', title: 't' }])).toBe(true);
179
+ expect(auth().canAccess('delete', [table, { id: 'x' }])).toBe(true);
180
+ });
181
+ });
182
+
183
+ describe('TableServiceAuth — updateArrayMembership / updatePreserving (update parity)', () => {
184
+ afterEach(() => {
185
+ (UserAuth as unknown as UserAuthInternals).userRepo = undefined;
186
+ });
187
+
188
+ const membership = (columnPropertyName: string) => ({
189
+ recordId: 'x',
190
+ columnPropertyName,
191
+ ops: [{ op: 'add', id: 'm1', afterId: null }],
192
+ });
193
+
194
+ it('both verbs are gated by the update grant: denied for non-admin, allowed for admin', () => {
195
+ setUser([]);
196
+ const table = new ServerWrittenTable();
197
+ const updateDenial = 'User is not authorized to update records in table: server_written_test';
198
+ expectDenied(() => auth().canAccess('updateArrayMembership', [table, membership('title')]), updateDenial);
199
+ expectDenied(
200
+ () =>
201
+ auth().canAccess('updatePreserving', [
202
+ table,
203
+ { id: 'x', title: 't' },
204
+ [{ columnPropertyName: 'title', paths: ['content'] }],
205
+ ]),
206
+ updateDenial
207
+ );
208
+
209
+ setUser(['admin']);
210
+ expect(auth().canAccess('updateArrayMembership', [table, membership('title')])).toBe(true);
211
+ expect(
212
+ auth().canAccess('updatePreserving', [
213
+ table,
214
+ { id: 'x', title: 't' },
215
+ [{ columnPropertyName: 'title', paths: ['content'] }],
216
+ ])
217
+ ).toBe(true);
218
+ });
219
+
220
+ it('default deny (no auth block): both verbs denied for non-admin with the update denial', () => {
221
+ setUser([]);
222
+ const table = new NoAuthTable();
223
+ const updateDenial = 'User is not authorized to update records in table: no_auth_test';
224
+ expectDenied(() => auth().canAccess('updateArrayMembership', [table, membership('title')]), updateDenial);
225
+ expectDenied(() => auth().canAccess('updatePreserving', [table, { id: 'x', title: 't' }, []]), updateDenial);
226
+ });
227
+
228
+ it('updateArrayMembership cannot target a serviceProtectedColumn (its payload names the column, not record fields)', () => {
229
+ setUser([]);
230
+ const table = new ProtectedColumnTable();
231
+ expectDenied(
232
+ () => auth().canAccess('updateArrayMembership', [table, membership('owner')]),
233
+ "Column 'owner' cannot be written via the db service on table: protected_column_test"
234
+ );
235
+ expect(auth().canAccess('updateArrayMembership', [table, membership('title')])).toBe(true);
236
+ });
237
+
238
+ it('updatePreserving runs the record-shaped protected-column check on its payload', () => {
239
+ setUser([]);
240
+ const table = new ProtectedColumnTable();
241
+ expectDenied(
242
+ () => auth().canAccess('updatePreserving', [table, { id: 'x', owner: 'someone' }, []]),
243
+ "Column 'owner' cannot be written via the db service on table: protected_column_test"
244
+ );
245
+ expect(auth().canAccess('updatePreserving', [table, { id: 'x', title: 't', owner: null }, []])).toBe(true);
102
246
  });
103
247
  });
104
248