@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.
- package/README.md +34 -16
- package/REFERENCE.md +66 -26
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/postgres/agent-history.d.ts +60 -18
- package/dist/postgres/agent-history.d.ts.map +1 -1
- package/dist/postgres/agent-history.js +114 -37
- package/dist/postgres/agent-history.js.map +1 -1
- package/dist/postgres/column-mapping.d.ts.map +1 -1
- package/dist/postgres/column-mapping.js +6 -3
- package/dist/postgres/column-mapping.js.map +1 -1
- package/dist/postgres/entity-commit.d.ts +90 -56
- package/dist/postgres/entity-commit.d.ts.map +1 -1
- package/dist/postgres/entity-commit.js +439 -188
- package/dist/postgres/entity-commit.js.map +1 -1
- package/dist/postgres/entity-history.d.ts +4 -2
- package/dist/postgres/entity-history.d.ts.map +1 -1
- package/dist/postgres/entity-history.js +163 -262
- package/dist/postgres/entity-history.js.map +1 -1
- package/dist/postgres/errors.d.ts +1 -1
- package/dist/postgres/errors.d.ts.map +1 -1
- package/dist/postgres/errors.js +5 -3
- package/dist/postgres/errors.js.map +1 -1
- package/dist/postgres/id-column.d.ts.map +1 -1
- package/dist/postgres/id-column.js +6 -3
- package/dist/postgres/id-column.js.map +1 -1
- package/dist/postgres/record-storage.d.ts +228 -117
- package/dist/postgres/record-storage.d.ts.map +1 -1
- package/dist/postgres/record-storage.js +350 -204
- package/dist/postgres/record-storage.js.map +1 -1
- package/dist/postgres/storage-factory.d.ts +267 -30
- package/dist/postgres/storage-factory.d.ts.map +1 -1
- package/dist/postgres/storage-factory.js +786 -145
- package/dist/postgres/storage-factory.js.map +1 -1
- package/dist/postgres/table-initializer.d.ts +7 -4
- package/dist/postgres/table-initializer.d.ts.map +1 -1
- package/dist/postgres/table-initializer.js +21 -15
- package/dist/postgres/table-initializer.js.map +1 -1
- package/dist/postgres/table-resolver.d.ts +15 -1
- package/dist/postgres/table-resolver.d.ts.map +1 -1
- package/dist/postgres/table-resolver.js +36 -7
- package/dist/postgres/table-resolver.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +5 -5
- package/dist/postgres/agent-execution.d.ts +0 -284
- package/dist/postgres/agent-execution.d.ts.map +0 -1
- package/dist/postgres/agent-execution.js +0 -648
- package/dist/postgres/agent-execution.js.map +0 -1
- package/dist/postgres/delivery-cleanup.d.ts +0 -76
- package/dist/postgres/delivery-cleanup.d.ts.map +0 -1
- package/dist/postgres/delivery-cleanup.js +0 -224
- 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,
|
|
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
|
-
|
|
29
|
+
tableNameValue;
|
|
30
|
+
onSchema;
|
|
31
31
|
lifecycle;
|
|
32
32
|
onClose;
|
|
33
|
+
create;
|
|
33
34
|
/**
|
|
34
|
-
* Reports that compare-and-set uses
|
|
35
|
+
* Reports that compare-and-set uses a conditional SQL mutation.
|
|
35
36
|
*/
|
|
36
37
|
atomicCompareAndSet = true;
|
|
37
38
|
#idColumn;
|
|
38
39
|
#columns;
|
|
39
|
-
#
|
|
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
|
|
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,
|
|
54
|
+
constructor(context, spec, tableNameValue, onSchema, lifecycle, onClose, create, stringifiers = defaultStringifierRegistry) {
|
|
52
55
|
super(context, spec);
|
|
53
|
-
this.
|
|
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
|
-
|
|
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.
|
|
80
|
+
return this.tableNameValue;
|
|
67
81
|
}
|
|
68
82
|
/**
|
|
69
|
-
* Prepares this table before
|
|
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
|
|
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
|
-
|
|
115
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
186
|
-
return this.using((client) => this.entries(client,
|
|
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
|
|
337
|
+
* Compares and conditionally mutates one row with a raw-byte SQL guard.
|
|
190
338
|
*
|
|
191
|
-
* @param id Identifies the record
|
|
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
|
|
198
|
-
|
|
199
|
-
|
|
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
|
|
350
|
+
throw new PostgresStorageOperationError("PostgreSQL record compare-and-set contention exceeded.");
|
|
208
351
|
}
|
|
209
352
|
/**
|
|
210
|
-
* Writes all supplied materialized records in source order
|
|
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
|
|
356
|
+
* @returns A promise that resolves after all writes complete.
|
|
214
357
|
*/
|
|
215
358
|
async writeAllRecords(records) {
|
|
216
|
-
for (
|
|
217
|
-
|
|
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.
|
|
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
|
|
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
|
|
250
|
-
*/
|
|
251
|
-
async casOnce(id, expected, next) {
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
if (
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
*
|
|
266
|
-
*
|
|
267
|
-
* @
|
|
268
|
-
* @
|
|
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
|
|
272
|
-
await this.
|
|
273
|
-
|
|
274
|
-
|
|
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
|
-
|
|
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
|
-
|
|
283
|
-
|
|
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
|
-
|
|
302
|
-
|
|
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
|
|
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
|
|
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,
|
|
325
|
-
const result = await client.query(`SELECT "bytes" FROM ${this.qualified()} WHERE "ID" = $1${
|
|
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
|
|
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
|
|
393
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
600
|
+
async appendImmutableOn(client, record) {
|
|
419
601
|
const id = this.recordSpec.idValueIn(record);
|
|
420
|
-
|
|
421
|
-
await this.
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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
|
-
|
|
672
|
-
|
|
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
|
-
|
|
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
|