@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
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.27.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"
@@ -65,5 +65,6 @@
65
65
  "jest": "29.7.0",
66
66
  "ts-jest": "29.1.1",
67
67
  "typescript": "5.2.2"
68
- }
68
+ },
69
+ "gitHead": "020e0d6b909cdd60925952aa05c23cfdecd8353d"
69
70
  }
package/src/Db.ts CHANGED
@@ -22,11 +22,15 @@ import { TableServiceAuth } from './auth/TableServiceAuth';
22
22
  import { TableWatcherRunner } from './TableWatcherRunner';
23
23
  import {
24
24
  DefaultTransactionContextFactory,
25
+ PostCommitHook,
25
26
  getDefaultTransactionContextFactory,
27
+ TransactionContextData,
26
28
  } from './transaction/TransactionContextFactory';
27
29
  import { isInstanceOf } from '@proteinjs/util';
28
30
  import { Reference } from './reference/Reference';
29
31
  import { ReferenceArray } from './reference/ReferenceArray';
32
+ import { ArrayMembershipUpdate, applyArrayMembershipOps } from './reference/ArrayMembershipOps';
33
+ import { PreservedPath, overlayPreservedPaths } from './UpdatePreserving';
30
34
 
31
35
  /** get `Db` if on server, and `DbService` if on browser */
32
36
  export const getDb = <R extends Record = Record>() =>
