@proteinjs/db 1.47.3 → 1.48.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proteinjs/db",
3
- "version": "1.47.3",
3
+ "version": "1.48.0",
4
4
  "main": "./dist/generated/index.js",
5
5
  "types": "./dist/generated/index.d.ts",
6
6
  "exports": {
@@ -66,5 +66,5 @@
66
66
  "ts-jest": "29.1.1",
67
67
  "typescript": "5.2.2"
68
68
  },
69
- "gitHead": "08154afae17a9a19c9e48520a3dd67ead7b997c6"
69
+ "gitHead": "ec3fd54430dde18afd406ec70eccd857492272a0"
70
70
  }
package/src/Db.ts CHANGED
@@ -95,6 +95,15 @@ export interface DbDriver {
95
95
  ): Promise<SerializedRecord[]>;
96
96
  runDml(generateStatement: (config: DbDriverDmlStatementConfig) => Statement, transaction?: any): Promise<number>;
97
97
  runTransaction<T>(fn: (transaction: any) => Promise<T>): Promise<T>;
98
+ /**
99
+ * The driver's per-operation deadline, in milliseconds: the longest one statement it runs may
100
+ * take before the driver fails it — the bound the driver enforces, as configured. Not a whole
101
+ * transaction's budget (a transaction the database aborts and the driver re-runs may take
102
+ * longer in all). Code that waits on another request's write — a row it depends on whose insert
103
+ * may still be in flight — sizes its wait by this, so the wait follows what the deployment
104
+ * configured its database to tolerate rather than a number of its own.
105
+ */
106
+ getOperationDeadlineMs(): number;
98
107
  }
99
108
 
100
109
  export class Db<R extends Record = Record> implements DbService<R> {
@@ -5,6 +5,26 @@ export const getTransactionRunnerService = serviceFactory<TransactionRunnerServi
5
5
  '@proteinjs/db/TransactionRunnerService'
6
6
  );
7
7
 
8
+ /**
9
+ * What a write declares about the rows it depends on.
10
+ *
11
+ * A client that releases a queued write because its page is going away cannot order that write
12
+ * behind the writes it depends on — the page is gone before their answers arrive. The server
13
+ * outlives the page, so the write DECLARES its dependencies and the server honours them.
14
+ */
15
+ export type TransactionRunOptions = {
16
+ /**
17
+ * Ids of rows the operations depend on that may not exist yet: the row an update or a
18
+ * membership update targets while its insert is still in flight, the permission source a
19
+ * child row attaches to while the root's own insert (and the owner grant that commits with
20
+ * it) is still landing. Every id must be a row the operations themselves reference. The
21
+ * runner waits, bounded, for each to exist AND be visible to the caller before running the
22
+ * operations; a row that never appears fails the request with a plain clause — never a
23
+ * silent zero-row write, never a hang.
24
+ */
25
+ afterRows?: string[];
26
+ };
27
+
8
28
  export interface TransactionRunnerService extends Service {
9
- run(ops: Operation<any>[]): Promise<void>;
29
+ run(ops: Operation<any>[], options?: TransactionRunOptions): Promise<void>;
10
30
  }
@@ -1,6 +1,7 @@
1
1
  import { Record } from '../Record';
2
2
  import { DbService, ObjectQuery } from '../services/DbService';
3
3
  import { getTransactionRunner } from './TransactionRunner';
4
+ import { TransactionRunOptions } from '../services/TransactionRunnerService';
4
5
  import { addDefaultFieldValues, Table } from '../Table';
5
6
  import { isInstanceOf } from '@proteinjs/util';
6
7
  import { Condition, QueryBuilder } from '@proteinjs/db-query';
