@spine-event-engine/storage-postgres 2.0.0-snapshot.23 → 2.0.0-snapshot.25

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 (53) hide show
  1. package/README.md +34 -16
  2. package/REFERENCE.md +66 -26
  3. package/dist/index.d.ts +1 -1
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js.map +1 -1
  6. package/dist/postgres/agent-history.d.ts +60 -18
  7. package/dist/postgres/agent-history.d.ts.map +1 -1
  8. package/dist/postgres/agent-history.js +114 -37
  9. package/dist/postgres/agent-history.js.map +1 -1
  10. package/dist/postgres/column-mapping.d.ts.map +1 -1
  11. package/dist/postgres/column-mapping.js +6 -3
  12. package/dist/postgres/column-mapping.js.map +1 -1
  13. package/dist/postgres/entity-commit.d.ts +90 -56
  14. package/dist/postgres/entity-commit.d.ts.map +1 -1
  15. package/dist/postgres/entity-commit.js +439 -188
  16. package/dist/postgres/entity-commit.js.map +1 -1
  17. package/dist/postgres/entity-history.d.ts +4 -2
  18. package/dist/postgres/entity-history.d.ts.map +1 -1
  19. package/dist/postgres/entity-history.js +163 -262
  20. package/dist/postgres/entity-history.js.map +1 -1
  21. package/dist/postgres/errors.d.ts +1 -1
  22. package/dist/postgres/errors.d.ts.map +1 -1
  23. package/dist/postgres/errors.js +5 -3
  24. package/dist/postgres/errors.js.map +1 -1
  25. package/dist/postgres/id-column.d.ts.map +1 -1
  26. package/dist/postgres/id-column.js +6 -3
  27. package/dist/postgres/id-column.js.map +1 -1
  28. package/dist/postgres/record-storage.d.ts +228 -117
  29. package/dist/postgres/record-storage.d.ts.map +1 -1
  30. package/dist/postgres/record-storage.js +350 -204
  31. package/dist/postgres/record-storage.js.map +1 -1
  32. package/dist/postgres/storage-factory.d.ts +267 -30
  33. package/dist/postgres/storage-factory.d.ts.map +1 -1
  34. package/dist/postgres/storage-factory.js +786 -145
  35. package/dist/postgres/storage-factory.js.map +1 -1
  36. package/dist/postgres/table-initializer.d.ts +7 -4
  37. package/dist/postgres/table-initializer.d.ts.map +1 -1
  38. package/dist/postgres/table-initializer.js +21 -15
  39. package/dist/postgres/table-initializer.js.map +1 -1
  40. package/dist/postgres/table-resolver.d.ts +15 -1
  41. package/dist/postgres/table-resolver.d.ts.map +1 -1
  42. package/dist/postgres/table-resolver.js +36 -7
  43. package/dist/postgres/table-resolver.js.map +1 -1
  44. package/dist/tsconfig.tsbuildinfo +1 -1
  45. package/package.json +5 -5
  46. package/dist/postgres/agent-execution.d.ts +0 -284
  47. package/dist/postgres/agent-execution.d.ts.map +0 -1
  48. package/dist/postgres/agent-execution.js +0 -648
  49. package/dist/postgres/agent-execution.js.map +0 -1
  50. package/dist/postgres/delivery-cleanup.d.ts +0 -76
  51. package/dist/postgres/delivery-cleanup.d.ts.map +0 -1
  52. package/dist/postgres/delivery-cleanup.js +0 -224
  53. package/dist/postgres/delivery-cleanup.js.map +0 -1
@@ -12,13 +12,12 @@
12
12
  * the License.
13
13
  */
14
14
  import { fromBinary, ScalarType, toBinary } from "@bufbuild/protobuf";
15
+ import { defaultStringifierRegistry, StringifierRegistry } from "@spine-event-engine/core";
16
+ import { ColumnMappings, defaultQueryCandidateLimit, RecordQuery, RecordStorage, } from "@spine-event-engine/storage";
15
17
  import { createHash } from "node:crypto";
16
- import { StringifierRegistry } from "@spine-event-engine/core";
17
- import { ColumnMappings, defaultQueryCandidateLimit, RecordStorage, } from "@spine-event-engine/storage";
18
18
  import { PostgresColumnMapping } from "./column-mapping.js";
19
- import { PostgresStorageDataError, PostgresStorageOperationError, PostgresTransactionErrors, PostgresClientDisposal, PostgresRollbackErrors, } from "./errors.js";
19
+ import { PostgresStorageDataError, PostgresStorageOperationError, PostgresClientDisposal, } from "./errors.js";
20
20
  import { PostgresIdColumn } from "./id-column.js";
21
- import { PostgresTableInitializer } from "./table-initializer.js";
22
21
  const maximumNormalizedPlanBinds = 1_000;
