@proteinjs/db 1.25.2 → 1.26.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 (61) hide show
  1. package/dist/generated/index.js +7 -7
  2. package/dist/generated/index.js.map +1 -1
  3. package/dist/generated/test/index.js +7 -7
  4. package/dist/generated/test/index.js.map +1 -1
  5. package/dist/index.d.ts +2 -0
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +2 -0
  8. package/dist/index.js.map +1 -1
  9. package/dist/src/Db.d.ts +48 -1
  10. package/dist/src/Db.d.ts.map +1 -1
  11. package/dist/src/Db.js +206 -2
  12. package/dist/src/Db.js.map +1 -1
  13. package/dist/src/MigrationRunner.d.ts.map +1 -1
  14. package/dist/src/MigrationRunner.js +6 -0
  15. package/dist/src/MigrationRunner.js.map +1 -1
  16. package/dist/src/UpdatePreserving.d.ts +34 -0
  17. package/dist/src/UpdatePreserving.d.ts.map +1 -0
  18. package/dist/src/UpdatePreserving.js +72 -0
  19. package/dist/src/UpdatePreserving.js.map +1 -0
  20. package/dist/src/auth/TableAuth.d.ts +9 -0
  21. package/dist/src/auth/TableAuth.d.ts.map +1 -1
  22. package/dist/src/auth/TableAuth.js +36 -5
  23. package/dist/src/auth/TableAuth.js.map +1 -1
  24. package/dist/src/auth/TableServiceAuth.d.ts +7 -0
  25. package/dist/src/auth/TableServiceAuth.d.ts.map +1 -1
  26. package/dist/src/auth/TableServiceAuth.js +34 -0
  27. package/dist/src/auth/TableServiceAuth.js.map +1 -1
  28. package/dist/src/reference/ArrayMembershipOps.d.ts +69 -0
  29. package/dist/src/reference/ArrayMembershipOps.d.ts.map +1 -0
  30. package/dist/src/reference/ArrayMembershipOps.js +146 -0
  31. package/dist/src/reference/ArrayMembershipOps.js.map +1 -0
  32. package/dist/src/services/DbService.d.ts +38 -0
  33. package/dist/src/services/DbService.d.ts.map +1 -1
  34. package/dist/src/services/DbService.js.map +1 -1
  35. package/dist/src/transaction/Transaction.d.ts +19 -2
  36. package/dist/src/transaction/Transaction.d.ts.map +1 -1
  37. package/dist/src/transaction/Transaction.js +38 -0
  38. package/dist/src/transaction/Transaction.js.map +1 -1
  39. package/dist/src/transaction/TransactionContextFactory.d.ts +10 -0
  40. package/dist/src/transaction/TransactionContextFactory.d.ts.map +1 -1
  41. package/dist/test/ArrayMembershipOps.test.d.ts +2 -0
  42. package/dist/test/ArrayMembershipOps.test.d.ts.map +1 -0
  43. package/dist/test/ArrayMembershipOps.test.js +147 -0
  44. package/dist/test/ArrayMembershipOps.test.js.map +1 -0
  45. package/dist/test/TableServiceAuth.test.js +108 -4
  46. package/dist/test/TableServiceAuth.test.js.map +1 -1
  47. package/generated/index.ts +7 -7
  48. package/generated/test/index.ts +7 -7
  49. package/index.ts +2 -0
  50. package/package.json +4 -4
  51. package/src/Db.ts +160 -1
  52. package/src/MigrationRunner.ts +6 -0
  53. package/src/UpdatePreserving.ts +89 -0
  54. package/src/auth/TableAuth.ts +17 -4
  55. package/src/auth/TableServiceAuth.ts +36 -1
  56. package/src/reference/ArrayMembershipOps.ts +155 -0
  57. package/src/services/DbService.ts +38 -0
  58. package/src/transaction/Transaction.ts +52 -2
  59. package/src/transaction/TransactionContextFactory.ts +11 -0
  60. package/test/ArrayMembershipOps.test.ts +160 -0
  61. package/test/TableServiceAuth.test.ts +148 -4
package/index.ts CHANGED
@@ -5,7 +5,9 @@ export * from './src/auth/TableServiceAuth';
5
5
  export * from './src/Columns';
6
6
  export * from './src/Record';
7
7
  export * from './src/reference/ReferenceArray';