@@ -73,7 +77,6 @@ export class Db<R extends Record = Record> implements DbService<R> {
73
77
  private statementConfigFactory: StatementConfigFactory;
74
78
  private auth = new TableAuth();
75
79
  private tableWatcherRunner = new TableWatcherRunner<R>();
76
- private currentTransaction?: any;
77
80
  private transactionContextFactory: DefaultTransactionContextFactory;
78
81
  public serviceMetadata: Service['serviceMetadata'] = {
79
82
  auth: {
@@ -93,10 +96,6 @@ export class Db<R extends Record = Record> implements DbService<R> {
93
96
  this.transactionContextFactory = transactionContextFactory
94
97
  ? transactionContextFactory
95
98
  : this.getDefaultTransactionContextFactory();
96
- const transactionContext = this.transactionContextFactory.getTransactionContext();
97
- if (transactionContext.currentTransaction) {
98
- this.currentTransaction = transactionContext.currentTransaction;
99
- }
100
99
  }
101
100
 
102
101
  static getDefaultDbDriver(): DbDriver {
@@ -156,7 +155,7 @@ export class Db<R extends Record = Record> implements DbService<R> {
156
155
  serializedRecord as Partial<T>,
157
156
  this.statementConfigFactory.getStatementConfig(config)
158
157
  );
159
- await this.dbDriver.runDml(generateInsert, this.currentTransaction);
158
+ await this.dbDriver.runDml(generateInsert, this.transactionForDriver());
160
159
  await this.tableWatcherRunner.runAfterInsertTableWatchers(table, recordCopy as T);
161
160
  return recordCopy as T;
162
161
  }
@@ -199,11 +198,101 @@ export class Db<R extends Record = Record> implements DbService<R> {
199
198
  qb,
200
199
  this.statementConfigFactory.getStatementConfig(config)
201
200
  );
202
- const recordUpdateCount = await this.dbDriver.runDml(generateUpdate, this.currentTransaction);
201
+ const recordUpdateCount = await this.dbDriver.runDml(generateUpdate, this.transactionForDriver());
203
202
  await this.tableWatcherRunner.runAfterUpdateTableWatchers(table, recordUpdateCount, recordCopy, qb);
204
203
  return recordUpdateCount;
205
204
  }
206
205
 
206
+ /**
207
+ * Apply commutative membership ops (add/remove/move) to a `ReferenceArrayColumn`
208
+ * read-modify-write against COMMITTED truth, so concurrent membership writers
209
+ * converge instead of last-write-wins clobbering each other (the write-side
210
+ * lost-update class). Self-wraps in a transaction when called outside one; the
211
+ * driver's abort/retry re-executes the read, so a retried transaction applies
212
+ * its ops to fresh truth rather than replaying a stale list snapshot.
213
+ *
214
+ * Returns the update count (0 when the ops are a no-op against committed truth
215
+ * or the record no longer exists — a concurrently deleted record wins).
216
+ */
217
+ async updateArrayMembership<T extends R>(table: Table<T>, update: ArrayMembershipUpdate): Promise<number> {
218
+ if (!this.transactionContextFactory.getTransactionContext().currentTransaction) {
219
+ const db = this.newSelfWrapDb();
220
+ return await db.runTransaction(async () => await db.updateArrayMembership(table, update));
221
+ }
222
+
223
+ const column = (table.columns as any)[update.columnPropertyName];
224
+ if (!column || column.constructor?.name !== 'ReferenceArrayColumn') {
225
+ throw new Error(
226
+ `updateArrayMembership requires a ReferenceArrayColumn; '${update.columnPropertyName}' on table '${table.name}' is not one`
227
+ );
228
+ }
229
+
230
+ const qb = new QueryBuilderFactory().getQueryBuilder(table);
231
+ await this.addColumnQueries(table, qb, 'write');
232
+ qb.select({ fields: ['id', update.columnPropertyName] as any });
233
+ qb.condition({ field: 'id', operator: '=', value: update.recordId as T[keyof T] });
234
+ const rows = await this._query(table, qb);
235
+ if (rows.length === 0) {
236
+ return 0;
237
+ }
238
+
239
+ const currentRefArray = (rows[0] as any)[update.columnPropertyName] as ReferenceArray<any> | null | undefined;
240
+ const currentIds = currentRefArray?._ids ?? [];
241
+ const { ids, changed } = applyArrayMembershipOps(currentIds, update.ops);
242
+ if (!changed) {
243
+ return 0;
244
+ }
245
+
246
+ const record: Partial<T> = { id: update.recordId } as Partial<T>;
247
+ (record as any)[update.columnPropertyName] = new ReferenceArray((column as any).referenceTable, ids);
248
+ return await this.update(table, record);
249
+ }
250
+
251
+ /**
252
+ * Update with committed-truth preservation for column sub-paths the writer does
253
+ * not own (see `UpdatePreserving.ts`). The payload's listed paths are overlaid
254
+ * with their committed values read inside the same transaction, so this write
255
+ * commutes with the writers that own those paths (e.g. a structural editor op
256
+ * writing a JSON object's styling while a debounced text save owns `content`).
257
+ * Self-wraps in a transaction when called outside one. Plain-JSON columns only.
258
+ */
259
+ async updatePreserving<T extends R>(table: Table<T>, record: Partial<T>, preserve: PreservedPath[]): Promise<number> {
260
+ if (!this.transactionContextFactory.getTransactionContext().currentTransaction) {
261
+ const db = this.newSelfWrapDb();
262
+ return await db.runTransaction(async () => await db.updatePreserving(table, record, preserve));
263
+ }
264
+
265
+ if (!record.id) {
266
+ throw new Error(`updatePreserving must be called with a record with an id property`);
267
+ }
268
+
269
+ const applicable = preserve.filter((p) => (record as any)[p.columnPropertyName] !== undefined);
270
+ if (applicable.length === 0) {
271
+ return await this.update(table, record);
272
+ }
273
+
274
+ const qb = new QueryBuilderFactory().getQueryBuilder(table);
275
+ await this.addColumnQueries(table, qb, 'write');
276
+ qb.select({ fields: ['id', ...applicable.map((p) => p.columnPropertyName)] as any });
277
+ qb.condition({ field: 'id', operator: '=', value: record.id as T[keyof T] });
278
+ const rows = await this._query(table, qb);
279
+ if (rows.length === 0) {
280
+ return 0;
281
+ }
282
+
283
+ const recordCopy: Partial<T> = Object.assign({}, record);
284
+ for (const p of applicable) {
285
+ (recordCopy as any)[p.columnPropertyName] = overlayPreservedPaths(
286
+ (rows[0] as any)[p.columnPropertyName],
287
+ (recordCopy as any)[p.columnPropertyName],
288
+ p.paths,
289
+ p.whenType
290
+ );
291
+ }
292
+
293
+ return await this.update(table, recordCopy);
294
+ }
295
+
207
296
  async delete<T extends R>(table: Table<T>, query: Query<T>): Promise<number> {
208
297
  if (!this.runAsSystem) {
209
298
  this.auth.canDelete(table);
@@ -223,7 +312,7 @@ export class Db<R extends Record = Record> implements DbService<R> {
223
312
  new StatementFactory<T>().delete(table.name, deleteQb, this.statementConfigFactory.getStatementConfig(config));
224
313
  await this.runColumnBeforeDeletes(table, recordsToDelete);
225
314
  await this.tableWatcherRunner.runBeforeDeleteTableWatchers(table, recordsToDelete, qb, deleteQb);
226
- const recordDeleteCount = await this.dbDriver.runDml(generateDelete, this.currentTransaction);
315
+ const recordDeleteCount = await this.dbDriver.runDml(generateDelete, this.transactionForDriver());
227
316
  await this.runCascadeDeletions(table, recordsToDelete);
228
317
  await this.runColumnReverseCascadeDeletions(table, recordsToDelete);
229
318
  await this.tableWatcherRunner.runAfterDeleteTableWatchers(table, recordDeleteCount, recordsToDelete, qb, deleteQb);
@@ -443,7 +532,7 @@ export class Db<R extends Record = Record> implements DbService<R> {
443
532
 
444
533
  const generateQuery = (config: DbDriverQueryStatementConfig) =>
445
534
  qb.toSql(this.statementConfigFactory.getStatementConfig(config));
446
- const serializedRecords = await this.dbDriver.runQuery(generateQuery, this.currentTransaction);
535
+ const serializedRecords = await this.dbDriver.runQuery(generateQuery, this.transactionForDriver());
447
536
  const recordSerializer = new RecordSerializer(table);
448
537
  const records = await Promise.all(
449
538
  serializedRecords.map(async (serializedRecord) => recordSerializer.deserialize(serializedRecord))
@@ -486,7 +575,7 @@ export class Db<R extends Record = Record> implements DbService<R> {
486
575
  await this.addColumnQueries(table, qb);
487
576
  const generateQuery = (config: DbDriverQueryStatementConfig) =>
488
577
  qb.toSql(this.statementConfigFactory.getStatementConfig(config));
489
- const result = await this.dbDriver.runQuery(generateQuery, this.currentTransaction);
578
+ const result = await this.dbDriver.runQuery(generateQuery, this.transactionForDriver());
490
579
  return result[0]['count'];
491
580
  }
492
581
 
@@ -515,13 +604,19 @@ export class Db<R extends Record = Record> implements DbService<R> {
515
604
  /**
516
605
  * Run a transaction.
517
606
  *
518
- * Use this db instance for any operation you want to include in the transaction.
519
- *
520
- * Note: This method uses Db instance state. Usually it is best to create a new instance
521
- * of Db to run a transaction.
607
+ * Db instances are STATELESS with respect to transactions: every operation resolves the
608
+ * ambient transaction (AsyncLocalStorage) at call time, so any Db instance used inside the
609
+ * transaction body whenever it was constructed rides the transaction. There is no way
610
+ * to issue an operation outside a transaction from inside its body, and no second session
611
+ * is ever acquired inside one (the historical pool-wedge class is unrepresentable —
612
+ * plans/DB_PERF_PLAN.md P2, in the consumer repo).
522
613
  *
523
614
  * Note: Nested transactions are not supported; will throw.
524
615
  *
616
+ * Note: work spawned inside the body but NOT awaited by it escapes the transaction's
617
+ * lifetime while still holding its context — such work fails loudly on its next db
618
+ * operation (see TransactionContextData.ended). Await everything inside the body, or run
619
+ * it outside the transaction.
525
620
  *
526
621
  * Example:
527
622
  *
@@ -537,22 +632,105 @@ export class Db<R extends Record = Record> implements DbService<R> {
537
632
  * ```
538
633
  */
539
634
  async runTransaction<T>(fn: () => Promise<T>): Promise<T> {
540
- if (this.currentTransaction) {
541
- throw new Error(`Nested transactions are not supported. A transaction is already running on this Db instance.`);
635
+ if (this.transactionContextFactory.getTransactionContext().currentTransaction) {
636
+ throw new Error(`Nested transactions are not supported. A transaction is already running in this context.`);
542
637
  }
543
638
 
544
- return await this.dbDriver.runTransaction(async (transaction) => {
545
- this.currentTransaction = transaction;
546
-
639
+ // Reassigned fresh per driver attempt: drivers may retry `fn` on transient aborts (Spanner's
640
+ // runTransactionAsync does), and hooks queued by a discarded attempt must not survive into
641
+ // the attempt that actually commits. Only the committed attempt's queue is drained below.
642
+ let postCommitHooks: PostCommitHook[] = [];
643
+ const result = await this.dbDriver.runTransaction(async (transaction) => {
644
+ // A fresh ambient store per attempt: the transaction, the attempt's post-commit hook
645
+ // queue (shared by every Db instance created inside — see runAfterCommit), and the
646
+ // ended-tombstone below.
647
+ postCommitHooks = [];
648
+ const contextData: TransactionContextData = { currentTransaction: transaction, postCommitHooks };
547
649
  try {
548
- return await this.transactionContextFactory.runInContext(transaction, async () => {
549
- const result = await fn();
550
- return result;
551
- });
650
+ return await this.transactionContextFactory.runInContext(contextData, fn);
552
651
  } finally {
553
- this.currentTransaction = undefined;
652
+ // Tombstone the store: detached work spawned inside the body still holds it by
653
+ // reference — the flag turns its next db operation into a loud error instead of a
654
+ // silent op on a finished transaction (see transactionForDriver).
655
+ contextData.ended = true;
554
656
  }
555
657
  });
658
+
659
+ // COMMIT BOUNDARY: the driver resolves only after the transaction is durably committed (and
660
+ // rejects on rollback, in which case the queue above is never drained). Hook failures are
661
+ // logged, not thrown — the write already committed, and surfacing a hook error as a
662
+ // transaction failure would report a durable write as failed.
663
+ for (const postCommitHook of postCommitHooks) {
664
+ try {
665
+ await postCommitHook();
666
+ } catch (error: any) {
667
+ this.logger.error({ message: `Post-commit hook failed`, error });
668
+ }
669
+ }
670
+
671
+ return result;
672
+ }
673
+
674
+ /**
675
+ * Run `hook` once the write unit currently executing is durably committed.
676
+ *
677
+ * - Inside a `runTransaction` scope: the hook is queued on the ambient transaction context and
678
+ * runs after the transaction COMMITS. Hooks never run on rollback.
679
+ * - Outside a transaction: every DML statement auto-commits when it resolves, so the hook runs
680
+ * (awaited) immediately. Callers are responsible for invoking this only from points where
681
+ * the triggering statement has already executed — e.g. `after*` table watchers, never
682
+ * `before*` ones.
683
+ *
684
+ * For side effects that must observe committed truth — e.g. socket notifications that trigger
685
+ * client refetches: a pre-commit emission lets a client refetch read pre-commit rows and
686
+ * "resurrect" state the transaction is deleting.
687
+ */
688
+ async runAfterCommit(hook: PostCommitHook): Promise<void> {
689
+ const transactionContext = this.transactionContextFactory.getTransactionContext();
690
+ if (transactionContext.currentTransaction) {
691
+ if (!transactionContext.postCommitHooks) {
692
+ // runTransaction is the only opener of transaction scopes and always seeds the queue —
693
+ // an active transaction without one means a second scope owner appeared. Fail loudly
694
+ // rather than run the hook at (wrong) pre-commit time.
695
+ throw new Error(
696
+ `Active transaction context has no post-commit hook queue. Transaction scopes must be opened via Db.runTransaction.`
697
+ );
698
+ }
699
+ transactionContext.postCommitHooks.push(hook);
700
+ return;
701
+ }
702
+
703
+ await hook();
704
+ }
705
+
706
+ /**
707
+ * A fresh instance for self-wrapping RMW verbs (`updateArrayMembership`, `updatePreserving`)
708
+ * in a transaction. `runTransaction` carries the open transaction as INSTANCE state, and these
709
+ * verbs also serve the `DbService` RPC path, where one long-lived Db instance handles
710
+ * concurrent requests — self-wrapping on `this` would leak one request's transaction into
711
+ * another's ops (or spuriously reject it as a nested transaction). Same driver/tables/system
712
+ * mode; only the transaction state is isolated.
713
+ */
714
+ private newSelfWrapDb(): Db<R> {
715
+ return new Db<R>(this.dbDriver, this.getTable, this.transactionContextFactory, this.runAsSystem);
716
+ }
717
+
718
+ /**
719
+ * The transaction every driver call rides, resolved from the ambient context AT CALL TIME
720
+ * (statelessness is the safety property: operations inside a transaction body always ride
721
+ * it; operations outside always use the pool). The one remaining failure shape — work that
722
+ * escaped a finished transaction's body while holding its context — throws here by name;
723
+ * handing the driver an ended transaction would fail anyway, with a far worse error.
724
+ */
725
+ private transactionForDriver(): any {
726
+ const context = this.transactionContextFactory.getTransactionContext();
727
+ if (context.ended) {
728
+ throw new Error(
729
+ `Db operation issued in the context of a transaction that already ended: work spawned inside a runTransaction body (and not awaited by it) survived the transaction. Await the work inside the body, or run it outside the transaction.`
730
+ );
731
+ }
732
+
733
+ return context.currentTransaction;
556
734
  }
557
735
 
558
736
  // 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
  }