@spine-event-engine/storage-postgres 2.0.0-snapshot.13
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/LICENSE +201 -0
- package/README.md +102 -0
- package/REFERENCE.md +85 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -0
- package/dist/postgres/column-mapping.d.ts +33 -0
- package/dist/postgres/column-mapping.d.ts.map +1 -0
- package/dist/postgres/column-mapping.js +93 -0
- package/dist/postgres/column-mapping.js.map +1 -0
- package/dist/postgres/data-type.d.ts +25 -0
- package/dist/postgres/data-type.d.ts.map +1 -0
- package/dist/postgres/data-type.js +29 -0
- package/dist/postgres/data-type.js.map +1 -0
- package/dist/postgres/delivery-cleanup.d.ts +76 -0
- package/dist/postgres/delivery-cleanup.d.ts.map +1 -0
- package/dist/postgres/delivery-cleanup.js +224 -0
- package/dist/postgres/delivery-cleanup.js.map +1 -0
- package/dist/postgres/entity-commit.d.ts +114 -0
- package/dist/postgres/entity-commit.d.ts.map +1 -0
- package/dist/postgres/entity-commit.js +385 -0
- package/dist/postgres/entity-commit.js.map +1 -0
- package/dist/postgres/entity-history.d.ts +85 -0
- package/dist/postgres/entity-history.d.ts.map +1 -0
- package/dist/postgres/entity-history.js +840 -0
- package/dist/postgres/entity-history.js.map +1 -0
- package/dist/postgres/errors.d.ts +51 -0
- package/dist/postgres/errors.d.ts.map +1 -0
- package/dist/postgres/errors.js +113 -0
- package/dist/postgres/errors.js.map +1 -0
- package/dist/postgres/id-column.d.ts +61 -0
- package/dist/postgres/id-column.d.ts.map +1 -0
- package/dist/postgres/id-column.js +165 -0
- package/dist/postgres/id-column.js.map +1 -0
- package/dist/postgres/parameter.d.ts +5 -0
- package/dist/postgres/parameter.d.ts.map +1 -0
- package/dist/postgres/parameter.js +15 -0
- package/dist/postgres/parameter.js.map +1 -0
- package/dist/postgres/record-storage.d.ts +514 -0
- package/dist/postgres/record-storage.d.ts.map +1 -0
- package/dist/postgres/record-storage.js +849 -0
- package/dist/postgres/record-storage.js.map +1 -0
- package/dist/postgres/storage-factory.d.ts +321 -0
- package/dist/postgres/storage-factory.d.ts.map +1 -0
- package/dist/postgres/storage-factory.js +584 -0
- package/dist/postgres/storage-factory.js.map +1 -0
- package/dist/postgres/table-initializer.d.ts +66 -0
- package/dist/postgres/table-initializer.d.ts.map +1 -0
- package/dist/postgres/table-initializer.js +301 -0
- package/dist/postgres/table-initializer.js.map +1 -0
- package/dist/postgres/table-resolver.d.ts +41 -0
- package/dist/postgres/table-resolver.d.ts.map +1 -0
- package/dist/postgres/table-resolver.js +225 -0
- package/dist/postgres/table-resolver.js.map +1 -0
- package/dist/postgres/table-spec.d.ts +40 -0
- package/dist/postgres/table-spec.d.ts.map +1 -0
- package/dist/postgres/table-spec.js +126 -0
- package/dist/postgres/table-spec.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/package.json +40 -0
|
@@ -0,0 +1,849 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2026, CodeMatters. All rights reserved.
|
|
3
|
+
*
|
|
4
|
+
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
|
|
5
|
+
* in compliance with the License. You may obtain a copy of the License at
|
|
6
|
+
*
|
|
7
|
+
* https://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
*
|
|
9
|
+
* Unless required by applicable law or agreed to in writing, software distributed under the License
|
|
10
|
+
* is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
|
|
11
|
+
* or implied. See the License for the specific language governing permissions and limitations under
|
|
12
|
+
* the License.
|
|
13
|
+
*/
|
|
14
|
+
import { fromBinary, toBinary } from "@bufbuild/protobuf";
|
|
15
|
+
import { createHash } from "node:crypto";
|
|
16
|
+
import { StringifierRegistry } from "@spine-event-engine/core";
|
|
17
|
+
import { ColumnMappings, defaultQueryCandidateLimit, RecordStorage, } from "@spine-event-engine/storage";
|
|
18
|
+
import { PostgresColumnMapping } from "./column-mapping.js";
|
|
19
|
+
import { PostgresStorageDataError, PostgresStorageOperationError, PostgresTransactionErrors, PostgresClientDisposal, PostgresRollbackErrors, } from "./errors.js";
|
|
20
|
+
import { PostgresIdColumn } from "./id-column.js";
|
|
21
|
+
import { PostgresTableInitializer } from "./table-initializer.js";
|
|
22
|
+
const maximumNormalizedPlanBinds = 1_000;
|
|
23
|
+
/**
|
|
24
|
+
* Stores one record family in one qualified PostgreSQL table.
|
|
25
|
+
*
|
|
26
|
+
* @typeParam I The storage identifier type.
|
|
27
|
+
* @typeParam R The stored Protobuf record type.
|
|
28
|
+
*/
|
|
29
|
+
export class PostgresRecordStorage extends RecordStorage {
|
|
30
|
+
table;
|
|
31
|
+
lifecycle;
|
|
32
|
+
onClose;
|
|
33
|
+
/**
|
|
34
|
+
* Reports that compare-and-set uses PostgreSQL transaction coordination.
|
|
35
|
+
*/
|
|
36
|
+
atomicCompareAndSet = true;
|
|
37
|
+
#idColumn;
|
|
38
|
+
#columns;
|
|
39
|
+
#initializer;
|
|
40
|
+
/**
|
|
41
|
+
* Creates a PostgreSQL record-family handle.
|
|
42
|
+
*
|
|
43
|
+
* @param context Identifies the storage boundary for this handle.
|
|
44
|
+
* @param spec Defines the record type, ID type, and declared columns.
|
|
45
|
+
* @param table Supplies the resolved physical table layout.
|
|
46
|
+
* @param lifecycle Acquires clients for the selected database and schema.
|
|
47
|
+
* @param onClose Removes this handle from the factory's live set.
|
|
48
|
+
* @param create Optionally supplies custom create-table SQL.
|
|
49
|
+
* @param stringifiers Converts message IDs and columns reversibly.
|
|
50
|
+
*/
|
|
51
|
+
constructor(context, spec, table, lifecycle, onClose, create, stringifiers = new StringifierRegistry()) {
|
|
52
|
+
super(context, spec);
|
|
53
|
+
this.table = table;
|
|
54
|
+
this.lifecycle = lifecycle;
|
|
55
|
+
this.onClose = onClose;
|
|
56
|
+
this.#idColumn = new PostgresIdColumn(spec.idType, stringifiers);
|
|
57
|
+
this.#columns = new PostgresColumnMapping(stringifiers);
|
|
58
|
+
this.#initializer = new PostgresTableInitializer(lifecycle, table.schema, table, create);
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Returns the resolved physical table name.
|
|
62
|
+
*
|
|
63
|
+
* @returns The physical table name without its schema.
|
|
64
|
+
*/
|
|
65
|
+
get tableName() {
|
|
66
|
+
return this.table.tableName;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Prepares this table before an external coordinator transaction.
|
|
70
|
+
*
|
|
71
|
+
* @returns A promise that resolves after compatible initialization.
|
|
72
|
+
*/
|
|
73
|
+
prepare() {
|
|
74
|
+
return this.#initializer.prepare();
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Opens package-local bounded history operations for this record family.
|
|
78
|
+
*
|
|
79
|
+
* @internal
|
|
80
|
+
* @returns PostgreSQL-only history execution capabilities.
|
|
81
|
+
*/
|
|
82
|
+
historyExecutor() {
|
|
83
|
+
return {
|
|
84
|
+
prepare: () => this.prepare(),
|
|
85
|
+
transaction: (work) => this.transaction(work),
|
|
86
|
+
using: (work) => this.using(work),
|
|
87
|
+
read: (client, id, lock) => this.readOn(client, id, lock === "for-update"),
|
|
88
|
+
assertImmutable: (client, record) => this.assertImmutableOn(client, record),
|
|
89
|
+
appendImmutable: (client, record) => this.appendImmutableOn(client, record),
|
|
90
|
+
write: (client, record) => this.writeOn(client, record),
|
|
91
|
+
delete: (client, id) => this.deleteOn(client, id),
|
|
92
|
+
query: (client, sql, values) => this.historyEntries(client, sql, values),
|
|
93
|
+
column: (name, value) => this.historyColumn(name, value),
|
|
94
|
+
table: () => this.qualified(),
|
|
95
|
+
lock: (domain, ...identity) => this.historyKey(domain, identity),
|
|
96
|
+
lockRecord: (id) => this.casKey(id),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Closes this handle and unregisters it from the factory.
|
|
101
|
+
*/
|
|
102
|
+
close() {
|
|
103
|
+
if (!this.isOpen())
|
|
104
|
+
return;
|
|
105
|
+
super.close();
|
|
106
|
+
this.onClose();
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Writes an immutable record or confirms an identical existing payload.
|
|
110
|
+
*
|
|
111
|
+
* @param record Provides the immutable record to store.
|
|
112
|
+
* @returns A promise that resolves when the payload is stored or confirmed.
|
|
113
|
+
*/
|
|
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) => {
|
|
120
|
+
throw operationError(error);
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Checks whether an immutable record is absent or byte-identical.
|
|
125
|
+
*
|
|
126
|
+
* @param record Provides the immutable record to inspect.
|
|
127
|
+
* @returns A promise that rejects when the stored payload differs.
|
|
128
|
+
*/
|
|
129
|
+
async assertImmutable(record) {
|
|
130
|
+
const existing = await this.read(this.recordSpec.idValueIn(record));
|
|
131
|
+
if (existing === undefined || this.same(existing, record))
|
|
132
|
+
return;
|
|
133
|
+
throw new PostgresStorageOperationError("PostgreSQL immutable record collides.");
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Deletes one record by storage ID.
|
|
137
|
+
*
|
|
138
|
+
* @param id Identifies the record to delete.
|
|
139
|
+
* @returns Whether a stored record was deleted.
|
|
140
|
+
*/
|
|
141
|
+
async deleteRecord(id) {
|
|
142
|
+
return this.transaction(async (client) => this.deleteOn(client, id)).catch((error) => {
|
|
143
|
+
throw operationError(error);
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Reads one record by storage ID.
|
|
148
|
+
*
|
|
149
|
+
* @param id Identifies the record to read.
|
|
150
|
+
* @returns The decoded record, when present.
|
|
151
|
+
*/
|
|
152
|
+
readRecord(id) {
|
|
153
|
+
return this.using((client) => this.readOn(client, id));
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Returns query records from one parameterized PostgreSQL statement.
|
|
157
|
+
*
|
|
158
|
+
* @param query Defines IDs, filters, order, continuation, and bounds.
|
|
159
|
+
* @returns The decoded records admitted by the query.
|
|
160
|
+
*/
|
|
161
|
+
async queryRecordEntries(query) {
|
|
162
|
+
if (query.ids?.length === 0)
|
|
163
|
+
return [];
|
|
164
|
+
const compiled = this.recordQuery(query);
|
|
165
|
+
return this.using((client) => this.entries(client, compiled));
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Returns complete normalized-plan pushdown capabilities.
|
|
169
|
+
*
|
|
170
|
+
* @returns The normalized query features implemented by this storage.
|
|
171
|
+
*/
|
|
172
|
+
queryCapabilities() {
|
|
173
|
+
return {
|
|
174
|
+
comparisons: ["equal", "greaterThan", "lessThan", "greaterOrEqual", "lessOrEqual"],
|
|
175
|
+
features: ["either", "nested", "order", "mask", "limit"],
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Executes a normalized plan as one parameterized statement.
|
|
180
|
+
*
|
|
181
|
+
* @param plan Defines the validated normalized predicate and bounds.
|
|
182
|
+
* @returns The decoded records admitted by the plan.
|
|
183
|
+
*/
|
|
184
|
+
async queryPlanRecordEntries(plan) {
|
|
185
|
+
const compiled = this.planQuery(plan);
|
|
186
|
+
return this.using((client) => this.entries(client, compiled));
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Compares and atomically applies one mutation with bounded retry.
|
|
190
|
+
*
|
|
191
|
+
* @param id Identifies the record guarded by the transaction advisory lock.
|
|
192
|
+
* @param expected Specifies the current materialized record required to write.
|
|
193
|
+
* @param next Specifies the replacement record, or absence for deletion.
|
|
194
|
+
* @returns Whether the current stored payload matched the expected payload.
|
|
195
|
+
*/
|
|
196
|
+
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
|
+
}
|
|
206
|
+
}
|
|
207
|
+
throw new PostgresStorageOperationError("PostgreSQL record operation failed.");
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Writes all supplied materialized records in source order and one transaction.
|
|
211
|
+
*
|
|
212
|
+
* @param records Provides the materialized records to write.
|
|
213
|
+
* @returns A promise that resolves after the transaction commits.
|
|
214
|
+
*/
|
|
215
|
+
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
|
+
}
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Writes one materialized record.
|
|
234
|
+
*
|
|
235
|
+
* @param record Provides the materialized record to write.
|
|
236
|
+
* @returns A promise that resolves after PostgreSQL applies the upsert.
|
|
237
|
+
*/
|
|
238
|
+
writeRecord(record) {
|
|
239
|
+
return this.transaction((client) => this.writeOn(client, record.record)).catch((error) => {
|
|
240
|
+
throw operationError(error);
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Applies one compare-and-set attempt inside a transaction.
|
|
245
|
+
*
|
|
246
|
+
* @param id Identifies the guarded record.
|
|
247
|
+
* @param expected Supplies the required current materialization.
|
|
248
|
+
* @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);
|
|
261
|
+
return true;
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
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.
|
|
270
|
+
*/
|
|
271
|
+
async transaction(work) {
|
|
272
|
+
await this.prepare();
|
|
273
|
+
const client = await this.lifecycle.acquire();
|
|
274
|
+
let discard;
|
|
275
|
+
try {
|
|
276
|
+
await client.query("BEGIN");
|
|
277
|
+
const result = await work(client);
|
|
278
|
+
await client.query("COMMIT");
|
|
279
|
+
return result;
|
|
280
|
+
}
|
|
281
|
+
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);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Executes work with one prepared and automatically released client.
|
|
296
|
+
*
|
|
297
|
+
* @typeParam T The value returned by the client work.
|
|
298
|
+
* @param work Performs the client work.
|
|
299
|
+
* @returns The value returned by the work.
|
|
300
|
+
*/
|
|
301
|
+
async using(work) {
|
|
302
|
+
await this.prepare();
|
|
303
|
+
const client = await this.lifecycle.acquire();
|
|
304
|
+
let discard = false;
|
|
305
|
+
try {
|
|
306
|
+
return await work(client);
|
|
307
|
+
}
|
|
308
|
+
catch (error) {
|
|
309
|
+
discard = PostgresClientDisposal.required(error);
|
|
310
|
+
throw operationError(error);
|
|
311
|
+
}
|
|
312
|
+
finally {
|
|
313
|
+
client.release(discard ? new Error("PostgreSQL client discarded after lock cleanup failure.") : undefined);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* Reads and decodes one record on the supplied client.
|
|
318
|
+
*
|
|
319
|
+
* @param client Provides the active client.
|
|
320
|
+
* @param id Identifies the record.
|
|
321
|
+
* @param lock Whether to lock the selected row for update.
|
|
322
|
+
* @returns The decoded record, when present.
|
|
323
|
+
*/
|
|
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)]);
|
|
326
|
+
if (result.rows[0] === undefined)
|
|
327
|
+
return undefined;
|
|
328
|
+
try {
|
|
329
|
+
return fromBinary(this.recordSpec.recordType, bytes(result.rows[0].bytes));
|
|
330
|
+
}
|
|
331
|
+
catch (error) {
|
|
332
|
+
throw new PostgresStorageDataError("Stored PostgreSQL record data is invalid.", {
|
|
333
|
+
cause: error,
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* Executes a compiled query and decodes its record entries.
|
|
339
|
+
*
|
|
340
|
+
* @param client Provides the active client.
|
|
341
|
+
* @param query Supplies the compiled SQL and bind values.
|
|
342
|
+
* @returns The decoded record entries.
|
|
343
|
+
*/
|
|
344
|
+
async entries(client, query) {
|
|
345
|
+
const result = await client.query(query.sql, [...query.values]);
|
|
346
|
+
try {
|
|
347
|
+
return result.rows.map((row) => ({
|
|
348
|
+
id: this.#idColumn.read(row.ID),
|
|
349
|
+
record: fromBinary(this.recordSpec.recordType, bytes(row.bytes)),
|
|
350
|
+
}));
|
|
351
|
+
}
|
|
352
|
+
catch (error) {
|
|
353
|
+
throw new PostgresStorageDataError("Stored PostgreSQL record data is invalid.", {
|
|
354
|
+
cause: error,
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
}
|
|
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
|
+
/**
|
|
384
|
+
* Writes one record on a caller-managed client.
|
|
385
|
+
*
|
|
386
|
+
* @param client Provides the active transaction client.
|
|
387
|
+
* @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
|
+
* @returns A promise that resolves after the upsert.
|
|
391
|
+
*/
|
|
392
|
+
async writeOn(client, record, id = this.recordSpec.idValueIn(record), locked = false) {
|
|
393
|
+
if (!locked)
|
|
394
|
+
await this.lockSlot(client, id);
|
|
395
|
+
await client.query(this.upsertSql(), this.values(record, id));
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* Deletes one record on a caller-managed client.
|
|
399
|
+
*
|
|
400
|
+
* @param client Provides the active transaction client.
|
|
401
|
+
* @param id Identifies the record.
|
|
402
|
+
* @param locked Whether the caller already holds the record lock.
|
|
403
|
+
* @returns Whether PostgreSQL deleted a row.
|
|
404
|
+
*/
|
|
405
|
+
async deleteOn(client, id, locked = false) {
|
|
406
|
+
if (!locked)
|
|
407
|
+
await this.lockSlot(client, id);
|
|
408
|
+
return (await client.query(this.deleteSql(), [this.id(id)])).rowCount === 1;
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* Stores an immutable record or confirms an identical existing record.
|
|
412
|
+
*
|
|
413
|
+
* @param client Provides the active transaction client.
|
|
414
|
+
* @param record Supplies the immutable record.
|
|
415
|
+
* @param locked Whether the caller already holds the record lock.
|
|
416
|
+
* @returns A promise that resolves after insertion or identity confirmation.
|
|
417
|
+
*/
|
|
418
|
+
async appendImmutableOn(client, record, locked = false) {
|
|
419
|
+
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.");
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* Executes provider SQL and decodes its record rows.
|
|
445
|
+
*
|
|
446
|
+
* @param client Provides the active client.
|
|
447
|
+
* @param sql Supplies parameterized SQL.
|
|
448
|
+
* @param values Supply the bind values.
|
|
449
|
+
* @returns The decoded records.
|
|
450
|
+
*/
|
|
451
|
+
async historyEntries(client, sql, values) {
|
|
452
|
+
const result = await client.query(sql, [...values]);
|
|
453
|
+
try {
|
|
454
|
+
return result.rows.map((row) => fromBinary(this.recordSpec.recordType, bytes(row.bytes)));
|
|
455
|
+
}
|
|
456
|
+
catch (error) {
|
|
457
|
+
throw new PostgresStorageDataError("Stored PostgreSQL record data is invalid.", {
|
|
458
|
+
cause: error,
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
/**
|
|
463
|
+
* Converts one declared history column value to its database representation.
|
|
464
|
+
*
|
|
465
|
+
* @param name Names the declared column.
|
|
466
|
+
* @param value Supplies its logical value.
|
|
467
|
+
* @returns The PostgreSQL parameter value.
|
|
468
|
+
*/
|
|
469
|
+
historyColumn(name, value) {
|
|
470
|
+
const column = this.recordSpec.columns.find((candidate) => candidate.name === name);
|
|
471
|
+
if (column === undefined)
|
|
472
|
+
throw new PostgresStorageOperationError(`PostgreSQL history column is not declared: ${name}`);
|
|
473
|
+
return ColumnMappings.value(this.#columns, column.type, value);
|
|
474
|
+
}
|
|
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
|
+
/**
|
|
494
|
+
* Encodes one record as ordered table-column values.
|
|
495
|
+
*
|
|
496
|
+
* @param record Supplies the record to encode.
|
|
497
|
+
* @param id Identifies the record.
|
|
498
|
+
* @returns Values ordered for the generated insert statement.
|
|
499
|
+
*/
|
|
500
|
+
values(record, id = this.recordSpec.idValueIn(record)) {
|
|
501
|
+
const materialized = this.recordSpec.materialize(record);
|
|
502
|
+
return [
|
|
503
|
+
this.id(id),
|
|
504
|
+
toBinary(this.recordSpec.recordType, record),
|
|
505
|
+
...this.recordSpec.columns.map((column) => ColumnMappings.value(this.#columns, column.type, materialized.columns.get(column.name))),
|
|
506
|
+
];
|
|
507
|
+
}
|
|
508
|
+
/**
|
|
509
|
+
* Builds parameterized PostgreSQL SQL for a record query.
|
|
510
|
+
*
|
|
511
|
+
* @param query Defines filters, ordering, continuation, and bounds.
|
|
512
|
+
* @returns The compiled statement.
|
|
513
|
+
*/
|
|
514
|
+
recordQuery(query) {
|
|
515
|
+
const bind = new Binds();
|
|
516
|
+
const clauses = this.recordClauses(query, bind);
|
|
517
|
+
const order = this.recordOrder(query);
|
|
518
|
+
if (query.after !== undefined)
|
|
519
|
+
clauses.push(this.continuation(query, bind));
|
|
520
|
+
let sql = `SELECT "ID", "bytes" FROM ${this.qualified()}${where(clauses)} ORDER BY ${order}`;
|
|
521
|
+
if (query.limit !== undefined)
|
|
522
|
+
sql += ` LIMIT ${bind.add(query.limit)}`;
|
|
523
|
+
if (query.offset !== undefined)
|
|
524
|
+
sql +=
|
|
525
|
+
query.limit === undefined
|
|
526
|
+
? this.offsetSql(bind, query.offset)
|
|
527
|
+
: ` OFFSET ${bind.add(query.offset)}`;
|
|
528
|
+
return bind.done(sql);
|
|
529
|
+
}
|
|
530
|
+
/**
|
|
531
|
+
* Builds one bounded statement for a normalized query plan.
|
|
532
|
+
*
|
|
533
|
+
* @param plan Supplies the normalized plan.
|
|
534
|
+
* @returns The compiled statement.
|
|
535
|
+
*/
|
|
536
|
+
planQuery(plan) {
|
|
537
|
+
const bind = new Binds();
|
|
538
|
+
const predicate = plan.predicate === undefined ? "" : ` WHERE ${this.predicate(plan.predicate, bind)}`;
|
|
539
|
+
if (bind.values.length >= maximumNormalizedPlanBinds)
|
|
540
|
+
throw new PostgresStorageOperationError("PostgreSQL normalized query exceeds the 1000-parameter bind budget.");
|
|
541
|
+
const order = [
|
|
542
|
+
...(plan.order ?? []).map((item) => `${this.column(item.column)} ${item.direction === "desc" ? "DESC NULLS LAST" : "ASC NULLS FIRST"}`),
|
|
543
|
+
'"ID" ASC',
|
|
544
|
+
].join(", ");
|
|
545
|
+
const limit = Math.min(plan.limit ?? Number.MAX_SAFE_INTEGER, (plan.candidateLimit ?? defaultQueryCandidateLimit) + 1);
|
|
546
|
+
return bind.done(`SELECT "ID", "bytes" FROM ${this.qualified()}${predicate} ORDER BY ${order} LIMIT ${bind.add(limit)}`);
|
|
547
|
+
}
|
|
548
|
+
/**
|
|
549
|
+
* Builds predicates for IDs and declared-column equality filters.
|
|
550
|
+
*
|
|
551
|
+
* @param query Supplies the record query.
|
|
552
|
+
* @param bind Collects parameter values.
|
|
553
|
+
* @returns SQL predicate fragments.
|
|
554
|
+
*/
|
|
555
|
+
recordClauses(query, bind) {
|
|
556
|
+
const clauses = [];
|
|
557
|
+
if (query.ids !== undefined)
|
|
558
|
+
clauses.push(`"ID" IN (${query.ids.map((id) => bind.add(this.id(id))).join(", ")})`);
|
|
559
|
+
for (const filter of query.filters ?? [])
|
|
560
|
+
clauses.push(`${this.column(filter.column === "id" ? "ID" : filter.column)} ` +
|
|
561
|
+
`IS NOT DISTINCT FROM ${bind.add(this.value(filter.column, filter.value))}`);
|
|
562
|
+
return clauses;
|
|
563
|
+
}
|
|
564
|
+
/**
|
|
565
|
+
* Builds deterministic ordering for one record query.
|
|
566
|
+
*
|
|
567
|
+
* @param query Supplies the requested sort order.
|
|
568
|
+
* @returns The PostgreSQL order expression.
|
|
569
|
+
*/
|
|
570
|
+
recordOrder(query) {
|
|
571
|
+
return [
|
|
572
|
+
...(query.sort ?? []).map((item) => this.column(item.field === "id" ? "ID" : item.field) +
|
|
573
|
+
` ${item.direction === "desc" ? "DESC NULLS LAST" : "ASC NULLS FIRST"}`),
|
|
574
|
+
'"ID" ASC',
|
|
575
|
+
].join(", ");
|
|
576
|
+
}
|
|
577
|
+
/**
|
|
578
|
+
* Builds the continuation predicate for one record query.
|
|
579
|
+
*
|
|
580
|
+
* @param query Supplies the sort order and continuation values.
|
|
581
|
+
* @param bind Collects parameter values.
|
|
582
|
+
* @returns The continuation predicate.
|
|
583
|
+
*/
|
|
584
|
+
continuation(query, bind) {
|
|
585
|
+
const sort = query.sort ?? [];
|
|
586
|
+
const after = query.after;
|
|
587
|
+
if (after === undefined)
|
|
588
|
+
return "FALSE";
|
|
589
|
+
const terms = [];
|
|
590
|
+
for (let index = 0; index < sort.length; index += 1) {
|
|
591
|
+
const current = sort[index];
|
|
592
|
+
if (current === undefined)
|
|
593
|
+
continue;
|
|
594
|
+
const field = current.field === "id" ? "ID" : current.field;
|
|
595
|
+
const prefix = sort
|
|
596
|
+
.slice(0, index)
|
|
597
|
+
.map((item, previous) => `${this.column(item.field === "id" ? "ID" : item.field)} ` +
|
|
598
|
+
`IS NOT DISTINCT FROM ${bind.add(this.value(item.field, after.values[previous]?.value))}`);
|
|
599
|
+
const value = this.value(field, field === "ID" ? after.id : after.values[index]?.value);
|
|
600
|
+
terms.push(`(${[
|
|
601
|
+
...prefix,
|
|
602
|
+
this.afterTerm(this.column(field), current.direction ?? "asc", value, bind),
|
|
603
|
+
].join(" AND ")})`);
|
|
604
|
+
}
|
|
605
|
+
const equal = sort.map((item, index) => `${this.column(item.field === "id" ? "ID" : item.field)} ` +
|
|
606
|
+
`IS NOT DISTINCT FROM ${bind.add(this.value(item.field, after.values[index]?.value))}`);
|
|
607
|
+
terms.push(`(${[...equal, `"ID" > ${bind.add(this.id(after.id))}`].join(" AND ")})`);
|
|
608
|
+
return `(${terms.join(" OR ")})`;
|
|
609
|
+
}
|
|
610
|
+
/**
|
|
611
|
+
* Builds one direction-aware continuation comparison.
|
|
612
|
+
*
|
|
613
|
+
* @param column Supplies the quoted column name.
|
|
614
|
+
* @param direction Supplies the requested sort direction.
|
|
615
|
+
* @param value Supplies the continuation value.
|
|
616
|
+
* @param bind Collects parameter values.
|
|
617
|
+
* @returns The comparison expression.
|
|
618
|
+
*/
|
|
619
|
+
afterTerm(column, direction, value, bind) {
|
|
620
|
+
if (value === null)
|
|
621
|
+
return direction === "desc" ? "FALSE" : `${column} IS NOT NULL`;
|
|
622
|
+
return `(${column} IS NOT NULL AND ${column} ${direction === "desc" ? "<" : ">"} ${bind.add(value)})`;
|
|
623
|
+
}
|
|
624
|
+
/**
|
|
625
|
+
* Builds SQL for one normalized query predicate recursively.
|
|
626
|
+
*
|
|
627
|
+
* @param predicate Supplies the normalized predicate.
|
|
628
|
+
* @param bind Collects parameter values.
|
|
629
|
+
* @returns The parameterized SQL predicate.
|
|
630
|
+
*/
|
|
631
|
+
predicate(predicate, bind) {
|
|
632
|
+
if (predicate.kind === "ids")
|
|
633
|
+
return `"ID" IN (${predicate.ids.map((id) => bind.add(this.id(id))).join(", ")})`;
|
|
634
|
+
if (predicate.kind === "comparison")
|
|
635
|
+
return (`${this.column(predicate.column)} ${operator(predicate.operator)} ` +
|
|
636
|
+
bind.add(this.value(predicate.column, predicate.value)));
|
|
637
|
+
const joiner = predicate.kind === "all" ? " AND " : " OR ";
|
|
638
|
+
return `(${predicate.predicates.map((child) => this.predicate(child, bind)).join(joiner)})`;
|
|
639
|
+
}
|
|
640
|
+
/**
|
|
641
|
+
* Validates and quotes one queryable column name.
|
|
642
|
+
*
|
|
643
|
+
* @param name Names the requested column.
|
|
644
|
+
* @returns The quoted PostgreSQL identifier.
|
|
645
|
+
*/
|
|
646
|
+
column(name) {
|
|
647
|
+
if (name === "ID")
|
|
648
|
+
return '"ID"';
|
|
649
|
+
if (!this.recordSpec.columns.some((column) => column.name === name))
|
|
650
|
+
throw new PostgresStorageOperationError(`PostgreSQL query column is not declared: ${name}`);
|
|
651
|
+
return quote(name);
|
|
652
|
+
}
|
|
653
|
+
/**
|
|
654
|
+
* Converts one query value to its database representation.
|
|
655
|
+
*
|
|
656
|
+
* @param name Names the ID or declared column.
|
|
657
|
+
* @param value Supplies the logical value.
|
|
658
|
+
* @returns The PostgreSQL parameter value.
|
|
659
|
+
*/
|
|
660
|
+
value(name, value) {
|
|
661
|
+
if (name === "id" || name === "ID")
|
|
662
|
+
return this.id(value);
|
|
663
|
+
const column = this.recordSpec.columns.find((candidate) => candidate.name === name);
|
|
664
|
+
if (column === undefined)
|
|
665
|
+
throw new PostgresStorageOperationError(`PostgreSQL query column is not declared: ${name}`);
|
|
666
|
+
return ColumnMappings.value(this.#columns, column.type, value);
|
|
667
|
+
}
|
|
668
|
+
/**
|
|
669
|
+
* Converts one record ID to its database representation.
|
|
670
|
+
*
|
|
671
|
+
* @param id Supplies the logical ID.
|
|
672
|
+
* @returns The PostgreSQL parameter value.
|
|
673
|
+
*/
|
|
674
|
+
id(id) {
|
|
675
|
+
try {
|
|
676
|
+
return this.#idColumn.value(id);
|
|
677
|
+
}
|
|
678
|
+
catch (error) {
|
|
679
|
+
throw operationError(error);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
/**
|
|
683
|
+
* Compares two optional records by their Protobuf bytes.
|
|
684
|
+
*
|
|
685
|
+
* @param left Supplies the first record.
|
|
686
|
+
* @param right Supplies the second record.
|
|
687
|
+
* @returns Whether both records are absent or byte-identical.
|
|
688
|
+
*/
|
|
689
|
+
same(left, right) {
|
|
690
|
+
return left === undefined || right === undefined
|
|
691
|
+
? left === right
|
|
692
|
+
: Buffer.from(toBinary(this.recordSpec.recordType, left)).equals(Buffer.from(toBinary(this.recordSpec.recordType, right)));
|
|
693
|
+
}
|
|
694
|
+
/**
|
|
695
|
+
* Returns the quoted schema-qualified table name.
|
|
696
|
+
*
|
|
697
|
+
* @returns The qualified PostgreSQL identifier.
|
|
698
|
+
*/
|
|
699
|
+
qualified() {
|
|
700
|
+
return `${quote(this.lifecycle.schema)}.${quote(this.table.tableName)}`;
|
|
701
|
+
}
|
|
702
|
+
/**
|
|
703
|
+
* Builds the delete statement for this record family.
|
|
704
|
+
*
|
|
705
|
+
* @returns Parameterized delete SQL.
|
|
706
|
+
*/
|
|
707
|
+
deleteSql() {
|
|
708
|
+
return `DELETE FROM ${this.qualified()} WHERE "ID" = $1`;
|
|
709
|
+
}
|
|
710
|
+
/**
|
|
711
|
+
* Builds the conflict-tolerant immutable insert statement.
|
|
712
|
+
*
|
|
713
|
+
* @returns Parameterized immutable-insert SQL.
|
|
714
|
+
*/
|
|
715
|
+
immutableSql() {
|
|
716
|
+
return `${this.insertSql()} ON CONFLICT ("ID") DO NOTHING`;
|
|
717
|
+
}
|
|
718
|
+
/**
|
|
719
|
+
* Builds the upsert statement for this record family.
|
|
720
|
+
*
|
|
721
|
+
* @returns Parameterized upsert SQL.
|
|
722
|
+
*/
|
|
723
|
+
upsertSql() {
|
|
724
|
+
const names = ["ID", "bytes", ...this.recordSpec.columns.map(({ name }) => name)];
|
|
725
|
+
const updates = names.slice(1).map((name) => `${quote(name)}=EXCLUDED.${quote(name)}`);
|
|
726
|
+
return `${this.insertSql()} ON CONFLICT ("ID") DO UPDATE SET ${updates.join(", ")}`;
|
|
727
|
+
}
|
|
728
|
+
/**
|
|
729
|
+
* Builds the base insert statement for this record family.
|
|
730
|
+
*
|
|
731
|
+
* @returns Parameterized insert SQL.
|
|
732
|
+
*/
|
|
733
|
+
insertSql() {
|
|
734
|
+
const names = ["ID", "bytes", ...this.recordSpec.columns.map(({ name }) => name)];
|
|
735
|
+
const columns = names.map(quote).join(", ");
|
|
736
|
+
const values = names.map((_, index) => `$${String(index + 1)}`).join(", ");
|
|
737
|
+
return `INSERT INTO ${this.qualified()} (${columns}) VALUES (${values})`;
|
|
738
|
+
}
|
|
739
|
+
/**
|
|
740
|
+
* Builds PostgreSQL's unbounded-limit form for an offset-only query.
|
|
741
|
+
*
|
|
742
|
+
* @param bind Collects parameter values.
|
|
743
|
+
* @param offset Supplies the requested row offset.
|
|
744
|
+
* @returns The limit and offset clause.
|
|
745
|
+
*/
|
|
746
|
+
offsetSql(bind, offset) {
|
|
747
|
+
return ` LIMIT ${bind.add(9223372036854775807n)} OFFSET ${bind.add(offset)}`;
|
|
748
|
+
}
|
|
749
|
+
/**
|
|
750
|
+
* Calculates the advisory key for one record mutation.
|
|
751
|
+
*
|
|
752
|
+
* @param id Identifies the record.
|
|
753
|
+
* @returns The signed advisory-lock key.
|
|
754
|
+
*/
|
|
755
|
+
casKey(id) {
|
|
756
|
+
return this.historyKey("record-mutation", [this.table.tableName, String(this.id(id))]);
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
/**
|
|
760
|
+
* Allocates PostgreSQL placeholders and collects their values.
|
|
761
|
+
*/
|
|
762
|
+
class Binds {
|
|
763
|
+
values = [];
|
|
764
|
+
/**
|
|
765
|
+
* Adds one bind value.
|
|
766
|
+
*
|
|
767
|
+
* @param value Supplies the value.
|
|
768
|
+
* @returns Its one-based PostgreSQL placeholder.
|
|
769
|
+
*/
|
|
770
|
+
add(value) {
|
|
771
|
+
this.values.push(value);
|
|
772
|
+
return `$${String(this.values.length)}`;
|
|
773
|
+
}
|
|
774
|
+
/**
|
|
775
|
+
* Completes one compiled statement.
|
|
776
|
+
*
|
|
777
|
+
* @param sql Supplies SQL containing the allocated placeholders.
|
|
778
|
+
* @returns The statement and collected values.
|
|
779
|
+
*/
|
|
780
|
+
done(sql) {
|
|
781
|
+
return { sql, values: this.values };
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
/**
|
|
785
|
+
* Quotes one PostgreSQL identifier.
|
|
786
|
+
*
|
|
787
|
+
* @param value Supplies the identifier.
|
|
788
|
+
* @returns The safely quoted identifier.
|
|
789
|
+
*/
|
|
790
|
+
function quote(value) {
|
|
791
|
+
return `"${value.replaceAll('"', '""')}"`;
|
|
792
|
+
}
|
|
793
|
+
/**
|
|
794
|
+
* Joins predicate fragments into an optional where clause.
|
|
795
|
+
*
|
|
796
|
+
* @param clauses Supply predicate fragments.
|
|
797
|
+
* @returns An empty string or a complete where clause.
|
|
798
|
+
*/
|
|
799
|
+
function where(clauses) {
|
|
800
|
+
return clauses.length === 0 ? "" : ` WHERE ${clauses.join(" AND ")}`;
|
|
801
|
+
}
|
|
802
|
+
/**
|
|
803
|
+
* Copies a PostgreSQL binary value into a plain byte array.
|
|
804
|
+
*
|
|
805
|
+
* @param value Supplies the returned binary value.
|
|
806
|
+
* @returns A plain byte array.
|
|
807
|
+
*/
|
|
808
|
+
function bytes(value) {
|
|
809
|
+
return new Uint8Array(value);
|
|
810
|
+
}
|
|
811
|
+
/**
|
|
812
|
+
* Converts a normalized comparison name to PostgreSQL SQL.
|
|
813
|
+
*
|
|
814
|
+
* @param value Supplies the normalized comparison name.
|
|
815
|
+
* @returns Its SQL operator.
|
|
816
|
+
*/
|
|
817
|
+
function operator(value) {
|
|
818
|
+
return ({
|
|
819
|
+
equal: "IS NOT DISTINCT FROM",
|
|
820
|
+
greaterThan: ">",
|
|
821
|
+
lessThan: "<",
|
|
822
|
+
greaterOrEqual: ">=",
|
|
823
|
+
lessOrEqual: "<=",
|
|
824
|
+
}[value] ??
|
|
825
|
+
(() => {
|
|
826
|
+
throw new PostgresStorageOperationError("PostgreSQL query operator is invalid.");
|
|
827
|
+
})());
|
|
828
|
+
}
|
|
829
|
+
/**
|
|
830
|
+
* Reports whether a failed transaction may be attempted once more.
|
|
831
|
+
*
|
|
832
|
+
* @param error Supplies the failure.
|
|
833
|
+
* @returns Whether PostgreSQL classified it as retryable.
|
|
834
|
+
*/
|
|
835
|
+
function retryable(error) {
|
|
836
|
+
return PostgresTransactionErrors.retryable(error);
|
|
837
|
+
}
|
|
838
|
+
/**
|
|
839
|
+
* Converts an arbitrary failure to the package's record-operation error.
|
|
840
|
+
*
|
|
841
|
+
* @param error Supplies the failure.
|
|
842
|
+
* @returns The preserved or normalized storage error.
|
|
843
|
+
*/
|
|
844
|
+
function operationError(error) {
|
|
845
|
+
return error instanceof PostgresStorageOperationError || error instanceof PostgresStorageDataError
|
|
846
|
+
? error
|
|
847
|
+
: new PostgresStorageOperationError("PostgreSQL record operation failed.");
|
|
848
|
+
}
|
|
849
|
+
//# sourceMappingURL=record-storage.js.map
|