8
+ export * from './src/reference/ArrayMembershipOps';
8
9
  export * from './src/reference/Reference';
10
+ export * from './src/UpdatePreserving';
9
11
  export * from './src/reference/ReferenceCache';
10
12
  export * from './src/RecordIterator';
11
13
  export * from './src/source/SourceRecord';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proteinjs/db",
3
- "version": "1.25.2",
3
+ "version": "1.26.0",
4
4
  "main": "./dist/generated/index.js",
5
5
  "types": "./dist/generated/index.d.ts",
6
6
  "exports": {
@@ -41,13 +41,13 @@
41
41
  "test": "jest --passWithNoTests"
42
42
  },
43
43
  "dependencies": {
44
- "@proteinjs/db-query": "^1.4.10",
44
+ "@proteinjs/db-query": "^1.5.0",
45
45
  "@proteinjs/logger": "^1.0.21",
46
46
  "@proteinjs/reflection": "^1.1.12",
47
47
  "@proteinjs/serializer": "^1.1.10",
48
48
  "@proteinjs/server-api": "^3.0.10",
49
- "@proteinjs/service": "^1.4.0",
50
- "@proteinjs/user-auth": "^1.1.15",
49
+ "@proteinjs/service": "^1.4.1",
50
+ "@proteinjs/user-auth": "^1.2.0",
51
51
  "@proteinjs/util": "^1.6.1",
52
52
  "moment": "2.29.4",
53
53
  "uuid": "8.3.0"
package/src/Db.ts CHANGED
@@ -22,11 +22,14 @@ import { TableServiceAuth } from './auth/TableServiceAuth';
22
22
  import { TableWatcherRunner } from './TableWatcherRunner';
23
23
  import {
24
24
  DefaultTransactionContextFactory,
25
+ PostCommitHook,
25
26
  getDefaultTransactionContextFactory,
26
27
  } from './transaction/TransactionContextFactory';
27
28
  import { isInstanceOf } from '@proteinjs/util';
28
29
  import { Reference } from './reference/Reference';
29
30
  import { ReferenceArray } from './reference/ReferenceArray';
31
+ import { ArrayMembershipUpdate, applyArrayMembershipOps } from './reference/ArrayMembershipOps';
32
+ import { PreservedPath, overlayPreservedPaths } from './UpdatePreserving';
30
33
 
31
34
  /** get `Db` if on server, and `DbService` if on browser */
32
35
  export const getDb = <R extends Record = Record>() =>
@@ -204,6 +207,96 @@ export class Db<R extends Record = Record> implements DbService<R> {
204
207
  return recordUpdateCount;
205
208
  }
206
209
 
210
+ /**
211
+ * Apply commutative membership ops (add/remove/move) to a `ReferenceArrayColumn`
212
+ * read-modify-write against COMMITTED truth, so concurrent membership writers
213
+ * converge instead of last-write-wins clobbering each other (the write-side
214
+ * lost-update class). Self-wraps in a transaction when called outside one; the
215
+ * driver's abort/retry re-executes the read, so a retried transaction applies
216
+ * its ops to fresh truth rather than replaying a stale list snapshot.
217
+ *
218
+ * Returns the update count (0 when the ops are a no-op against committed truth
219
+ * or the record no longer exists — a concurrently deleted record wins).
220
+ */
221
+ async updateArrayMembership<T extends R>(table: Table<T>, update: ArrayMembershipUpdate): Promise<number> {
222
+ if (!this.currentTransaction) {
223
+ const db = this.newSelfWrapDb();
224
+ return await db.runTransaction(async () => await db.updateArrayMembership(table, update));
225
+ }
226
+
227
+ const column = (table.columns as any)[update.columnPropertyName];
228
+ if (!column || column.constructor?.name !== 'ReferenceArrayColumn') {
229
+ throw new Error(
230
+ `updateArrayMembership requires a ReferenceArrayColumn; '${update.columnPropertyName}' on table '${table.name}' is not one`
231
+ );
232
+ }
233
+
234
+ const qb = new QueryBuilderFactory().getQueryBuilder(table);
235
+ await this.addColumnQueries(table, qb, 'write');
236
+ qb.select({ fields: ['id', update.columnPropertyName] as any });
237
+ qb.condition({ field: 'id', operator: '=', value: update.recordId as T[keyof T] });
238
+ const rows = await this._query(table, qb);
239
+ if (rows.length === 0) {
240
+ return 0;
241
+ }
242
+
243
+ const currentRefArray = (rows[0] as any)[update.columnPropertyName] as ReferenceArray<any> | null | undefined;
244
+ const currentIds = currentRefArray?._ids ?? [];
245
+ const { ids, changed } = applyArrayMembershipOps(currentIds, update.ops);
246
+ if (!changed) {
247
+ return 0;
248
+ }
249
+
250
+ const record: Partial<T> = { id: update.recordId } as Partial<T>;
251
+ (record as any)[update.columnPropertyName] = new ReferenceArray((column as any).referenceTable, ids);
252
+ return await this.update(table, record);
253
+ }
254
+
255
+ /**
256
+ * Update with committed-truth preservation for column sub-paths the writer does
257
+ * not own (see `UpdatePreserving.ts`). The payload's listed paths are overlaid
258
+ * with their committed values read inside the same transaction, so this write
259
+ * commutes with the writers that own those paths (e.g. a structural editor op
260
+ * writing a JSON object's styling while a debounced text save owns `content`).
261
+ * Self-wraps in a transaction when called outside one. Plain-JSON columns only.
262
+ */
263
+ async updatePreserving<T extends R>(table: Table<T>, record: Partial<T>, preserve: PreservedPath[]): Promise<number> {
264
+ if (!this.currentTransaction) {
265
+ const db = this.newSelfWrapDb();
266
+ return await db.runTransaction(async () => await db.updatePreserving(table, record, preserve));
267
+ }
268
+
269
+ if (!record.id) {
270
+ throw new Error(`updatePreserving must be called with a record with an id property`);
271
+ }
272
+
273
+ const applicable = preserve.filter((p) => (record as any)[p.columnPropertyName] !== undefined);
274
+ if (applicable.length === 0) {
275
+ return await this.update(table, record);
276
+ }
277
+
278
+ const qb = new QueryBuilderFactory().getQueryBuilder(table);
279
+ await this.addColumnQueries(table, qb, 'write');
280
+ qb.select({ fields: ['id', ...applicable.map((p) => p.columnPropertyName)] as any });
281
+ qb.condition({ field: 'id', operator: '=', value: record.id as T[keyof T] });
282
+ const rows = await this._query(table, qb);
283
+ if (rows.length === 0) {
284
+ return 0;
285
+ }
286
+
287
+ const recordCopy: Partial<T> = Object.assign({}, record);
288
+ for (const p of applicable) {
289
+ (recordCopy as any)[p.columnPropertyName] = overlayPreservedPaths(
290
+ (rows[0] as any)[p.columnPropertyName],
291
+ (recordCopy as any)[p.columnPropertyName],
292
+ p.paths,
293
+ p.whenType
294
+ );
295
+ }
296
+
297
+ return await this.update(table, recordCopy);
298
+ }
299
+
207
300
  async delete<T extends R>(table: Table<T>, query: Query<T>): Promise<number> {
208
301
  if (!this.runAsSystem) {
209
302
  this.auth.canDelete(table);
@@ -541,11 +634,19 @@ export class Db<R extends Record = Record> implements DbService<R> {
541
634
  throw new Error(`Nested transactions are not supported. A transaction is already running on this Db instance.`);
542
635
  }
543
636
 
544
- return await this.dbDriver.runTransaction(async (transaction) => {
637
+ // Reassigned fresh per driver attempt: drivers may retry `fn` on transient aborts (Spanner's
638
+ // runTransactionAsync does), and hooks queued by a discarded attempt must not survive into
639
+ // the attempt that actually commits. Only the committed attempt's queue is drained below.
640
+ let postCommitHooks: PostCommitHook[] = [];
641
+ const result = await this.dbDriver.runTransaction(async (transaction) => {
545
642
  this.currentTransaction = transaction;
643
+ postCommitHooks = [];
546
644
 
547
645
  try {
548
646
  return await this.transactionContextFactory.runInContext(transaction, async () => {
647
+ // Seed the post-commit hook queue on the ambient context so every Db instance created
648
+ // inside this transaction shares it (see runAfterCommit).
649
+ this.transactionContextFactory.getTransactionContext().postCommitHooks = postCommitHooks;
549
650
  const result = await fn();
550
651
  return result;
551
652
  });
@@ -553,6 +654,64 @@ export class Db<R extends Record = Record> implements DbService<R> {
553
654
  this.currentTransaction = undefined;
554
655
  }
555
656
  });
657
+
658
+ // COMMIT BOUNDARY: the driver resolves only after the transaction is durably committed (and
659
+ // rejects on rollback, in which case the queue above is never drained). Hook failures are
660
+ // logged, not thrown — the write already committed, and surfacing a hook error as a
661
+ // transaction failure would report a durable write as failed.
662
+ for (const postCommitHook of postCommitHooks) {
663
+ try {
664
+ await postCommitHook();
665
+ } catch (error: any) {
666
+ this.logger.error({ message: `Post-commit hook failed`, error });
667
+ }
668
+ }
669
+
670
+ return result;
671
+ }
672
+
673
+ /**
674
+ * Run `hook` once the write unit currently executing is durably committed.
675
+ *
676
+ * - Inside a `runTransaction` scope: the hook is queued on the ambient transaction context and
677
+ * runs after the transaction COMMITS. Hooks never run on rollback.
678
+ * - Outside a transaction: every DML statement auto-commits when it resolves, so the hook runs
679
+ * (awaited) immediately. Callers are responsible for invoking this only from points where
680
+ * the triggering statement has already executed — e.g. `after*` table watchers, never
681
+ * `before*` ones.
682
+ *
683
+ * For side effects that must observe committed truth — e.g. socket notifications that trigger
684
+ * client refetches: a pre-commit emission lets a client refetch read pre-commit rows and
685
+ * "resurrect" state the transaction is deleting.
686
+ */
687
+ async runAfterCommit(hook: PostCommitHook): Promise<void> {
688
+ const transactionContext = this.transactionContextFactory.getTransactionContext();
689
+ if (transactionContext.currentTransaction) {
690
+ if (!transactionContext.postCommitHooks) {
691
+ // runTransaction is the only opener of transaction scopes and always seeds the queue —
692
+ // an active transaction without one means a second scope owner appeared. Fail loudly
693
+ // rather than run the hook at (wrong) pre-commit time.
694
+ throw new Error(
695
+ `Active transaction context has no post-commit hook queue. Transaction scopes must be opened via Db.runTransaction.`
696
+ );
697
+ }
698
+ transactionContext.postCommitHooks.push(hook);
699
+ return;
700
+ }
701
+
702
+ await hook();
703
+ }
704
+
705
+ /**
706
+ * A fresh instance for self-wrapping RMW verbs (`updateArrayMembership`, `updatePreserving`)
707
+ * in a transaction. `runTransaction` carries the open transaction as INSTANCE state, and these
708
+ * verbs also serve the `DbService` RPC path, where one long-lived Db instance handles
709
+ * concurrent requests — self-wrapping on `this` would leak one request's transaction into
710
+ * another's ops (or spuriously reject it as a nested transaction). Same driver/tables/system
711
+ * mode; only the transaction state is isolated.
712
+ */
713
+ private newSelfWrapDb(): Db<R> {
714
+ return new Db<R>(this.dbDriver, this.getTable, this.transactionContextFactory, this.runAsSystem);
556
715
  }
557
716
 
558
717
  // Utility: simple chunker
@@ -13,6 +13,12 @@ export const getMigrationRunner = () =>
13
13
  export class MigrationRunner implements MigrationRunnerService {
14
14
  private logger = new Logger({ name: this.constructor.name });
15
15
  public serviceMetadata: Service['serviceMetadata'] = {
16
+ // Explicit, not implicit: running migrations is admin-only. Omitting the auth block would
17
+ // fall back to the same default-deny (admin role), but every service is required to declare
18
+ // its auth intent explicitly.
19
+ auth: {
20
+ roles: ['admin'],
21
+ },
16
22
  doNotAwait: true,
17
23
  };
18
24
 
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Support for `Db.updatePreserving` / `Transaction.updatePreserving`: an update
3
+ * whose payload carries a column value the writer only PARTIALLY owns — the
4
+ * listed sub-paths belong to other writers, so their committed values are
5
+ * preserved (overlaid into the payload read-modify-write) instead of being
6
+ * clobbered by whatever stale copy the writer's snapshot happened to hold.
7
+ *
8
+ * The motivating case: a structural editor operation changes non-text fields of
9
+ * a JSON object column (styling, child overrides) while a debounced text save
10
+ * owns the object's text content. The structural payload's `content` is stale
11
+ * by construction (text syncs into the record at save time, not per keystroke);
12
+ * writing it would erase a concurrently committed text save. Preserving
13
+ * `content` from the committed row makes the two writers commute.
14
+ */
15
+
16
+ export type PreservedPath = {
17
+ /** Property name of the column whose payload value should have paths preserved. */
18
+ columnPropertyName: string;
19
+ /** Dot-separated paths inside the column value (plain-JSON columns only). */
20
+ paths: string[];
21
+ /**
22
+ * Only overlay a committed value whose `typeof` matches. Guards shape
23
+ * transitions: when the op legitimately changes the column's SHAPE (e.g. a
24
+ * text object becoming a composite wrapper), a committed value of a
25
+ * different type must not be dragged into the new shape.
26
+ */
27
+ whenType?: 'string' | 'number' | 'boolean' | 'object';
28
+ };
29
+
30
+ /**
31
+ * Overlay committed values at `paths` into a clone of `incomingValue`.
32
+ * Pure: returns the (possibly cloned) value to write. Only plain-JSON values
33
+ * are supported (the clone is JSON-based).
34
+ */
35
+ export function overlayPreservedPaths(
36
+ committedValue: unknown,
37
+ incomingValue: unknown,
38
+ paths: string[],
39
+ whenType?: PreservedPath['whenType']
40
+ ): unknown {
41
+ if (incomingValue == null || committedValue == null) {
42
+ return incomingValue;
43
+ }
44
+
45
+ let result: unknown | undefined;
46
+ for (const path of paths) {
47
+ const committedAtPath = deepGet(committedValue, path);
48
+ if (committedAtPath === undefined) {
49
+ continue;
50
+ }
51
+ if (whenType && typeof committedAtPath !== whenType) {
52
+ continue;
53
+ }
54
+ if (deepGet(result ?? incomingValue, path) === committedAtPath) {
55
+ continue;
56
+ }
57
+ if (result === undefined) {
58
+ result = JSON.parse(JSON.stringify(incomingValue));
59
+ }
60
+ deepSet(result, path, committedAtPath);
61
+ }
62
+
63
+ return result === undefined ? incomingValue : result;
64
+ }
65
+
66
+ function deepGet(value: unknown, path: string): unknown {
67
+ let current: any = value;
68
+ for (const key of path.split('.')) {
69
+ if (current == null || typeof current !== 'object') {
70
+ return undefined;
71
+ }
72
+ current = current[key];
73
+ }
74
+ return current;
75
+ }
76
+
77
+ function deepSet(value: unknown, path: string, pathValue: unknown): void {
78
+ const keys = path.split('.');
79
+ let current: any = value;
80
+ for (let i = 0; i < keys.length - 1; i++) {
81
+ const next = current?.[keys[i]];
82
+ if (next == null || typeof next !== 'object') {
83
+ // The incoming value doesn't have this path's parent — nothing to preserve into.
84
+ return;
85
+ }
86
+ current = next;
87
+ }
88
+ current[keys[keys.length - 1]] = pathValue;
89
+ }
@@ -20,6 +20,19 @@ export type TableOperationsAuth = {
20
20
  delete?: Identity;
21
21
  };
22
22
 
23
+ /**
24
+ * A table-auth denial. The message names the table and operation and is safe to show the caller
25
+ * (it only echoes what they asked for). Name-tagged rather than relying on instanceof — the
26
+ * prototype chain is unreliable across package compile targets (same reason ServiceRouter's
27
+ * isServiceError checks `name`).
28
+ */
29
+ export class TableAuthError extends Error {
30
+ constructor(message: string) {
31
+ super(message);
32
+ this.name = 'TableAuthError';
33
+ }
34
+ }
35
+
23
36
  /**
24
37
  * Util to check which table operations a user can perform
25
38
  */
@@ -46,25 +59,25 @@ export class TableAuth {
46
59
 
47
60
  canQuery(table: Table<any>, api: 'db' | 'service' = 'db'): void {
48
61
  if (!this.canAccess(table, api, 'query')) {
49
- throw new Error(`User is not authorized to query table: ${table.name}`);
62
+ throw new TableAuthError(`User is not authorized to query table: ${table.name}`);
50
63
  }
51
64
  }
52
65
 
53
66
  canInsert(table: Table<any>, api: 'db' | 'service' = 'db'): void {
54
67
  if (!this.canAccess(table, api, 'insert')) {
55
- throw new Error(`User is not authorized to insert records into table: ${table.name}`);
68
+ throw new TableAuthError(`User is not authorized to insert records into table: ${table.name}`);
56
69
  }
57
70
  }
58
71
 
59
72
  canUpdate(table: Table<any>, api: 'db' | 'service' = 'db'): void {
60
73
  if (!this.canAccess(table, api, 'update')) {
61
- throw new Error(`User is not authorized to update records in table: ${table.name}`);
74
+ throw new TableAuthError(`User is not authorized to update records in table: ${table.name}`);
62
75
  }
63
76
  }
64
77
 
65
78
  canDelete(table: Table<any>, api: 'db' | 'service' = 'db'): void {
66
79
  if (!this.canAccess(table, api, 'delete')) {
67
- throw new Error(`User is not authorized to delete records from table: ${table.name}`);
80
+ throw new TableAuthError(`User is not authorized to delete records from table: ${table.name}`);
68
81
  }
69
82
  }
70
83
  }
@@ -1,7 +1,7 @@
1
1
  import { Logger } from '@proteinjs/logger';
2
2
  import { ServiceError } from '@proteinjs/service';
3
3
  import { Table, isTable } from '../Table';
4
- import { TableAuth } from './TableAuth';
4
+ import { TableAuth, TableAuthError } from './TableAuth';
5
5
 
6
6
  export class TableServiceAuth {
7
7
  private logger = new Logger({ name: this.constructor.name });
@@ -22,6 +22,15 @@ export class TableServiceAuth {
22
22
  } else if (methodName === 'update') {
23
23
  tableAuth.canUpdate(table as Table<any>, 'service');
24
24
  this.checkServiceProtectedColumns(table as Table<any>, args[1]);
25
+ } else if (methodName === 'updateArrayMembership') {
26
+ // An update in RMW clothing: gated exactly like `update`. Row visibility is enforced
27
+ // inside the operation itself — scoped/column query injection runs on the server-side
28
+ // read-modify-write, so a caller can only touch rows they could already update.
29
+ tableAuth.canUpdate(table as Table<any>, 'service');
30
+ this.checkArrayMembershipProtectedColumn(table as Table<any>, args[1]);
31
+ } else if (methodName === 'updatePreserving') {
32
+ tableAuth.canUpdate(table as Table<any>, 'service');
33
+ this.checkServiceProtectedColumns(table as Table<any>, args[1]);
25
34
  } else if (methodName === 'delete') {
26
35
  tableAuth.canDelete(table as Table<any>, 'service');
27
36
  } else {
@@ -35,6 +44,13 @@ export class TableServiceAuth {
35
44
  if (error?.name === 'ServiceError') {
36
45
  throw error;
37
46
  }
47
+ // A table-auth denial is equally client-safe (it only names the table/operation the caller
48
+ // asked for). Cross the wire verbatim so the caller sees "not authorized to query table:
49
+ // session" rather than the generic run-service denial — a denied query must be
50
+ // distinguishable from an empty one at the surface that shows it.
51
+ if (error?.name === 'TableAuthError') {
52
+ throw new ServiceError(error.message);
53
+ }
38
54
  this.logger.error({ message: `Failed evaluating auth for method: ${methodName}`, error });
39
55
  return false;
40
56
  }
@@ -61,4 +77,23 @@ export class TableServiceAuth {
61
77
  }
62
78
  }
63
79
  }
80
+
81
+ /**
82
+ * `updateArrayMembership`'s payload names its target column (`columnPropertyName`) instead of
83
+ * carrying record fields, so the record-shaped protected-column check above can't see the
84
+ * write. Membership ops always SET real membership — there is no null-clearing form — so
85
+ * targeting a protected column is rejected outright.
86
+ */
87
+ private checkArrayMembershipProtectedColumn(table: Table<any>, update: any): void {
88
+ const protectedColumns: string[] | undefined = table.auth?.serviceProtectedColumns;
89
+ if (!protectedColumns?.length || !update || typeof update !== 'object') {
90
+ return;
91
+ }
92
+
93
+ if (protectedColumns.includes(update.columnPropertyName)) {
94
+ throw new ServiceError(
95
+ `Column '${update.columnPropertyName}' cannot be written via the db service on table: ${table.name}`
96
+ );
97
+ }
98
+ }
64
99
  }
@@ -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>;