@@ -269,14 +270,22 @@ export class Transaction implements OperationQueue {
269
270
 
270
271
  /**
271
272
  * Run the operations in order (not a batch), as a single transaction.
273
+ *
274
+ * `options.afterRows` declares rows the operations depend on that may not exist yet (see
275
+ * {@link TransactionRunOptions}); the server waits for them before running. A run with nothing
276
+ * to declare hands the runner the operations alone — the request is the one it always was.
272
277
  */
273
- async run(): Promise<void> {
278
+ async run(options?: TransactionRunOptions): Promise<void> {
274
279
  if (this.ops.length === 0) {
275
280
  return;
276
281
  }
277
282
 
278
283
  const runner = getTransactionRunner();
279
- await runner.run(this.ops);
284
+ if (options?.afterRows && options.afterRows.length > 0) {
285
+ await runner.run(this.ops, { afterRows: options.afterRows });
286
+ } else {
287
+ await runner.run(this.ops);
288
+ }
280
289
  this.ops = [];
281
290
  }
282
291
  }
@@ -1,10 +1,32 @@
1
- import { getDb } from '../Db';
2
- import { TransactionRunnerService, getTransactionRunnerService } from '../services/TransactionRunnerService';
1
+ import { Logger } from '@proteinjs/logger';
2
+ import { Db, getDb } from '../Db';
3
+ import { Table, tableByName } from '../Table';
4
+ import { getDefaultTransactionContextFactory } from './TransactionContextFactory';
5
+ import {
6
+ TransactionRunnerService,
7
+ TransactionRunOptions,
8
+ getTransactionRunnerService,
9
+ } from '../services/TransactionRunnerService';
3
10
  import { Operation } from './Transaction';
4
11
 
5
12
  export const getTransactionRunner = () =>
6
13
  typeof self === 'undefined' ? new TransactionRunner() : (getTransactionRunnerService() as TransactionRunner);
7
14
 
15
+ /**
16
+ * Runs a client `Transaction`'s operations as one server-side transaction.
17
+ *
18
+ * A write may DECLARE the rows it depends on (`options.afterRows`): rows its operations
19
+ * reference whose own insert may still be in flight when this request arrives — a text update
20
+ * released by a page that was going away while its row's insert was still being served, a child
21
+ * row's insert whose root (its permission source, and the owner grant that commits with the
22
+ * root) is still landing. Without the declaration such a write ran to completion inside the
23
+ * insert's window and matched nothing, silently, or was refused at the door for a grant that was
24
+ * a moment from existing — and the page that could have retried was gone. The server outlives
25
+ * the page: it waits, bounded by the db driver's per-operation deadline, for every declared row to
26
+ * exist AND be visible to the caller (a read as the caller sees a row exactly when the caller's
27
+ * grant on its scope has committed), then runs the operations. A row that never appears fails the
28
+ * request with a plain clause.
29
+ */
8
30
  export class TransactionRunner implements TransactionRunnerService {
9
31
  public serviceMetadata = {
10
32
  auth: {
@@ -12,8 +34,17 @@ export class TransactionRunner implements TransactionRunnerService {
12
34
  },
13
35
  };
14
36
 
15
- async run(ops: Operation<any>[]): Promise<void> {
37
+ private static readonly FIRST_POLL_INTERVAL_MS = 25;
38
+ private static readonly MAX_POLL_INTERVAL_MS = 250;
39
+
40
+ private logger = new Logger({ name: this.constructor.name });
41
+
42
+ async run(ops: Operation<any>[], options?: TransactionRunOptions): Promise<void> {
16
43
  const db = getDb();
44
+ const afterRows = options?.afterRows ?? [];
45
+ if (afterRows.length > 0) {
46
+ await this.awaitRows(ops, afterRows);
47
+ }
17
48
 
18
49
  await db.runTransaction(async () => {
19
50
  for (const op of ops) {
@@ -21,4 +52,144 @@ export class TransactionRunner implements TransactionRunnerService {
21
52
  }
22
53
  });
23
54
  }
55
+
56
+ /**
57
+ * Wait until every declared row exists and is visible to the caller, bounded by the db driver's
58
+ * per-operation deadline (`DbDriver.getOperationDeadlineMs`), read when the write waits. The
59
+ * bound is the ceiling for a row that NEVER comes — the named failure, never a hang. A
60
+ * dependency's insert lands in hundreds of milliseconds on a healthy link and in seconds on a
61
+ * degraded one (a root's birth of a few dozen sequential statements has taken 13 s), and the
62
+ * writes released behind it by a page that is gone have no second chance. The driver's deadline
63
+ * is how long the deployment lets one statement of that insert run before failing it: the wait
64
+ * takes its measure from there — one owner for the number, configured with the database — not
65
+ * from a number of its own. A write that waits here holds one request and a poll, nothing else.
66
+ *
67
+ * Each poll is a single-use strong read outside any transaction, so a commit is seen the moment
68
+ * it lands; the interval starts short and backs off so a dependency landing in hundreds of
69
+ * milliseconds is caught within tens.
70
+ */
71
+ private async awaitRows(ops: Operation<any>[], afterRows: string[]): Promise<void> {
72
+ if (getDefaultTransactionContextFactory()?.getTransactionContext().currentTransaction) {
73
+ throw new Error(
74
+ `TransactionRunner: afterRows cannot be honoured inside an ambient transaction — its reads would see the transaction's own snapshot, never a row committed after it began`
75
+ );
76
+ }
77
+
78
+ const tablesById = TransactionRunner.rowsTheOperationsReference(ops);
79
+ const unknown = afterRows.filter((id) => !tablesById.has(id));
80
+ if (unknown.length > 0) {
81
+ throw new Error(
82
+ `TransactionRunner: afterRows names rows none of the operations reference (${unknown.join(', ')}) — the write was not run`
83
+ );
84
+ }
85
+
86
+ const db = getDb();
87
+ const boundMs = Db.getDefaultDbDriver().getOperationDeadlineMs();
88
+ const started = Date.now();
89
+ const deadline = started + boundMs;
90
+ /** Per declared id, the (table, id) rows still absent. */
91
+ let waiting: { id: string; tables: Table<any>[] }[] = afterRows
92
+ .filter((id, index) => afterRows.indexOf(id) === index)
93
+ .map((id) => ({ id, tables: tablesById.get(id)!.slice() }));
94
+ let interval = TransactionRunner.FIRST_POLL_INTERVAL_MS;
95
+ let polls = 0;
96
+ for (;;) {
97
+ polls += 1;
98
+ for (const row of waiting) {
99
+ const stillAbsent: Table<any>[] = [];
100
+ for (const table of row.tables) {
101
+ if (!(await db.get(table, { id: row.id } as any))) {
102
+ stillAbsent.push(table);
103
+ }
104
+ }
105
+ row.tables = stillAbsent;
106
+ }
107
+ waiting = waiting.filter((row) => row.tables.length > 0);
108
+ if (waiting.length === 0) {
109
+ const waitedMs = Date.now() - started;
110
+ // One line when the seam did work (the row landed while this write waited); a row that
111
+ // was already there is the common case and stays quiet.
112
+ const line = { message: `Declared rows present`, obj: { afterRows, waitedMs, polls } };
113
+ if (polls > 1) {
114
+ this.logger.info(line);
115
+ } else {
116
+ this.logger.debug(line);
117
+ }
118
+ return;
119
+ }
120
+
121
+ const now = Date.now();
122
+ if (now >= deadline) {
123
+ const absent = waiting.map((row) => row.tables.map((table) => `${table.name}:${row.id}`).join(', ')).join(', ');
124
+ this.logger.warn({
125
+ message: `Declared rows absent at the bound — the write was not run`,
126
+ obj: { absent, boundMs, waitedMs: now - started, polls },
127
+ });
128
+ throw new Error(
129
+ `TransactionRunner: waited ${boundMs} ms for rows this write depends on to exist and be visible to the caller; still absent: ${absent} — the write was not run (the bound is the db driver's per-operation deadline)`
130
+ );
131
+ }
132
+ await new Promise((resolve) => setTimeout(resolve, Math.min(interval, deadline - now)));
133
+ interval = Math.min(interval * 2, TransactionRunner.MAX_POLL_INTERVAL_MS);
134
+ }
135
+ }
136
+
137
+ /**
138
+ * The rows the operations reference, by id → the table(s) they are read from: the permission
139
+ * source a row attaches to (an insert whose `permissionSource` is another row), the row an
140
+ * update / preserving update / membership update / delete targets. The rows the operations
141
+ * insert themselves are not dependencies.
142
+ */
143
+ private static rowsTheOperationsReference(ops: Operation<any>[]): Map<string, Table<any>[]> {
144
+ const inserted: string[] = [];
145
+ const referenced = new Map<string, Table<any>[]>();
146
+ const reference = (table: Table<any> | undefined, id: unknown) => {
147
+ if (!table || typeof id !== 'string') {
148
+ return;
149
+ }
150
+ let tables = referenced.get(id);
151
+ if (!tables) {
152
+ tables = [];
153
+ referenced.set(id, tables);
154
+ }
155
+ if (tables.indexOf(table) === -1) {
156
+ tables.push(table);
157
+ }
158
+ };
159
+
160
+ for (const op of ops) {
161
+ const args = op.args as unknown[];
162
+ const table = args[0] as Table<any> | undefined;
163
+ if (op.name === 'insert') {
164
+ const record = args[1] as
165
+ | { id?: string; permissionSource?: { _table?: string; _id?: string }; permissionSourceTable?: string }
166
+ | undefined;
167
+ if (record?.id) {
168
+ inserted.push(record.id);
169
+ }
170
+ const source = record?.permissionSource;
171
+ if (source?._id && source._id !== record?.id) {
172
+ const sourceTableName = record?.permissionSourceTable ?? source._table;
173
+ reference(sourceTableName ? TransactionRunner.tableNamed(sourceTableName, table) : table, source._id);
174
+ }
175
+ } else if (op.name === 'update' || op.name === 'updatePreserving') {
176
+ const record = args[1] as { id?: string } | undefined;
177
+ const query = args[2] as { id?: unknown } | undefined;
178
+ reference(table, record?.id ?? (query && typeof query === 'object' && 'id' in query ? query.id : undefined));
179
+ } else if (op.name === 'updateArrayMembership') {
180
+ reference(table, (args[1] as { recordId?: string } | undefined)?.recordId);
181
+ } else if (op.name === 'delete') {
182
+ const query = args[1] as { id?: unknown } | undefined;
183
+ reference(table, query && typeof query === 'object' ? query.id : undefined);
184
+ }
185
+ }
186
+
187
+ inserted.forEach((id) => referenced.delete(id));
188
+ return referenced;
189
+ }
190
+
191
+ /** The table a permission source lives in: the insert's own table when it names the same one. */
192
+ private static tableNamed(tableName: string, inserting: Table<any> | undefined): Table<any> {
193
+ return inserting && inserting.name === tableName ? inserting : tableByName(tableName);
194
+ }
24
195
  }