23
22
  /**
24
23
  * Stores one record family in one qualified PostgreSQL table.
@@ -27,35 +26,50 @@ const maximumNormalizedPlanBinds = 1_000;
27
26
  * @typeParam R The stored Protobuf record type.
28
27
  */
29
28
  export class PostgresRecordStorage extends RecordStorage {
30
- table;
29
+ tableNameValue;
30
+ onSchema;
31
31
  lifecycle;
32
32
  onClose;
33
+ create;
33
34
  /**
34
- * Reports that compare-and-set uses PostgreSQL transaction coordination.
35
+ * Reports that compare-and-set uses a conditional SQL mutation.
35
36
  */
36
37
  atomicCompareAndSet = true;
37
38
  #idColumn;
38
39
  #columns;
39
- #initializer;
40
+ #binding;
41
+ #table;
40
42
  /**
41
43
  * Creates a PostgreSQL record-family handle.
42
44
  *
43
45
  * @param context Identifies the storage boundary for this handle.
44
46
  * @param spec Defines the record type, ID type, and declared columns.
45
- * @param table Supplies the resolved physical table layout.
47
+ * @param tableNameValue Supplies the locally resolved physical table name.
48
+ * @param onSchema Builds the physical layout after schema resolution.
46
49
  * @param lifecycle Acquires clients for the selected database and schema.
47
50
  * @param onClose Removes this handle from the factory's live set.
48
51
  * @param create Optionally supplies custom create-table SQL.
49
52
  * @param stringifiers Converts message IDs and columns reversibly.
50
53
  */
51
- constructor(context, spec, table, lifecycle, onClose, create, stringifiers = new StringifierRegistry()) {
54
+ constructor(context, spec, tableNameValue, onSchema, lifecycle, onClose, create, stringifiers = defaultStringifierRegistry) {
52
55
  super(context, spec);
53
- this.table = table;
56
+ this.tableNameValue = tableNameValue;
57
+ this.onSchema = onSchema;
54
58
  this.lifecycle = lifecycle;
55
59
  this.onClose = onClose;
60
+ this.create = create;
56
61
  this.#idColumn = new PostgresIdColumn(spec.idType, stringifiers);
57
62
  this.#columns = new PostgresColumnMapping(stringifiers);
58
- this.#initializer = new PostgresTableInitializer(lifecycle, table.schema, table, create);
63
+ }
64
+ /**
65
+ * Returns the bound layout after preparation.
66
+ *
67
+ * @returns The resolved physical layout.
68
+ */
69
+ get table() {
70
+ if (this.#table === undefined)
71
+ throw new Error("PostgreSQL record table is not bound.");
72
+ return this.#table;
59
73
  }
60
74
  /**
61
75
  * Returns the resolved physical table name.
@@ -63,15 +77,47 @@ export class PostgresRecordStorage extends RecordStorage {
63
77
  * @returns The physical table name without its schema.
64
78
  */
65
79
  get tableName() {
66
- return this.table.tableName;
80
+ return this.tableNameValue;
67
81
  }
68
82
  /**
69
- * Prepares this table before an external coordinator transaction.
83
+ * Prepares this table before a record operation.
70
84
  *
71
85
  * @returns A promise that resolves after compatible initialization.
72
86
  */
73
87
  prepare() {
74
- return this.#initializer.prepare();
88
+ return this.admit(() => this.prepareInternal());
89
+ }
90
+ /**
91
+ * Prepares an already admitted operation, including after the factory fence.
92
+ *
93
+ * @returns Completion after table preparation.
94
+ */
95
+ prepareInternal() {
96
+ return this.lifecycle.track(Promise.resolve().then(() => this.prepareBound()));
97
+ }
98
+ /**
99
+ * Binds the destination and prepares its physical table.
100
+ *
101
+ * @returns Completion after physical table preparation.
102
+ */
103
+ async prepareBound() {
104
+ this.#binding ??= this.bind().catch((error) => {
105
+ this.#binding = undefined;
106
+ throw error;
107
+ });
108
+ await this.#binding;
109
+ const create = this.create;
110
+ await this.lifecycle.prepare(this.table, create === undefined ? undefined : () => create(this.table));
111
+ }
112
+ /**
113
+ * Resolves the database before constructing the schema-bound initializer.
114
+ *
115
+ * @returns Completion after the initializer is available.
116
+ */
117
+ async bind() {
118
+ await this.lifecycle.resolve();
119
+ const table = this.onSchema(this.lifecycle.schema);
120
+ this.#table = table;
75
121
  }
76
122
  /**
77
123
  * Opens package-local bounded history operations for this record family.
@@ -85,15 +131,12 @@ export class PostgresRecordStorage extends RecordStorage {
85
131
  transaction: (work) => this.transaction(work),
86
132
  using: (work) => this.using(work),
87
133
  read: (client, id, lock) => this.readOn(client, id, lock === "for-update"),
88
- assertImmutable: (client, record) => this.assertImmutableOn(client, record),
89
134
  appendImmutable: (client, record) => this.appendImmutableOn(client, record),
90
135
  write: (client, record) => this.writeOn(client, record),
91
- delete: (client, id) => this.deleteOn(client, id),
92
136
  query: (client, sql, values) => this.historyEntries(client, sql, values),
93
137
  column: (name, value) => this.historyColumn(name, value),
94
138
  table: () => this.qualified(),
95
139
  lock: (domain, ...identity) => this.historyKey(domain, identity),
96
- lockRecord: (id) => this.casKey(id),
97
140
  };
98
141
  }
99
142
  /**
@@ -105,18 +148,104 @@ export class PostgresRecordStorage extends RecordStorage {
105
148
  super.close();
106
149
  this.onClose();
107
150
  }
151
+ /**
152
+ * Registers a complete public operation before invoking record callbacks.
153
+ *
154
+ * @typeParam T The operation result.
155
+ * @param work Performs the public record operation.
156
+ * @returns The complete admitted outcome.
157
+ */
158
+ admit(work) {
159
+ if (!this.isOpen() || !this.lifecycle.publicOpen())
160
+ return Promise.reject(new Error("PostgreSQL record storage is closed."));
161
+ const { promise, resolve, reject } = Promise.withResolvers();
162
+ void this.lifecycle.track(promise);
163
+ try {
164
+ void work().then(resolve, reject);
165
+ }
166
+ catch (error) {
167
+ reject(error);
168
+ }
169
+ return promise;
170
+ }
171
+ /**
172
+ * Deletes one public record under the factory admission fence.
173
+ * @inheritdoc
174
+ */
175
+ delete(id) {
176
+ return this.admit(() => super.delete(id));
177
+ }
178
+ /**
179
+ * Reads one public record under the factory admission fence.
180
+ * @inheritdoc
181
+ */
182
+ read(id) {
183
+ return this.admit(() => super.read(id));
184
+ }
185
+ /**
186
+ * Indexes public records under the factory admission fence.
187
+ * @inheritdoc
188
+ */
189
+ index(query = {}) {
190
+ return this.admit(() => super.index(query));
191
+ }
192
+ /**
193
+ * Queries public records under the factory admission fence.
194
+ * @inheritdoc
195
+ */
196
+ query(query = {}) {
197
+ return this.admit(() => super.query(query));
198
+ }
199
+ /**
200
+ * Queries public record entries under the factory admission fence.
201
+ * @inheritdoc
202
+ */
203
+ queryEntries(query = {}) {
204
+ return this.admit(() => super.queryEntries(query));
205
+ }
206
+ /**
207
+ * Queries a normalized plan under the factory admission fence.
208
+ * @inheritdoc
209
+ */
210
+ queryPlan(plan) {
211
+ return this.admit(async () => (await super.queryPlanEntries(plan)).map((entry) => entry.record));
212
+ }
213
+ /**
214
+ * Queries normalized entries under the factory admission fence.
215
+ * @inheritdoc
216
+ */
217
+ queryPlanEntries(plan) {
218
+ return this.admit(() => super.queryPlanEntries(plan));
219
+ }
220
+ /**
221
+ * Writes a public record under the factory admission fence.
222
+ * @inheritdoc
223
+ */
224
+ write(record) {
225
+ return this.admit(() => super.write(record));
226
+ }
227
+ /**
228
+ * Compares and sets a public record under the factory admission fence.
229
+ * @inheritdoc
230
+ */
231
+ compareAndSet(id, expected, next) {
232
+ return this.admit(() => super.compareAndSet(id, expected, next));
233
+ }
234
+ /**
235
+ * Writes a public batch under the factory admission fence.
236
+ * @inheritdoc
237
+ */
238
+ writeAll(records) {
239
+ return this.admit(() => super.writeAll(records));
240
+ }
108
241
  /**
109
242
  * Writes an immutable record or confirms an identical existing payload.
110
243
  *
111
244
  * @param record Provides the immutable record to store.
112
245
  * @returns A promise that resolves when the payload is stored or confirmed.
113
246
  */
114
- async writeImmutable(record) {
115
- const id = this.recordSpec.idValueIn(record);
116
- await this.transaction(async (client) => {
117
- await this.lockSlot(client, id);
118
- await this.appendImmutableOn(client, record, true);
119
- }).catch((error) => {
247
+ writeImmutable(record) {
248
+ return this.admit(() => this.using((client) => this.appendImmutableOn(client, record))).catch((error) => {
120
249
  throw operationError(error);
121
250
  });
122
251
  }
@@ -127,7 +256,7 @@ export class PostgresRecordStorage extends RecordStorage {
127
256
  * @returns A promise that rejects when the stored payload differs.
128
257
  */
129
258
  async assertImmutable(record) {
130
- const existing = await this.read(this.recordSpec.idValueIn(record));
259
+ const existing = await this.admit(() => super.read(this.recordSpec.idValueIn(record)));
131
260
  if (existing === undefined || this.same(existing, record))
132
261
  return;
133
262
  throw new PostgresStorageOperationError("PostgreSQL immutable record collides.");
@@ -139,7 +268,7 @@ export class PostgresRecordStorage extends RecordStorage {
139
268
  * @returns Whether a stored record was deleted.
140
269
  */
141
270
  async deleteRecord(id) {
142
- return this.transaction(async (client) => this.deleteOn(client, id)).catch((error) => {
271
+ return this.using(async (client) => this.deleteOn(client, id)).catch((error) => {
143
272
  throw operationError(error);
144
273
  });
145
274
  }
@@ -161,8 +290,7 @@ export class PostgresRecordStorage extends RecordStorage {
161
290
  async queryRecordEntries(query) {
162
291
  if (query.ids?.length === 0)
163
292
  return [];
164
- const compiled = this.recordQuery(query);
165
- return this.using((client) => this.entries(client, compiled));
293
+ return this.using((client) => this.entries(client, this.recordQuery(query)));
166
294
  }
167
295
  /**
168
296
  * Returns complete normalized-plan pushdown capabilities.
@@ -182,52 +310,54 @@ export class PostgresRecordStorage extends RecordStorage {
182
310
  * @returns The decoded records admitted by the plan.
183
311
  */
184
312
  async queryPlanRecordEntries(plan) {
185
- const compiled = this.planQuery(plan);
186
- return this.using((client) => this.entries(client, compiled));
313
+ this.validatePlan(plan);
314
+ return this.using((client) => this.entries(client, this.planQuery(plan)));
315
+ }
316
+ /**
317
+ * Checks plan columns and bind count before connecting to PostgreSQL.
318
+ *
319
+ * @param plan The normalized query definition.
320
+ */
321
+ validatePlan(plan) {
322
+ for (const item of plan.order ?? [])
323
+ this.column(item.column);
324
+ const visit = (predicate) => {
325
+ if (predicate.kind === "comparison")
326
+ this.column(predicate.column);
327
+ if (predicate.kind === "all" || predicate.kind === "either")
328
+ for (const child of predicate.predicates)
329
+ visit(child);
330
+ };
331
+ if (plan.predicate !== undefined)
332
+ visit(plan.predicate);
333
+ if (!plan.exhaustive && this.predicateBindCount(plan.predicate) >= maximumNormalizedPlanBinds)
334
+ throw new PostgresStorageOperationError("PostgreSQL normalized query exceeds the 1000-parameter bind budget.");
187
335
  }
188
336
  /**
189
- * Compares and atomically applies one mutation with bounded retry.
337
+ * Compares and conditionally mutates one row with a raw-byte SQL guard.
190
338
  *
191
- * @param id Identifies the record guarded by the transaction advisory lock.
339
+ * @param id Identifies the record slot.
192
340
  * @param expected Specifies the current materialized record required to write.
193
341
  * @param next Specifies the replacement record, or absence for deletion.
194
342
  * @returns Whether the current stored payload matched the expected payload.
195
343
  */
196
344
  async compareAndSetRecord(id, expected, next) {
197
- for (let attempt = 0; attempt !== 2; attempt += 1) {
198
- try {
199
- return await this.casOnce(id, expected, next);
200
- }
201
- catch (error) {
202
- if (attempt === 0 && retryable(error))
203
- continue;
204
- throw operationError(error);
205
- }
345
+ for (let attempt = 0; attempt < 3; attempt += 1) {
346
+ const result = await this.using((client) => this.casOnce(client, id, expected, next));
347
+ if (result !== undefined)
348
+ return result;
206
349
  }
207
- throw new PostgresStorageOperationError("PostgreSQL record operation failed.");
350
+ throw new PostgresStorageOperationError("PostgreSQL record compare-and-set contention exceeded.");
208
351
  }
209
352
  /**
210
- * Writes all supplied materialized records in source order and one transaction.
353
+ * Writes all supplied materialized records independently in source order.
211
354
  *
212
355
  * @param records Provides the materialized records to write.
213
- * @returns A promise that resolves after the transaction commits.
356
+ * @returns A promise that resolves after all writes complete.
214
357
  */
215
358
  async writeAllRecords(records) {
216
- for (let attempt = 0; attempt < 2; attempt += 1) {
217
- try {
218
- await this.transaction(async (client) => {
219
- await this.lockSlots(client, records.map((record) => this.recordSpec.idValueIn(record.record)));
220
- for (const record of records)
221
- await this.writeOn(client, record.record, undefined, true);
222
- });
223
- return;
224
- }
225
- catch (error) {
226
- if (attempt === 0 && PostgresTransactionErrors.retryable(error))
227
- continue;
228
- throw operationError(error);
229
- }
230
- }
359
+ for (const record of records)
360
+ await this.writeRecord(record);
231
361
  }
232
362
  /**
233
363
  * Writes one materialized record.
@@ -236,61 +366,77 @@ export class PostgresRecordStorage extends RecordStorage {
236
366
  * @returns A promise that resolves after PostgreSQL applies the upsert.
237
367
  */
238
368
  writeRecord(record) {
239
- return this.transaction((client) => this.writeOn(client, record.record)).catch((error) => {
369
+ return this.using((client) => this.writeOn(client, record.record)).catch((error) => {
240
370
  throw operationError(error);
241
371
  });
242
372
  }
243
373
  /**
244
- * Applies one compare-and-set attempt inside a transaction.
374
+ * Applies one compare-and-set attempt using a conditional statement.
245
375
  *
376
+ * @param client Provides the operation client.
246
377
  * @param id Identifies the guarded record.
247
378
  * @param expected Supplies the required current materialization.
248
379
  * @param next Supplies the replacement or requests deletion when absent.
249
- * @returns Whether the current record matched the expected record.
250
- */
251
- async casOnce(id, expected, next) {
252
- return this.transaction(async (client) => {
253
- await this.lockSlot(client, id);
254
- const current = await this.readOn(client, id, true);
255
- if (!this.same(current, expected?.record))
256
- return false;
257
- if (next === undefined)
258
- await this.deleteOn(client, id, true);
259
- else
260
- await this.writeOn(client, next.record, id, true);
380
+ * @returns Whether the current record matched, or undefined after a guard miss.
381
+ */
382
+ async casOnce(client, id, expected, next) {
383
+ const raw = await this.rawOn(client, id);
384
+ const current = raw === undefined ? undefined : this.decode(raw);
385
+ if (!this.same(current, expected?.record))
386
+ return false;
387
+ if (next === undefined) {
388
+ if (raw === undefined)
389
+ return true;
390
+ const deleted = await client.query(`DELETE FROM ${this.qualified()} WHERE "ID" = $1 AND "bytes" = $2`, [this.id(id), raw]);
391
+ return deleted.rowCount === 1 ? true : undefined;
392
+ }
393
+ if (this.same(current, next.record))
261
394
  return true;
262
- });
395
+ if (raw === undefined) {
396
+ const inserted = await client.query(`${this.insertSql()} ON CONFLICT ("ID") DO NOTHING`, this.values(next.record, id));
397
+ return inserted.rowCount === 1 ? true : undefined;
398
+ }
399
+ const changed = await client.query(this.casUpdateSql(), [
400
+ this.id(id),
401
+ raw,
402
+ ...this.values(next.record, id).slice(1),
403
+ ]);
404
+ return changed.rowCount === 1 ? true : undefined;
263
405
  }
264
406
  /**
265
- * Executes work in one transaction and always releases its client.
266
- *
267
- * @typeParam T The value returned by the transaction work.
268
- * @param work Performs the transaction work.
269
- * @returns The value returned by the work after a successful commit.
407
+ * Reads the exact bytes used as a conditional-mutation guard.
408
+ * @param client Provides the operation client.
409
+ * @param id Identifies the row.
410
+ * @returns Stored bytes, if present.
270
411
  */
271
- async transaction(work) {
272
- await this.prepare();
273
- const client = await this.lifecycle.acquire();
274
- let discard;
412
+ async rawOn(client, id) {
413
+ const result = await client.query(`SELECT "bytes" FROM ${this.qualified()} WHERE "ID" = $1`, [this.id(id)]);
414
+ return result.rows[0] === undefined ? undefined : bytes(result.rows[0].bytes);
415
+ }
416
+ /**
417
+ * Decodes one stored record with the provider's data-error boundary.
418
+ * @param raw Supplies exact stored bytes.
419
+ * @returns Decoded Protobuf record.
420
+ */
421
+ decode(raw) {
275
422
  try {
276
- await client.query("BEGIN");
277
- const result = await work(client);
278
- await client.query("COMMIT");
279
- return result;
423
+ return fromBinary(this.recordSpec.recordType, raw);
280
424
  }
281
425
  catch (error) {
282
- try {
283
- await client.query("ROLLBACK");
284
- }
285
- catch {
286
- discard = PostgresRollbackErrors.discard(error);
287
- }
288
- throw error;
289
- }
290
- finally {
291
- client.release(discard);
426
+ throw new PostgresStorageDataError("Stored PostgreSQL record data is invalid.", {
427
+ cause: error,
428
+ });
292
429
  }
293
430
  }
431
+ /**
432
+ * Builds a conditional update that changes bytes and materialized columns together.
433
+ * @returns Parameterized update SQL.
434
+ */
435
+ casUpdateSql() {
436
+ const names = ["bytes", ...this.recordSpec.columns.map(({ name }) => name)];
437
+ const assignments = names.map((name, index) => `${quote(name)} = $${String(index + 3)}`);
438
+ return `UPDATE ${this.qualified()} SET ${assignments.join(", ")} WHERE "ID" = $1 AND "bytes" = $2`;
439
+ }
294
440
  /**
295
441
  * Executes work with one prepared and automatically released client.
296
442
  *
@@ -298,8 +444,18 @@ export class PostgresRecordStorage extends RecordStorage {
298
444
  * @param work Performs the client work.
299
445
  * @returns The value returned by the work.
300
446
  */
301
- async using(work) {
302
- await this.prepare();
447
+ using(work) {
448
+ return this.lifecycle.track(this.usePrepared(work));
449
+ }
450
+ /**
451
+ * Executes one prepared record operation on a released client.
452
+ *
453
+ * @typeParam T The operation result.
454
+ * @param work Performs the client operation.
455
+ * @returns The operation result.
456
+ */
457
+ async usePrepared(work) {
458
+ await this.prepareInternal();
303
459
  const client = await this.lifecycle.acquire();
304
460
  let discard = false;
305
461
  try {
@@ -310,19 +466,77 @@ export class PostgresRecordStorage extends RecordStorage {
310
466
  throw operationError(error);
311
467
  }
312
468
  finally {
313
- client.release(discard ? new Error("PostgreSQL client discarded after lock cleanup failure.") : undefined);
469
+ client.release(discard ? new Error("PostgreSQL client discarded after provider failure.") : undefined);
314
470
  }
315
471
  }
472
+ /**
473
+ * Executes Agent-scoped record work under one PostgreSQL transaction.
474
+ *
475
+ * @typeParam T Result of the transaction work.
476
+ * @param work Operations to run on the acquired client.
477
+ * @returns Result after commit.
478
+ */
479
+ transaction(work) {
480
+ return this.lifecycle.track(this.transactionPrepared(work));
481
+ }
482
+ /**
483
+ * Executes one prepared Agent transaction on a released client.
484
+ *
485
+ * @typeParam T The transaction result.
486
+ * @param work Performs work on the transaction client.
487
+ * @returns The committed result.
488
+ */
489
+ async transactionPrepared(work) {
490
+ await this.prepareInternal();
491
+ const client = await this.lifecycle.acquire();
492
+ let releaseFailure;
493
+ try {
494
+ await client.query("BEGIN");
495
+ const result = await work(client);
496
+ await client.query("COMMIT");
497
+ return result;
498
+ }
499
+ catch (error) {
500
+ try {
501
+ await client.query("ROLLBACK");
502
+ }
503
+ catch {
504
+ releaseFailure = new Error("PostgreSQL Agent transaction rollback failed.");
505
+ }
506
+ throw error;
507
+ }
508
+ finally {
509
+ client.release(releaseFailure);
510
+ }
511
+ }
512
+ /**
513
+ * Calculates a stable Agent advisory key in this database and schema.
514
+ *
515
+ * @param domain Lock namespace within the provider.
516
+ * @param identity Components of the lock identity.
517
+ * @returns Stable advisory lock key.
518
+ */
519
+ historyKey(domain, identity) {
520
+ return createHash("sha256")
521
+ .update(`spine-postgres-${domain}\0`)
522
+ .update(this.lifecycle.databaseName)
523
+ .update("\0")
524
+ .update(this.lifecycle.schema)
525
+ .update("\0")
526
+ .update(identity.join("\0"))
527
+ .digest()
528
+ .readBigInt64BE();
529
+ }
316
530
  /**
317
531
  * Reads and decodes one record on the supplied client.
318
532
  *
319
533
  * @param client Provides the active client.
320
534
  * @param id Identifies the record.
321
- * @param lock Whether to lock the selected row for update.
535
+ * @param locked Whether to lock the selected row in the Agent transaction.
322
536
  * @returns The decoded record, when present.
323
537
  */
324
- async readOn(client, id, lock = false) {
325
- const result = await client.query(`SELECT "bytes" FROM ${this.qualified()} WHERE "ID" = $1${lock ? " FOR UPDATE" : ""}`, [this.id(id)]);
538
+ async readOn(client, id, locked = false) {
539
+ const result = await client.query(`SELECT "bytes" FROM ${this.qualified()} WHERE "ID" = $1${locked ? " FOR UPDATE" : ""}`, [this.id(id)]);
326
540
  if (result.rows[0] === undefined)
327
541
  return undefined;
328
542
  try {
@@ -355,90 +569,48 @@ export class PostgresRecordStorage extends RecordStorage {
355
569
  });
356
570
  }
357
571
  }
358
- /**
359
- * Acquires the transaction lock for one record slot.
360
- *
361
- * @param client Provides the active transaction client.
362
- * @param id Identifies the record slot.
363
- * @returns A promise that resolves after lock acquisition.
364
- */
365
- lockSlot(client, id) {
366
- return client
367
- .query("SELECT pg_advisory_xact_lock($1)", [this.casKey(id)])
368
- .then(() => undefined);
369
- }
370
- /**
371
- * Acquires record-slot locks in stable order to avoid deadlocks.
372
- *
373
- * @param client Provides the active transaction client.
374
- * @param ids Identify the record slots.
375
- * @returns A promise that resolves after every lock is acquired.
376
- */
377
- async lockSlots(client, ids) {
378
- const keys = [...new Set(ids.map((id) => this.casKey(id)))];
379
- keys.sort((left, right) => (left < right ? -1 : left > right ? 1 : 0));
380
- for (const key of keys)
381
- await client.query("SELECT pg_advisory_xact_lock($1)", [key]);
382
- }
383
572
  /**
384
573
  * Writes one record on a caller-managed client.
385
574
  *
386
- * @param client Provides the active transaction client.
575
+ * @param client Provides the acquired client.
387
576
  * @param record Supplies the record to write.
388
- * @param id Identifies the record.
389
- * @param locked Whether the caller already holds the record lock.
390
577
  * @returns A promise that resolves after the upsert.
391
578
  */
392
- async writeOn(client, record, id = this.recordSpec.idValueIn(record), locked = false) {
393
- if (!locked)
394
- await this.lockSlot(client, id);
579
+ async writeOn(client, record) {
580
+ const id = this.recordSpec.idValueIn(record);
395
581
  await client.query(this.upsertSql(), this.values(record, id));
396
582
  }
397
583
  /**
398
584
  * Deletes one record on a caller-managed client.
399
585
  *
400
- * @param client Provides the active transaction client.
586
+ * @param client Provides the acquired client.
401
587
  * @param id Identifies the record.
402
- * @param locked Whether the caller already holds the record lock.
403
588
  * @returns Whether PostgreSQL deleted a row.
404
589
  */
405
- async deleteOn(client, id, locked = false) {
406
- if (!locked)
407
- await this.lockSlot(client, id);
590
+ async deleteOn(client, id) {
408
591
  return (await client.query(this.deleteSql(), [this.id(id)])).rowCount === 1;
409
592
  }
410
593
  /**
411
594
  * Stores an immutable record or confirms an identical existing record.
412
595
  *
413
- * @param client Provides the active transaction client.
596
+ * @param client Provides the acquired client.
414
597
  * @param record Supplies the immutable record.
415
- * @param locked Whether the caller already holds the record lock.
416
598
  * @returns A promise that resolves after insertion or identity confirmation.
417
599
  */
418
- async appendImmutableOn(client, record, locked = false) {
600
+ async appendImmutableOn(client, record) {
419
601
  const id = this.recordSpec.idValueIn(record);
420
- if (!locked)
421
- await this.lockSlot(client, id);
422
- const inserted = await client.query(this.immutableSql(), this.values(record));
423
- if (inserted.rowCount === 1)
424
- return;
425
- const existing = await this.readOn(client, id);
426
- if (existing === undefined || this.same(existing, record))
427
- return;
428
- throw new PostgresStorageOperationError("PostgreSQL immutable record collides.");
429
- }
430
- /**
431
- * Checks that an immutable record is absent or byte-identical.
432
- *
433
- * @param client Provides the active client.
434
- * @param record Supplies the immutable record.
435
- * @returns A promise that rejects for a conflicting stored payload.
436
- */
437
- async assertImmutableOn(client, record) {
438
- const existing = await this.readOn(client, this.recordSpec.idValueIn(record));
439
- if (existing === undefined || this.same(existing, record))
440
- return;
441
- throw new PostgresStorageOperationError("PostgreSQL immutable record collides.");
602
+ for (let attempt = 0; attempt < 3; attempt += 1) {
603
+ const inserted = await client.query(this.immutableSql(), this.values(record));
604
+ if (inserted.rowCount === 1)
605
+ return;
606
+ const existing = await this.readOn(client, id);
607
+ if (existing === undefined)
608
+ continue;
609
+ if (this.same(existing, record))
610
+ return;
611
+ throw new PostgresStorageOperationError("PostgreSQL immutable record collides.");
612
+ }
613
+ throw new PostgresStorageOperationError("PostgreSQL immutable record contention exceeded.");
442
614
  }
443
615
  /**
444
616
  * Executes provider SQL and decodes its record rows.
@@ -472,24 +644,6 @@ export class PostgresRecordStorage extends RecordStorage {
472
644
  throw new PostgresStorageOperationError(`PostgreSQL history column is not declared: ${name}`);
473
645
  return ColumnMappings.value(this.#columns, column.type, value);
474
646
  }
475
- /**
476
- * Calculates a stable advisory-lock key within the selected database and schema.
477
- *
478
- * @param domain Separates lock purposes.
479
- * @param identity Identifies the protected resource.
480
- * @returns A signed PostgreSQL advisory-lock key.
481
- */
482
- historyKey(domain, identity) {
483
- return createHash("sha256")
484
- .update(`spine-postgres-${domain}\0`)
485
- .update(this.lifecycle.databaseName)
486
- .update("\0")
487
- .update(this.lifecycle.schema)
488
- .update("\0")
489
- .update(identity.join("\0"))
490
- .digest()
491
- .readBigInt64BE();
492
- }
493
647
  /**
494
648
  * Encodes one record as ordered table-column values.
495
649
  *
@@ -667,9 +821,16 @@ export class PostgresRecordStorage extends RecordStorage {
667
821
  const clauses = [];
668
822
  if (query.ids !== undefined)
669
823
  clauses.push(`"ID" IN (${query.ids.map((id) => bind.add(this.id(id))).join(", ")})`);
670
- for (const filter of query.filters ?? [])
671
- clauses.push(`${this.column(filter.column === "id" ? "ID" : filter.column)} ` +
672
- `IS NOT DISTINCT FROM ${bind.add(this.value(filter.column, filter.value))}`);
824
+ for (const filter of query.filters ?? []) {
825
+ const column = this.column(filter.column === "id" ? "ID" : filter.column);
826
+ const accepted = RecordQuery.acceptedValues(filter);
827
+ const alternatives = accepted.map((value) => `${column} IS NOT DISTINCT FROM ${bind.add(this.value(filter.column, value))}`);
828
+ clauses.push(accepted.length === 0
829
+ ? "1=0"
830
+ : accepted.length === 1
831
+ ? (alternatives[0] ?? "1=0")
832
+ : `(${alternatives.join(" OR ")})`);
833
+ }
673
834
  return clauses;
674
835
  }
675
836
  /**
@@ -730,7 +891,10 @@ export class PostgresRecordStorage extends RecordStorage {
730
891
  afterTerm(column, direction, value, bind) {
731
892
  if (value === null)
732
893
  return direction === "desc" ? "FALSE" : `${column} IS NOT NULL`;
733
- return `(${column} IS NOT NULL AND ${column} ${direction === "desc" ? "<" : ">"} ${bind.add(value)})`;
894
+ const comparison = `(${column} IS NOT NULL AND ${column} ${direction === "desc" ? "<" : ">"} ${bind.add(value)})`;
895
+ return direction === "desc" && column !== '"ID"'
896
+ ? `(${comparison} OR ${column} IS NULL)`
897
+ : comparison;
734
898
  }
735
899
  /**
736
900
  * Builds SQL for one normalized query predicate recursively.
@@ -857,15 +1021,6 @@ export class PostgresRecordStorage extends RecordStorage {
857
1021
  offsetSql(bind, offset) {
858
1022
  return ` LIMIT ${bind.add(9223372036854775807n)} OFFSET ${bind.add(offset)}`;
859
1023
  }
860
- /**
861
- * Calculates the advisory key for one record mutation.
862
- *
863
- * @param id Identifies the record.
864
- * @returns The signed advisory-lock key.
865
- */
866
- casKey(id) {
867
- return this.historyKey("record-mutation", [this.table.tableName, String(this.id(id))]);
868
- }
869
1024
  }
870
1025
  /**
871
1026
  * Allocates PostgreSQL placeholders and collects their values.
@@ -937,15 +1092,6 @@ function operator(value) {
937
1092
  throw new PostgresStorageOperationError("PostgreSQL query operator is invalid.");
938
1093
  })());
939
1094
  }
940
- /**
941
- * Reports whether a failed transaction may be attempted once more.
942
- *
943
- * @param error Supplies the failure.
944
- * @returns Whether PostgreSQL classified it as retryable.
945
- */
946
- function retryable(error) {
947
- return PostgresTransactionErrors.retryable(error);
948
- }
949
1095
  /**
950
1096
  * Converts an arbitrary failure to the package's record-operation error.
951
1097
  *
@@ -955,6 +1101,6 @@ function retryable(error) {
955
1101
  function operationError(error) {
956
1102
  return error instanceof PostgresStorageOperationError || error instanceof PostgresStorageDataError
957
1103
  ? error
958
- : new PostgresStorageOperationError("PostgreSQL record operation failed.");
1104
+ : new PostgresStorageOperationError("PostgreSQL record operation failed.", { cause: error });
959
1105
  }
960
1106
  //# sourceMappingURL=record-storage.js.map