@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,840 @@
|
|
|
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 } from "@bufbuild/protobuf";
|
|
15
|
+
import { disabledEventHistoryPort, disabledStateHistoryPort, eventHistorySpec, stateHistorySpec, } from "@spine-event-engine/storage/provider";
|
|
16
|
+
import { PostgresClientDisposal, PostgresStorageDataError, PostgresStorageErrors, PostgresStorageOperationError, } from "./errors.js";
|
|
17
|
+
import { PostgresRecordStorage } from "./record-storage.js";
|
|
18
|
+
/**
|
|
19
|
+
* Provides PostgreSQL-backed current Entity storage and optional disabled history ports.
|
|
20
|
+
*
|
|
21
|
+
* @typeParam I Entity identifier type.
|
|
22
|
+
* @typeParam S Entity state message type.
|
|
23
|
+
*/
|
|
24
|
+
export class PostgresEntityStorage {
|
|
25
|
+
records;
|
|
26
|
+
onClose;
|
|
27
|
+
/**
|
|
28
|
+
* Exposes current Entity records.
|
|
29
|
+
*/
|
|
30
|
+
current;
|
|
31
|
+
/**
|
|
32
|
+
* Exposes optional Entity state history.
|
|
33
|
+
*/
|
|
34
|
+
states;
|
|
35
|
+
/**
|
|
36
|
+
* Exposes optional Entity event history.
|
|
37
|
+
*/
|
|
38
|
+
events;
|
|
39
|
+
#open = true;
|
|
40
|
+
/**
|
|
41
|
+
* Creates Entity ports around one current-record handle.
|
|
42
|
+
*
|
|
43
|
+
* @param input Supplies Entity identity and materialized column definitions.
|
|
44
|
+
* @param records Stores current Entity record envelopes.
|
|
45
|
+
* @param open Opens grouped PostgreSQL record families.
|
|
46
|
+
* @param onClose Unregisters this Entity handle from its factory.
|
|
47
|
+
*/
|
|
48
|
+
constructor(input, records, open, onClose) {
|
|
49
|
+
this.records = records;
|
|
50
|
+
this.onClose = onClose;
|
|
51
|
+
this.current = new PostgresCurrentStorage(input, records);
|
|
52
|
+
const states = input.stateHistory ? stateHistorySpec(input.stateSchema) : undefined;
|
|
53
|
+
this.states =
|
|
54
|
+
states === undefined
|
|
55
|
+
? disabledStateHistoryPort()
|
|
56
|
+
: new PostgresStates(input, open(states.spec, states.group));
|
|
57
|
+
const events = input.eventHistory ? eventHistorySpec(input.stateSchema) : undefined;
|
|
58
|
+
this.events =
|
|
59
|
+
events === undefined
|
|
60
|
+
? disabledEventHistoryPort()
|
|
61
|
+
: new PostgresEvents(input, open(events.spec, events.group));
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Returns whether this Entity handle accepts operations.
|
|
65
|
+
*
|
|
66
|
+
* @returns Whether this handle remains open.
|
|
67
|
+
*/
|
|
68
|
+
isOpen() {
|
|
69
|
+
return this.#open;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Closes the current-record handle and unregisters this Entity handle.
|
|
73
|
+
*/
|
|
74
|
+
close() {
|
|
75
|
+
if (!this.#open)
|
|
76
|
+
return;
|
|
77
|
+
this.#open = false;
|
|
78
|
+
this.records.close();
|
|
79
|
+
this.states.close();
|
|
80
|
+
this.events.close();
|
|
81
|
+
this.onClose();
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Stores and reads the state history for one Entity type.
|
|
86
|
+
*
|
|
87
|
+
* @typeParam I Entity identifier type.
|
|
88
|
+
* @typeParam S Entity state message type.
|
|
89
|
+
*/
|
|
90
|
+
class PostgresStates {
|
|
91
|
+
input;
|
|
92
|
+
records;
|
|
93
|
+
#executor;
|
|
94
|
+
#open = true;
|
|
95
|
+
/**
|
|
96
|
+
* Creates a state-history port around one grouped record family.
|
|
97
|
+
*
|
|
98
|
+
* @param input Supplies Entity identity and state-schema definitions.
|
|
99
|
+
* @param records Stores immutable Entity state records.
|
|
100
|
+
*/
|
|
101
|
+
constructor(input, records) {
|
|
102
|
+
this.input = input;
|
|
103
|
+
this.records = records;
|
|
104
|
+
this.#executor = records.historyExecutor();
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Stores one immutable Entity state record.
|
|
108
|
+
*
|
|
109
|
+
* @param record Entity state record to append.
|
|
110
|
+
* @returns Completion after PostgreSQL stores the record.
|
|
111
|
+
*/
|
|
112
|
+
append(record) {
|
|
113
|
+
if (!this.#open)
|
|
114
|
+
return Promise.reject(new Error("Entity state history is closed."));
|
|
115
|
+
return this.#executor
|
|
116
|
+
.transaction(async (client) => {
|
|
117
|
+
await client.query("SELECT pg_advisory_xact_lock_shared($1)", [this.familyKey()]);
|
|
118
|
+
await client.query("SELECT pg_advisory_xact_lock($1)", [this.entityKey(record.entityId)]);
|
|
119
|
+
await this.#executor.appendImmutable(client, record);
|
|
120
|
+
})
|
|
121
|
+
.catch((error) => {
|
|
122
|
+
throw PostgresStorageErrors.operation(error);
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Reads Entity states backward from an optional version.
|
|
127
|
+
*
|
|
128
|
+
* @param entityId Entity whose history is read.
|
|
129
|
+
* @param depth Maximum number of states to return.
|
|
130
|
+
* @param startingFromVersion Optional inclusive starting version.
|
|
131
|
+
* @returns Entity state records in descending version order.
|
|
132
|
+
*/
|
|
133
|
+
async backward(entityId, depth, startingFromVersion) {
|
|
134
|
+
this.assertOpen();
|
|
135
|
+
HistoryValues.assertDepth(depth);
|
|
136
|
+
await this.#executor.prepare();
|
|
137
|
+
return this.#executor
|
|
138
|
+
.transaction((client) => this.#executor.query(client, this.backwardSql(startingFromVersion), this.backwardValues(entityId, depth, startingFromVersion)))
|
|
139
|
+
.catch((error) => {
|
|
140
|
+
throw PostgresStorageErrors.operation(error);
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Reads the Entity state active at a given time.
|
|
145
|
+
*
|
|
146
|
+
* @param entityId Entity whose state is read.
|
|
147
|
+
* @param time Inclusive state timestamp.
|
|
148
|
+
* @returns Decoded state, or `undefined` when no state existed.
|
|
149
|
+
*/
|
|
150
|
+
async stateAt(entityId, time) {
|
|
151
|
+
this.assertOpen();
|
|
152
|
+
await this.#executor.prepare();
|
|
153
|
+
const records = await this.#executor
|
|
154
|
+
.transaction((client) => this.#executor.query(client, this.stateAtSql(), [
|
|
155
|
+
this.entityValue(entityId),
|
|
156
|
+
HistoryValues.nanos(time),
|
|
157
|
+
1,
|
|
158
|
+
]))
|
|
159
|
+
.catch((error) => {
|
|
160
|
+
throw PostgresStorageErrors.operation(error);
|
|
161
|
+
});
|
|
162
|
+
const record = records[0];
|
|
163
|
+
if (record?.state === undefined)
|
|
164
|
+
return undefined;
|
|
165
|
+
try {
|
|
166
|
+
return fromBinary(this.input.stateSchema, record.state.value);
|
|
167
|
+
}
|
|
168
|
+
catch (error) {
|
|
169
|
+
throw new PostgresStorageDataError("Stored PostgreSQL state data is invalid.", {
|
|
170
|
+
cause: error,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Removes all but the requested number of recent states for one Entity.
|
|
176
|
+
*
|
|
177
|
+
* @param entityId Entity whose history is trimmed.
|
|
178
|
+
* @param keepMostRecent Number of newest states to retain.
|
|
179
|
+
* @returns Completion after all older pages are deleted.
|
|
180
|
+
*/
|
|
181
|
+
async trim(entityId, keepMostRecent) {
|
|
182
|
+
this.assertOpen();
|
|
183
|
+
HistoryValues.assertKeep(keepMostRecent);
|
|
184
|
+
await this.#executor
|
|
185
|
+
.using(async (client) => {
|
|
186
|
+
const [family, entity] = this.trimLocks(entityId);
|
|
187
|
+
const acquired = [];
|
|
188
|
+
let operationFailure;
|
|
189
|
+
try {
|
|
190
|
+
await client.query("SELECT pg_advisory_lock_shared($1)", [family]);
|
|
191
|
+
acquired.push([family, true]);
|
|
192
|
+
await client.query("SELECT pg_advisory_lock($1)", [entity]);
|
|
193
|
+
acquired.push([entity, false]);
|
|
194
|
+
let cursor = await this.trimBoundary(client, entityId, keepMostRecent);
|
|
195
|
+
let includeCursor = true;
|
|
196
|
+
while (cursor !== undefined && this.#open) {
|
|
197
|
+
cursor = await this.trimPage(client, entityId, cursor, includeCursor);
|
|
198
|
+
includeCursor = false;
|
|
199
|
+
// The next page starts only while this history remains open.
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
catch (error) {
|
|
203
|
+
operationFailure = error;
|
|
204
|
+
throw error;
|
|
205
|
+
}
|
|
206
|
+
finally {
|
|
207
|
+
await PostgresSessionLocks.cleanup(client, acquired.reverse(), operationFailure);
|
|
208
|
+
}
|
|
209
|
+
})
|
|
210
|
+
.catch((error) => {
|
|
211
|
+
throw PostgresStorageErrors.operation(error);
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Deletes state-history records older than a timestamp.
|
|
216
|
+
*
|
|
217
|
+
* @param olderThan Exclusive history cutoff.
|
|
218
|
+
* @returns Completion after all matching pages are deleted.
|
|
219
|
+
*/
|
|
220
|
+
async truncate(olderThan) {
|
|
221
|
+
this.assertOpen();
|
|
222
|
+
await this.#executor.using(async (client) => {
|
|
223
|
+
const key = this.#executor.lock("history-family", this.#executor.table());
|
|
224
|
+
await client.query("SELECT pg_advisory_lock($1)", [key]);
|
|
225
|
+
let operationFailure;
|
|
226
|
+
try {
|
|
227
|
+
const cutoff = HistoryValues.nanos(olderThan);
|
|
228
|
+
const highWater = await this.highWater(client, cutoff);
|
|
229
|
+
if (highWater !== undefined)
|
|
230
|
+
while (this.#open && (await this.truncatePage(client, cutoff, highWater))) {
|
|
231
|
+
// The next page starts only while this history remains open.
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
catch (error) {
|
|
235
|
+
operationFailure = error;
|
|
236
|
+
}
|
|
237
|
+
const cleanup = await PostgresSessionLocks.unlock(client, key, false).catch((error) => error);
|
|
238
|
+
if (cleanup instanceof Error)
|
|
239
|
+
PostgresClientDisposal.mark(operationFailure ?? cleanup);
|
|
240
|
+
if (operationFailure instanceof Error)
|
|
241
|
+
throw operationFailure;
|
|
242
|
+
if (operationFailure !== undefined)
|
|
243
|
+
throw new PostgresStorageOperationError("PostgreSQL state history operation failed.");
|
|
244
|
+
if (cleanup instanceof Error)
|
|
245
|
+
throw cleanup;
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Closes this state-history port.
|
|
250
|
+
*/
|
|
251
|
+
close() {
|
|
252
|
+
if (!this.#open)
|
|
253
|
+
return;
|
|
254
|
+
this.#open = false;
|
|
255
|
+
this.records.close();
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Builds the backward state-history query.
|
|
259
|
+
*
|
|
260
|
+
* @param startingFromVersion Optional inclusive starting version.
|
|
261
|
+
* @returns Parameterized PostgreSQL query text.
|
|
262
|
+
*/
|
|
263
|
+
backwardSql(startingFromVersion) {
|
|
264
|
+
const continuation = startingFromVersion === undefined ? "" : ' AND "version" <= $2';
|
|
265
|
+
const limit = startingFromVersion === undefined ? 2 : 3;
|
|
266
|
+
return [
|
|
267
|
+
`SELECT "bytes" FROM ${this.#executor.table()} WHERE "entity_id" = $1${continuation}`,
|
|
268
|
+
'ORDER BY "version" DESC, "created" DESC',
|
|
269
|
+
`LIMIT $${String(limit)}`,
|
|
270
|
+
].join(" ");
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Builds bind values for a backward state-history query.
|
|
274
|
+
*
|
|
275
|
+
* @param id Entity identifier.
|
|
276
|
+
* @param depth Maximum number of states.
|
|
277
|
+
* @param version Optional inclusive starting version.
|
|
278
|
+
* @returns PostgreSQL bind values in query order.
|
|
279
|
+
*/
|
|
280
|
+
backwardValues(id, depth, version) {
|
|
281
|
+
return version === undefined
|
|
282
|
+
? [this.entityValue(id), depth]
|
|
283
|
+
: [this.entityValue(id), version, depth];
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Builds the point-in-time state query.
|
|
287
|
+
*
|
|
288
|
+
* @returns Parameterized PostgreSQL query text.
|
|
289
|
+
*/
|
|
290
|
+
stateAtSql() {
|
|
291
|
+
return [
|
|
292
|
+
`SELECT "bytes" FROM ${this.#executor.table()} WHERE "entity_id" = $1 AND "created" <= $2`,
|
|
293
|
+
'ORDER BY "created" DESC, "version" DESC LIMIT $3',
|
|
294
|
+
].join(" ");
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Converts an Entity identifier to its stored column value.
|
|
298
|
+
*
|
|
299
|
+
* @param id Entity identifier.
|
|
300
|
+
* @returns PostgreSQL-bound identifier value.
|
|
301
|
+
*/
|
|
302
|
+
entityValue(id) {
|
|
303
|
+
return this.#executor.column("entity_id", this.input.id.pack(id));
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* Calculates the lock key shared by this history family.
|
|
307
|
+
*
|
|
308
|
+
* @returns PostgreSQL advisory-lock key.
|
|
309
|
+
*/
|
|
310
|
+
familyKey() {
|
|
311
|
+
return this.#executor.lock("history-family", this.#executor.table());
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Calculates the family and Entity lock keys used while trimming.
|
|
315
|
+
*
|
|
316
|
+
* @param id Entity identifier.
|
|
317
|
+
* @returns Family lock followed by Entity lock.
|
|
318
|
+
*/
|
|
319
|
+
trimLocks(id) {
|
|
320
|
+
return [
|
|
321
|
+
this.familyKey(),
|
|
322
|
+
this.#executor.lock("entity-mutation", this.input.sourceType.typeName, this.entityIdentity(id)),
|
|
323
|
+
];
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* Calculates the mutation lock for a stored Entity record.
|
|
327
|
+
*
|
|
328
|
+
* @param entityId Packed Entity identifier from the record.
|
|
329
|
+
* @returns PostgreSQL advisory-lock key.
|
|
330
|
+
*/
|
|
331
|
+
entityKey(entityId) {
|
|
332
|
+
if (entityId === undefined)
|
|
333
|
+
throw new Error("State history requires EntityRecord.entityId.");
|
|
334
|
+
const id = this.input.id.unpack(entityId);
|
|
335
|
+
if (id === undefined)
|
|
336
|
+
throw new Error("State history EntityRecord ID does not match storage.");
|
|
337
|
+
return this.#executor.lock("entity-mutation", this.input.sourceType.typeName, this.entityIdentity(id));
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* Returns the stable string identity used in Entity mutation locks.
|
|
341
|
+
*
|
|
342
|
+
* @param id Entity identifier.
|
|
343
|
+
* @returns Stable Entity identity string.
|
|
344
|
+
*/
|
|
345
|
+
entityIdentity(id) {
|
|
346
|
+
return this.input.id.key(id);
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* Rejects state-history work after this port has closed.
|
|
350
|
+
*/
|
|
351
|
+
assertOpen() {
|
|
352
|
+
if (!this.#open)
|
|
353
|
+
throw new Error("Entity state history is closed.");
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Deletes one bounded page of old states for an Entity.
|
|
357
|
+
*
|
|
358
|
+
* @param client PostgreSQL client holding the session locks.
|
|
359
|
+
* @param id Entity identifier.
|
|
360
|
+
* @param cursor Last retained or deleted history key.
|
|
361
|
+
* @param includeCursor Whether the page includes the supplied cursor.
|
|
362
|
+
* @returns The next page cursor, or `undefined` after the final page.
|
|
363
|
+
*/
|
|
364
|
+
async trimPage(client, id, cursor, includeCursor) {
|
|
365
|
+
await client.query("BEGIN");
|
|
366
|
+
try {
|
|
367
|
+
const rows = await client.query(this.trimSql(includeCursor), [
|
|
368
|
+
this.entityValue(id),
|
|
369
|
+
...cursor,
|
|
370
|
+
128,
|
|
371
|
+
]);
|
|
372
|
+
const keys = rows.rows.map(({ ID }) => ID);
|
|
373
|
+
await this.deleteKeys(client, keys);
|
|
374
|
+
await client.query("COMMIT");
|
|
375
|
+
const last = rows.rows.at(-1);
|
|
376
|
+
return keys.length === 128 && last !== undefined
|
|
377
|
+
? [last.version, last.created, last.ID]
|
|
378
|
+
: undefined;
|
|
379
|
+
}
|
|
380
|
+
catch (error) {
|
|
381
|
+
await client.query("ROLLBACK").catch(() => undefined);
|
|
382
|
+
throw error;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* Deletes one bounded page below a timestamp and stable high-water key.
|
|
387
|
+
*
|
|
388
|
+
* @param client PostgreSQL client holding the family lock.
|
|
389
|
+
* @param olderThan Exclusive timestamp cutoff in nanoseconds.
|
|
390
|
+
* @param highWater Last key admitted when truncation began.
|
|
391
|
+
* @returns Whether another full page may remain.
|
|
392
|
+
*/
|
|
393
|
+
async truncatePage(client, olderThan, highWater) {
|
|
394
|
+
await client.query("BEGIN");
|
|
395
|
+
try {
|
|
396
|
+
const keys = await this.keys(client, this.truncateSql(), [olderThan, ...highWater, 128]);
|
|
397
|
+
await this.deleteKeys(client, keys);
|
|
398
|
+
await client.query("COMMIT");
|
|
399
|
+
return keys.length === 128;
|
|
400
|
+
}
|
|
401
|
+
catch (error) {
|
|
402
|
+
await client.query("ROLLBACK").catch(() => undefined);
|
|
403
|
+
throw error;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* Reads record identifiers for one bounded deletion page.
|
|
408
|
+
*
|
|
409
|
+
* @param client PostgreSQL transaction client.
|
|
410
|
+
* @param sql Parameterized key query.
|
|
411
|
+
* @param values PostgreSQL bind values.
|
|
412
|
+
* @returns Stored record identifiers.
|
|
413
|
+
*/
|
|
414
|
+
async keys(client, sql, values) {
|
|
415
|
+
return (await client.query(sql, [...values])).rows.map(({ ID }) => ID);
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* Deletes records identified by one bounded key page.
|
|
419
|
+
*
|
|
420
|
+
* @param client PostgreSQL transaction client.
|
|
421
|
+
* @param keys Stored record identifiers.
|
|
422
|
+
* @returns Completion after the deletion query.
|
|
423
|
+
*/
|
|
424
|
+
deleteKeys(client, keys) {
|
|
425
|
+
if (keys.length === 0)
|
|
426
|
+
return Promise.resolve();
|
|
427
|
+
const binds = keys.map((_, index) => `$${String(index + 1)}`).join(", ");
|
|
428
|
+
return client
|
|
429
|
+
.query(`DELETE FROM ${this.#executor.table()} WHERE "ID" IN (${binds})`, [...keys])
|
|
430
|
+
.then(() => undefined);
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* Builds one state-history trim-page query.
|
|
434
|
+
*
|
|
435
|
+
* @param includeCursor Whether the supplied cursor belongs to the page.
|
|
436
|
+
* @returns Parameterized PostgreSQL query text.
|
|
437
|
+
*/
|
|
438
|
+
trimSql(includeCursor) {
|
|
439
|
+
return [
|
|
440
|
+
`SELECT "version", "created", "ID" FROM ${this.#executor.table()} WHERE "entity_id" = $1`,
|
|
441
|
+
`AND ("version", "created", "ID") ${includeCursor ? "<=" : "<"} ($2, $3, $4)`,
|
|
442
|
+
'ORDER BY "version" DESC, "created" DESC, "ID" DESC LIMIT $5',
|
|
443
|
+
].join(" ");
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* Finds the first state-history key that trimming may delete.
|
|
447
|
+
*
|
|
448
|
+
* @param client PostgreSQL client holding the session locks.
|
|
449
|
+
* @param id Entity identifier.
|
|
450
|
+
* @param keep Number of newest states to retain.
|
|
451
|
+
* @returns First deletable key, or `undefined` when nothing must be removed.
|
|
452
|
+
*/
|
|
453
|
+
async trimBoundary(client, id, keep) {
|
|
454
|
+
const result = await client.query([
|
|
455
|
+
`SELECT "version", "created", "ID" FROM ${this.#executor.table()} WHERE "entity_id" = $1`,
|
|
456
|
+
'ORDER BY "version" DESC, "created" DESC, "ID" DESC LIMIT $3 OFFSET $2',
|
|
457
|
+
].join(" "), [this.entityValue(id), keep, 1]);
|
|
458
|
+
const key = result.rows[0];
|
|
459
|
+
return key === undefined ? undefined : [key.version, key.created, key.ID];
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* Builds one state-history truncation-page query.
|
|
463
|
+
*
|
|
464
|
+
* @returns Parameterized PostgreSQL query text.
|
|
465
|
+
*/
|
|
466
|
+
truncateSql() {
|
|
467
|
+
return [
|
|
468
|
+
`SELECT "ID" FROM ${this.#executor.table()} WHERE "created" < $1`,
|
|
469
|
+
'AND ("created", "version", "ID") <= ($2, $3, $4)',
|
|
470
|
+
'ORDER BY "created" ASC, "version" ASC, "ID" ASC LIMIT $5',
|
|
471
|
+
].join(" ");
|
|
472
|
+
}
|
|
473
|
+
/**
|
|
474
|
+
* Captures the last state key eligible when truncation begins.
|
|
475
|
+
*
|
|
476
|
+
* @param client PostgreSQL client holding the family lock.
|
|
477
|
+
* @param olderThan Exclusive timestamp cutoff in nanoseconds.
|
|
478
|
+
* @returns Stable high-water key, or `undefined` when nothing is eligible.
|
|
479
|
+
*/
|
|
480
|
+
async highWater(client, olderThan) {
|
|
481
|
+
const result = await client.query([
|
|
482
|
+
`SELECT "created", "version", "ID" FROM ${this.#executor.table()} WHERE "created" < $1`,
|
|
483
|
+
'ORDER BY "created" DESC, "version" DESC, "ID" DESC LIMIT $2',
|
|
484
|
+
].join(" "), [olderThan, 1]);
|
|
485
|
+
const key = result.rows[0];
|
|
486
|
+
return key === undefined ? undefined : [key.created, key.version, key.ID];
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* Stores and reads the event history for one Entity type.
|
|
491
|
+
*
|
|
492
|
+
* @typeParam I Entity identifier type.
|
|
493
|
+
* @typeParam S Entity state message type used to derive the history family.
|
|
494
|
+
*/
|
|
495
|
+
class PostgresEvents {
|
|
496
|
+
input;
|
|
497
|
+
records;
|
|
498
|
+
#executor;
|
|
499
|
+
#open = true;
|
|
500
|
+
/**
|
|
501
|
+
* Creates an event-history port around one grouped record family.
|
|
502
|
+
*
|
|
503
|
+
* @param input Supplies Entity identity and state-schema definitions.
|
|
504
|
+
* @param records Stores immutable Entity events.
|
|
505
|
+
*/
|
|
506
|
+
constructor(input, records) {
|
|
507
|
+
this.input = input;
|
|
508
|
+
this.records = records;
|
|
509
|
+
this.#executor = records.historyExecutor();
|
|
510
|
+
}
|
|
511
|
+
/**
|
|
512
|
+
* Stores one immutable Entity event.
|
|
513
|
+
*
|
|
514
|
+
* @param record Event to append.
|
|
515
|
+
* @returns Completion after PostgreSQL stores the event.
|
|
516
|
+
*/
|
|
517
|
+
append(record) {
|
|
518
|
+
if (!this.#open)
|
|
519
|
+
return Promise.reject(new Error("Entity event history is closed."));
|
|
520
|
+
return this.#executor
|
|
521
|
+
.transaction(async (client) => {
|
|
522
|
+
await client.query("SELECT pg_advisory_xact_lock_shared($1)", [this.familyKey()]);
|
|
523
|
+
await this.#executor.appendImmutable(client, record);
|
|
524
|
+
})
|
|
525
|
+
.catch((error) => {
|
|
526
|
+
throw PostgresStorageErrors.operation(error);
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* Reads Entity events backward from an optional version.
|
|
531
|
+
*
|
|
532
|
+
* @param id Entity identifier.
|
|
533
|
+
* @param depth Maximum number of events to return.
|
|
534
|
+
* @param version Optional inclusive starting version.
|
|
535
|
+
* @returns Events in descending version order.
|
|
536
|
+
*/
|
|
537
|
+
async backward(id, depth, version) {
|
|
538
|
+
this.assertOpen();
|
|
539
|
+
HistoryValues.assertDepth(depth);
|
|
540
|
+
await this.#executor.prepare();
|
|
541
|
+
return this.#executor
|
|
542
|
+
.transaction((client) => this.#executor.query(client, this.backwardSql(version), this.backwardValues(id, depth, version)))
|
|
543
|
+
.catch((error) => {
|
|
544
|
+
throw PostgresStorageErrors.operation(error);
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
/**
|
|
548
|
+
* Deletes Entity events older than a timestamp.
|
|
549
|
+
*
|
|
550
|
+
* @param olderThan Exclusive history cutoff.
|
|
551
|
+
* @returns Completion after all matching pages are deleted.
|
|
552
|
+
*/
|
|
553
|
+
async truncate(olderThan) {
|
|
554
|
+
this.assertOpen();
|
|
555
|
+
await this.#executor.using(async (client) => {
|
|
556
|
+
const key = this.familyKey();
|
|
557
|
+
await client.query("SELECT pg_advisory_lock($1)", [key]);
|
|
558
|
+
let operationFailure;
|
|
559
|
+
try {
|
|
560
|
+
const cutoff = HistoryValues.nanos(olderThan);
|
|
561
|
+
const highWater = await this.highWater(client, cutoff);
|
|
562
|
+
if (highWater !== undefined)
|
|
563
|
+
while (this.#open && (await this.deletePage(client, cutoff, highWater))) {
|
|
564
|
+
// The next page starts only while this history remains open.
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
catch (error) {
|
|
568
|
+
operationFailure = error;
|
|
569
|
+
}
|
|
570
|
+
const cleanup = await PostgresSessionLocks.unlock(client, key, false).catch((error) => error);
|
|
571
|
+
if (cleanup instanceof Error)
|
|
572
|
+
PostgresClientDisposal.mark(operationFailure ?? cleanup);
|
|
573
|
+
if (operationFailure instanceof Error)
|
|
574
|
+
throw operationFailure;
|
|
575
|
+
if (operationFailure !== undefined)
|
|
576
|
+
throw new PostgresStorageOperationError("PostgreSQL event history operation failed.");
|
|
577
|
+
if (cleanup instanceof Error)
|
|
578
|
+
throw cleanup;
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
/**
|
|
582
|
+
* Closes this event-history port.
|
|
583
|
+
*/
|
|
584
|
+
close() {
|
|
585
|
+
if (!this.#open)
|
|
586
|
+
return;
|
|
587
|
+
this.#open = false;
|
|
588
|
+
this.records.close();
|
|
589
|
+
}
|
|
590
|
+
/**
|
|
591
|
+
* Builds the backward event-history query.
|
|
592
|
+
*
|
|
593
|
+
* @param version Optional inclusive starting version.
|
|
594
|
+
* @returns Parameterized PostgreSQL query text.
|
|
595
|
+
*/
|
|
596
|
+
backwardSql(version) {
|
|
597
|
+
const continuation = version === undefined ? "" : ' AND "version" <= $2';
|
|
598
|
+
const limit = version === undefined ? 2 : 3;
|
|
599
|
+
return [
|
|
600
|
+
`SELECT "bytes" FROM ${this.#executor.table()} WHERE "entity_id" = $1${continuation}`,
|
|
601
|
+
'ORDER BY "version" DESC, "created" DESC',
|
|
602
|
+
`LIMIT $${String(limit)}`,
|
|
603
|
+
].join(" ");
|
|
604
|
+
}
|
|
605
|
+
/**
|
|
606
|
+
* Builds bind values for a backward event-history query.
|
|
607
|
+
*
|
|
608
|
+
* @param id Entity identifier.
|
|
609
|
+
* @param depth Maximum number of events.
|
|
610
|
+
* @param version Optional inclusive starting version.
|
|
611
|
+
* @returns PostgreSQL bind values in query order.
|
|
612
|
+
*/
|
|
613
|
+
backwardValues(id, depth, version) {
|
|
614
|
+
const entity = this.#executor.column("entity_id", this.input.id.pack(id));
|
|
615
|
+
return version === undefined ? [entity, depth] : [entity, version, depth];
|
|
616
|
+
}
|
|
617
|
+
/**
|
|
618
|
+
* Calculates the lock key shared by this history family.
|
|
619
|
+
*
|
|
620
|
+
* @returns PostgreSQL advisory-lock key.
|
|
621
|
+
*/
|
|
622
|
+
familyKey() {
|
|
623
|
+
return this.#executor.lock("history-family", this.#executor.table());
|
|
624
|
+
}
|
|
625
|
+
/**
|
|
626
|
+
* Rejects event-history work after this port has closed.
|
|
627
|
+
*/
|
|
628
|
+
assertOpen() {
|
|
629
|
+
if (!this.#open)
|
|
630
|
+
throw new Error("Entity event history is closed.");
|
|
631
|
+
}
|
|
632
|
+
/**
|
|
633
|
+
* Captures the last event key eligible when truncation begins.
|
|
634
|
+
*
|
|
635
|
+
* @param client PostgreSQL client holding the family lock.
|
|
636
|
+
* @param olderThan Exclusive timestamp cutoff in nanoseconds.
|
|
637
|
+
* @returns Stable high-water key, or `undefined` when nothing is eligible.
|
|
638
|
+
*/
|
|
639
|
+
async highWater(client, olderThan) {
|
|
640
|
+
const result = await client.query(this.highWaterSql(), [olderThan, 1]);
|
|
641
|
+
const key = result.rows[0];
|
|
642
|
+
return key === undefined ? undefined : [key.created, key.version, key.ID];
|
|
643
|
+
}
|
|
644
|
+
/**
|
|
645
|
+
* Deletes one bounded page below a timestamp and stable high-water key.
|
|
646
|
+
*
|
|
647
|
+
* @param client PostgreSQL client holding the family lock.
|
|
648
|
+
* @param olderThan Exclusive timestamp cutoff in nanoseconds.
|
|
649
|
+
* @param highWater Last key admitted when truncation began.
|
|
650
|
+
* @returns Whether another full page may remain.
|
|
651
|
+
*/
|
|
652
|
+
async deletePage(client, olderThan, highWater) {
|
|
653
|
+
await client.query("BEGIN");
|
|
654
|
+
try {
|
|
655
|
+
const keys = await this.keys(client, [olderThan, ...highWater, 128]);
|
|
656
|
+
await this.deleteKeys(client, keys);
|
|
657
|
+
await client.query("COMMIT");
|
|
658
|
+
return keys.length === 128;
|
|
659
|
+
}
|
|
660
|
+
catch (error) {
|
|
661
|
+
await client.query("ROLLBACK").catch(() => undefined);
|
|
662
|
+
throw error;
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
/**
|
|
666
|
+
* Reads event identifiers for one bounded deletion page.
|
|
667
|
+
*
|
|
668
|
+
* @param client PostgreSQL transaction client.
|
|
669
|
+
* @param values PostgreSQL bind values.
|
|
670
|
+
* @returns Stored event identifiers.
|
|
671
|
+
*/
|
|
672
|
+
keys(client, values) {
|
|
673
|
+
return client
|
|
674
|
+
.query(this.deleteSql(), [...values])
|
|
675
|
+
.then(({ rows }) => rows.map(({ ID }) => ID));
|
|
676
|
+
}
|
|
677
|
+
/**
|
|
678
|
+
* Deletes events identified by one bounded key page.
|
|
679
|
+
*
|
|
680
|
+
* @param client PostgreSQL transaction client.
|
|
681
|
+
* @param keys Stored event identifiers.
|
|
682
|
+
* @returns Completion after the deletion query.
|
|
683
|
+
*/
|
|
684
|
+
deleteKeys(client, keys) {
|
|
685
|
+
if (keys.length === 0)
|
|
686
|
+
return Promise.resolve();
|
|
687
|
+
const binds = keys.map((_, index) => `$${String(index + 1)}`).join(", ");
|
|
688
|
+
return client
|
|
689
|
+
.query(`DELETE FROM ${this.#executor.table()} WHERE "ID" IN (${binds})`, [...keys])
|
|
690
|
+
.then(() => undefined);
|
|
691
|
+
}
|
|
692
|
+
/**
|
|
693
|
+
* Builds the event high-water query.
|
|
694
|
+
*
|
|
695
|
+
* @returns Parameterized PostgreSQL query text.
|
|
696
|
+
*/
|
|
697
|
+
highWaterSql() {
|
|
698
|
+
return [
|
|
699
|
+
`SELECT "created", "version", "ID" FROM ${this.#executor.table()} WHERE "created" < $1`,
|
|
700
|
+
'ORDER BY "created" DESC, "version" DESC, "ID" DESC LIMIT $2',
|
|
701
|
+
].join(" ");
|
|
702
|
+
}
|
|
703
|
+
/**
|
|
704
|
+
* Builds one event-history deletion-page query.
|
|
705
|
+
*
|
|
706
|
+
* @returns Parameterized PostgreSQL query text.
|
|
707
|
+
*/
|
|
708
|
+
deleteSql() {
|
|
709
|
+
return [
|
|
710
|
+
`SELECT "ID" FROM ${this.#executor.table()} WHERE "created" < $1`,
|
|
711
|
+
'AND ("created", "version", "ID") <= ($2, $3, $4)',
|
|
712
|
+
'ORDER BY "created" ASC, "version" ASC, "ID" ASC LIMIT $5',
|
|
713
|
+
].join(" ");
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
const HistoryValues = Object.freeze({
|
|
717
|
+
/**
|
|
718
|
+
* Validates a requested history read depth.
|
|
719
|
+
*
|
|
720
|
+
* @param value Requested history depth.
|
|
721
|
+
*/
|
|
722
|
+
assertDepth(value) {
|
|
723
|
+
if (!Number.isSafeInteger(value) || value <= 0)
|
|
724
|
+
throw new Error("Entity history depth must be a positive finite integer.");
|
|
725
|
+
},
|
|
726
|
+
/**
|
|
727
|
+
* Validates a requested state retention count.
|
|
728
|
+
*
|
|
729
|
+
* @param value Number of newest states to keep.
|
|
730
|
+
*/
|
|
731
|
+
assertKeep(value) {
|
|
732
|
+
if (!Number.isSafeInteger(value) || value < 0)
|
|
733
|
+
throw new Error("Entity state history retention must be a non-negative safe integer.");
|
|
734
|
+
},
|
|
735
|
+
/**
|
|
736
|
+
* Converts a Protobuf timestamp to epoch nanoseconds.
|
|
737
|
+
*
|
|
738
|
+
* @param value Protobuf timestamp.
|
|
739
|
+
* @returns Epoch nanoseconds.
|
|
740
|
+
*/
|
|
741
|
+
nanos(value) {
|
|
742
|
+
return value.seconds * 1000000000n + BigInt(value.nanos || 0);
|
|
743
|
+
},
|
|
744
|
+
});
|
|
745
|
+
const PostgresSessionLocks = Object.freeze({
|
|
746
|
+
/**
|
|
747
|
+
* Clears all session locks and marks the client after a release failure.
|
|
748
|
+
*
|
|
749
|
+
* @param client PostgreSQL client holding the locks.
|
|
750
|
+
* @param locks Lock keys paired with their shared/exclusive mode.
|
|
751
|
+
* @param operationFailure Earlier operation failure, when present.
|
|
752
|
+
* @returns Completion after all releases are attempted.
|
|
753
|
+
*/
|
|
754
|
+
async cleanup(client, locks, operationFailure) {
|
|
755
|
+
for (const [key, shared] of locks) {
|
|
756
|
+
const cleanup = await this.unlock(client, key, shared).catch((error) => error);
|
|
757
|
+
if (cleanup instanceof Error) {
|
|
758
|
+
PostgresClientDisposal.mark(operationFailure ?? cleanup);
|
|
759
|
+
if (operationFailure === undefined)
|
|
760
|
+
throw cleanup;
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
},
|
|
764
|
+
/**
|
|
765
|
+
* Clears one PostgreSQL advisory session lock.
|
|
766
|
+
*
|
|
767
|
+
* @param client PostgreSQL client holding the lock.
|
|
768
|
+
* @param key Advisory-lock key.
|
|
769
|
+
* @param shared Whether the held lock is shared.
|
|
770
|
+
* @returns Completion after PostgreSQL confirms the release.
|
|
771
|
+
*/
|
|
772
|
+
async unlock(client, key, shared) {
|
|
773
|
+
try {
|
|
774
|
+
const result = await client.query(shared ? "SELECT pg_advisory_unlock_shared($1)" : "SELECT pg_advisory_unlock($1)", [key]);
|
|
775
|
+
const unlocked = shared
|
|
776
|
+
? result.rows[0]?.pg_advisory_unlock_shared
|
|
777
|
+
: result.rows[0]?.pg_advisory_unlock;
|
|
778
|
+
if (unlocked !== true)
|
|
779
|
+
throw new PostgresStorageOperationError("PostgreSQL history cleanup failed.");
|
|
780
|
+
}
|
|
781
|
+
catch (error) {
|
|
782
|
+
if (error instanceof PostgresStorageOperationError)
|
|
783
|
+
throw error;
|
|
784
|
+
throw new PostgresStorageOperationError("PostgreSQL history cleanup failed.");
|
|
785
|
+
}
|
|
786
|
+
},
|
|
787
|
+
});
|
|
788
|
+
/**
|
|
789
|
+
* Adapts current Entity records to the provider Entity-storage contract.
|
|
790
|
+
*
|
|
791
|
+
* @typeParam I Entity identifier type.
|
|
792
|
+
* @typeParam S Entity state message type used for materialized columns.
|
|
793
|
+
*/
|
|
794
|
+
class PostgresCurrentStorage {
|
|
795
|
+
input;
|
|
796
|
+
records;
|
|
797
|
+
/**
|
|
798
|
+
* Creates current Entity storage around one record family.
|
|
799
|
+
*
|
|
800
|
+
* @param input Supplies materialized Entity columns.
|
|
801
|
+
* @param records Stores current Entity records.
|
|
802
|
+
*/
|
|
803
|
+
constructor(input, records) {
|
|
804
|
+
this.input = input;
|
|
805
|
+
this.records = records;
|
|
806
|
+
}
|
|
807
|
+
/**
|
|
808
|
+
* Reads one current Entity record.
|
|
809
|
+
*
|
|
810
|
+
* @param id Entity identifier.
|
|
811
|
+
* @returns The current record, or `undefined` when absent.
|
|
812
|
+
*/
|
|
813
|
+
read(id) {
|
|
814
|
+
return this.records.read(id);
|
|
815
|
+
}
|
|
816
|
+
/**
|
|
817
|
+
* Writes one current Entity record.
|
|
818
|
+
*
|
|
819
|
+
* @param record Current Entity record.
|
|
820
|
+
* @returns Completion after PostgreSQL stores the record.
|
|
821
|
+
*/
|
|
822
|
+
write(record) {
|
|
823
|
+
return this.records.write(record);
|
|
824
|
+
}
|
|
825
|
+
/**
|
|
826
|
+
* Executes one normalized current-Entity query.
|
|
827
|
+
*
|
|
828
|
+
* @param plan Normalized storage query plan.
|
|
829
|
+
* @returns Matching records with materialized column values.
|
|
830
|
+
*/
|
|
831
|
+
async query(plan) {
|
|
832
|
+
const entries = await this.records.queryPlanEntries(plan);
|
|
833
|
+
return entries.map((entry) => ({
|
|
834
|
+
id: entry.id,
|
|
835
|
+
record: entry.record,
|
|
836
|
+
columns: new Map(this.input.columns.map((column) => [column.name, column.valueIn(entry.record)])),
|
|
837
|
+
}));
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
//# sourceMappingURL=entity-history.js.map
|