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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +25 -10
  2. package/REFERENCE.md +66 -50
  3. package/dist/datastore/agent-history.d.ts +36 -6
  4. package/dist/datastore/agent-history.d.ts.map +1 -1
  5. package/dist/datastore/agent-history.js +92 -6
  6. package/dist/datastore/agent-history.js.map +1 -1
  7. package/dist/datastore/column-mapping.d.ts.map +1 -1
  8. package/dist/datastore/column-mapping.js +6 -3
  9. package/dist/datastore/column-mapping.js.map +1 -1
  10. package/dist/datastore/entity-history.d.ts +206 -55
  11. package/dist/datastore/entity-history.d.ts.map +1 -1
  12. package/dist/datastore/entity-history.js +556 -239
  13. package/dist/datastore/entity-history.js.map +1 -1
  14. package/dist/datastore/id-column.d.ts +7 -0
  15. package/dist/datastore/id-column.d.ts.map +1 -1
  16. package/dist/datastore/id-column.js +27 -3
  17. package/dist/datastore/id-column.js.map +1 -1
  18. package/dist/datastore/record-kind.d.ts +8 -0
  19. package/dist/datastore/record-kind.d.ts.map +1 -0
  20. package/dist/datastore/record-kind.js +26 -0
  21. package/dist/datastore/record-kind.js.map +1 -0
  22. package/dist/datastore/record-storage.d.ts +11 -10
  23. package/dist/datastore/record-storage.d.ts.map +1 -1
  24. package/dist/datastore/record-storage.js +31 -32
  25. package/dist/datastore/record-storage.js.map +1 -1
  26. package/dist/datastore/storage-factory.d.ts +293 -89
  27. package/dist/datastore/storage-factory.d.ts.map +1 -1
  28. package/dist/datastore/storage-factory.js +734 -222
  29. package/dist/datastore/storage-factory.js.map +1 -1
  30. package/dist/datastore/tenant-catalog.d.ts +12 -25
  31. package/dist/datastore/tenant-catalog.d.ts.map +1 -1
  32. package/dist/datastore/tenant-catalog.js +43 -121
  33. package/dist/datastore/tenant-catalog.js.map +1 -1
  34. package/dist/datastore/transaction-rows.d.ts +30 -0
  35. package/dist/datastore/transaction-rows.d.ts.map +1 -0
  36. package/dist/datastore/transaction-rows.js +43 -0
  37. package/dist/datastore/transaction-rows.js.map +1 -0
  38. package/dist/index.d.ts +1 -1
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js.map +1 -1
  41. package/dist/tsconfig.tsbuildinfo +1 -1
  42. package/index.yaml +0 -9
  43. package/package.json +5 -5
  44. package/dist/datastore/agent-execution.d.ts +0 -337
  45. package/dist/datastore/agent-execution.d.ts.map +0 -1
  46. package/dist/datastore/agent-execution.js +0 -789
  47. package/dist/datastore/agent-execution.js.map +0 -1
  48. package/dist/datastore/delivery-cleanup.d.ts +0 -31
  49. package/dist/datastore/delivery-cleanup.d.ts.map +0 -1
  50. package/dist/datastore/delivery-cleanup.js +0 -111
  51. package/dist/datastore/delivery-cleanup.js.map +0 -1
@@ -11,16 +11,20 @@
11
11
  * or implied. See the License for the specific language governing permissions and limitations under
12
12
  * the License.
13
13
  */
14
- import { clone, fromBinary, toBinary } from "@bufbuild/protobuf";
14
+ import { clone, toBinary } from "@bufbuild/protobuf";
15
+ import { AgentHistoryEntrySchema, } from "@spine-event-engine/proto/agent";
16
+ import { AgentHistoryKeys, AgentHistoryRecords, AgentModelPreferences, } from "@spine-event-engine/storage/provider";
17
+ import { AnyPacker } from "@spine-event-engine/core";
15
18
  import {} from "@bufbuild/protobuf/wkt";
16
19
  import { EventSchema } from "@spine-event-engine/proto";
17
20
  import { EntityRecordSchema, } from "@spine-event-engine/proto/generated/spine/server/entity/entity_pb.js";
18
- import { eventStoreRecordSpec } from "@spine-event-engine/storage/provider";
19
- import { disabledEventHistoryPort, disabledStateHistoryPort, eventHistorySpec, stateHistorySpec, } from "@spine-event-engine/storage/provider";
21
+ import { eventStoreRecordSpec, EventStoreGroups } from "@spine-event-engine/storage/provider";
22
+ import { disabledEventHistoryPort, disabledStateHistoryPort, eventHistorySpec, stateHistorySpec, EntityCommitSnapshots, } from "@spine-event-engine/storage/provider";
20
23
  import { RecordSpec } from "@spine-event-engine/storage";
21
24
  import { TenantBoundary } from "@spine-event-engine/storage/provider";
22
- import { Datastore } from "@google-cloud/datastore";
23
25
  import { DatastoreRecordStorage } from "./record-storage.js";
26
+ import { AgentHistoryHash } from "./agent-history.js";
27
+ import { NativeTransactions } from "./transaction-rows.js";
24
28
  /**
25
29
  * Groups Entity current and optional history record families.
26
30
  *
@@ -70,8 +74,19 @@ export class DatastoreEntityStorage {
70
74
  close() {
71
75
  if (this.#open) {
72
76
  this.#open = false;
73
- for (const record of this.#records)
74
- record.close();
77
+ const errors = [];
78
+ for (const record of this.#records) {
79
+ try {
80
+ record.close();
81
+ }
82
+ catch (error) {
83
+ errors.push(error);
84
+ }
85
+ }
86
+ if (errors.length === 1)
87
+ throw errors[0];
88
+ if (errors.length)
89
+ throw new AggregateError(errors, "Datastore Entity records close failed.");
75
90
  }
76
91
  }
77
92
  /**
@@ -84,183 +99,574 @@ export class DatastoreEntityStorage {
84
99
  }
85
100
  }
86
101
  /**
87
- * Coordinates an Entity mutation through one Datastore transaction.
102
+ * Writes one Datastore Entity mutation in independent record order.
88
103
  */
89
104
  export class DatastoreEntityCommitStorage {
90
105
  input;
91
106
  openRecords;
107
+ onClose;
92
108
  #open = true;
109
+ #current;
110
+ /**
111
+ * Retained state-history records.
112
+ */
113
+ #states;
114
+ #diagnostics;
115
+ #events;
116
+ #history;
117
+ #preferences;
93
118
  /**
94
- * Creates transactional commit storage for one Entity persistence contract.
119
+ * Synchronous idle-close result, including any cleanup failure.
120
+ */
121
+ #idleOutcome;
122
+ #active = new Set();
123
+ #closing;
124
+ /**
125
+ * Creates commit storage for one Entity persistence contract.
95
126
  *
96
- * @param input The Entity persistence contract served by this instance.
97
- * @param openRecords The function that opens generated record families.
127
+ * @param input Defines the Entity source and tenant boundary.
128
+ * @param openRecords Opens each generated record family.
129
+ * @param onClose Releases factory tracking after terminal cleanup.
98
130
  */
99
- constructor(input, openRecords) {
131
+ constructor(input, openRecords, onClose = () => undefined) {
100
132
  this.input = input;
101
133
  this.openRecords = openRecords;
134
+ this.onClose = onClose;
102
135
  }
103
136
  /**
104
- * Commits one Entity state transition and its generated history records.
137
+ * Writes histories and delivery Events before the current Entity record.
138
+ *
139
+ * A failed save may leave a completed prefix. One transient retry replays
140
+ * captured records with the same versions and Event IDs.
105
141
  *
106
142
  * @typeParam I Entity identifier type.
107
143
  * @typeParam S Entity state message type.
108
- * @param input The Entity mutation to commit.
109
- * @returns Completion after Datastore commits the mutation.
110
- */
111
- async commit(input) {
112
- this.validate(input);
113
- const current = this.openRecords(input.entity.recordSpec);
114
- const stateLayout = stateHistorySpec(input.entity.stateSchema);
115
- const diagnosticLayout = eventHistorySpec(input.entity.stateSchema);
116
- const states = input.entity.stateHistory
117
- ? this.openRecords(stateLayout.spec, stateLayout.group)
144
+ * @param input Supplies the prepared Entity mutation.
145
+ * @returns Completion after every requested record write.
146
+ */
147
+ commit(input) {
148
+ if (!this.#open)
149
+ return Promise.reject(new Error("Entity commit storage is closed."));
150
+ return this.track(() => {
151
+ this.validate(input);
152
+ const prepared = EntityCommitSnapshots.capture(input);
153
+ return this.persist(prepared);
154
+ });
155
+ }
156
+ /**
157
+ * Registers a commit before invoking provider construction callbacks.
158
+ *
159
+ * @param action The admitted provider operation.
160
+ * @returns The admitted provider operation completion.
161
+ * @typeParam T Result type of the admitted provider operation.
162
+ */
163
+ track(action) {
164
+ let complete;
165
+ let fail;
166
+ const work = new Promise((resolve, reject) => {
167
+ complete = resolve;
168
+ fail = reject;
169
+ });
170
+ this.#active.add(work);
171
+ void work.then(() => this.#active.delete(work), () => this.#active.delete(work));
172
+ try {
173
+ action().then(complete, fail);
174
+ }
175
+ catch (error) {
176
+ fail(error);
177
+ }
178
+ return work;
179
+ }
180
+ /**
181
+ * Reads a detached current preference image from native storage.
182
+ * @param entityId Typed Agent identifier.
183
+ * @returns Complete selections or absent record.
184
+ */
185
+ readAgentPreferences(entityId) {
186
+ if (!this.#open)
187
+ return Promise.reject(new Error("Entity commit storage is closed."));
188
+ return this.track(async () => {
189
+ const key = AgentModelPreferences.key(this.input, entityId);
190
+ const rows = this.agentRows();
191
+ const slot = `v1:${AgentHistoryHash.value(AgentModelPreferences.text(key))}`;
192
+ const found = await rows.preferences.read(slot);
193
+ if (found === undefined)
194
+ return undefined;
195
+ AgentModelPreferences.requireIdentity(found, key);
196
+ return AgentModelPreferences.copy(found.preferences);
197
+ });
198
+ }
199
+ /**
200
+ * Commits Agent state, final audit, and preferences in one native transaction.
201
+ * @param input Captured live Agent transition.
202
+ * @returns Completion after a conditional group commits.
203
+ * @typeParam I Typed Agent identifier.
204
+ * @typeParam S Generated Agent state.
205
+ */
206
+ commitAgent(input) {
207
+ if (!this.#open)
208
+ return Promise.reject(new Error("Entity commit storage is closed."));
209
+ return this.track(async () => {
210
+ const id = this.input.id.clone(input.entityId);
211
+ const key = AgentModelPreferences.key(this.input, id);
212
+ const expected = input.expectedPreferences === undefined
213
+ ? undefined
214
+ : AgentModelPreferences.copy(input.expectedPreferences);
215
+ const next = AgentModelPreferences.record(key, input.preferences);
216
+ const mutation = input.entityCommit && EntityCommitSnapshots.capture(input.entityCommit);
217
+ const history = input.historyEntries?.map((entry) => clone(AgentHistoryEntrySchema, entry)) ?? [];
218
+ this.validateAgent(id, input.expectedVersion, mutation);
219
+ const rows = this.agentRows(mutation);
220
+ await this.agentTransaction(rows, id, input.expectedVersion, expected, next, mutation, history);
221
+ });
222
+ }
223
+ /**
224
+ * Acquires every missing native family before publishing retained references.
225
+ * @param mutation Optional Entity mutation requiring history families.
226
+ * @returns Native handles for one Agent commit.
227
+ */
228
+ agentRows(mutation) {
229
+ const acquired = [];
230
+ try {
231
+ const rows = this.acquireAgentRows(mutation, acquired);
232
+ this.retainAgentRows(rows);
233
+ return rows;
234
+ }
235
+ catch (error) {
236
+ const failures = this.closeGroup(acquired);
237
+ if (failures.length)
238
+ throw new AggregateError([error, ...failures], "Datastore Agent family acquisition failed.");
239
+ throw error;
240
+ }
241
+ }
242
+ /**
243
+ * Acquires only missing native Agent families without publishing partial acquisition.
244
+ * @param mutation Optional Entity mutation.
245
+ * @param acquired Collects newly opened handles for rollback.
246
+ * @returns Complete native family group.
247
+ */
248
+ acquireAgentRows(mutation, acquired) {
249
+ const current = this.native(this.#current ?? this.openAgentRecord(this.input.recordSpec, undefined, acquired));
250
+ const { states, diagnostics, events } = this.agentMutationRows(mutation, acquired);
251
+ const history = this.#history ??
252
+ this.openAgentRecord(AgentHistoryRecords.spec((value) => AgentHistoryHash.value(value)), AgentHistoryRecords.group, acquired);
253
+ const preferences = this.#preferences ??
254
+ this.openAgentRecord(AgentModelPreferences.spec((value) => AgentHistoryHash.value(value)), AgentModelPreferences.group, acquired);
255
+ return {
256
+ current,
257
+ states,
258
+ diagnostics,
259
+ events,
260
+ history,
261
+ preferences,
262
+ };
263
+ }
264
+ /**
265
+ * Acquires mutation families needed by the admitted Agent turn.
266
+ * @param mutation Optional changed Entity mutation.
267
+ * @param acquired Newly opened handles retained for rollback.
268
+ * @returns Optional mutation families.
269
+ */
270
+ agentMutationRows(mutation, acquired) {
271
+ const stateLayout = stateHistorySpec(this.input.stateSchema);
272
+ const eventLayout = eventHistorySpec(this.input.stateSchema);
273
+ const states = mutation?.states?.length
274
+ ? this.native(this.#states ?? this.openAgentRecord(stateLayout.spec, stateLayout.group, acquired))
275
+ : undefined;
276
+ const diagnostics = mutation?.diagnostics?.length
277
+ ? this.native(this.#diagnostics ?? this.openAgentRecord(eventLayout.spec, eventLayout.group, acquired))
118
278
  : undefined;
119
- const diagnostics = input.entity.eventHistory
120
- ? this.openRecords(diagnosticLayout.spec, diagnosticLayout.group)
279
+ const events = mutation?.events?.length
280
+ ? this.native(this.#events ??
281
+ this.openAgentRecord(eventStoreRecordSpec, EventStoreGroups.forContext(this.input.context.name), acquired))
121
282
  : undefined;
122
- const events = this.openRecords(eventStoreRecordSpec);
283
+ return { states, diagnostics, events };
284
+ }
285
+ /**
286
+ * Acquires one native record family and records its rollback obligation.
287
+ * @param spec Declarative record layout.
288
+ * @param group Optional physical family.
289
+ * @param acquired Newly opened handles.
290
+ * @returns Native record family.
291
+ * @typeParam Id Typed record identifier.
292
+ * @typeParam R Generated record.
293
+ */
294
+ openAgentRecord(spec, group, acquired) {
295
+ const port = this.openRecords(spec, group);
296
+ acquired.push(port);
297
+ return this.native(port);
298
+ }
299
+ /**
300
+ * Stores the complete acquired Agent group after every family succeeds.
301
+ * @param rows Complete native family group.
302
+ */
303
+ retainAgentRows(rows) {
304
+ this.#current ??= rows.current;
305
+ this.#states ??= rows.states;
306
+ this.#diagnostics ??= rows.diagnostics;
307
+ this.#events ??= rows.events;
308
+ this.#history ??= rows.history;
309
+ this.#preferences ??= rows.preferences;
310
+ }
311
+ /**
312
+ * Checks the built-in native transaction capability.
313
+ * @param port Selected record family.
314
+ * @returns Native record family.
315
+ * @typeParam Id Typed record identifier.
316
+ * @typeParam R Generated record.
317
+ */
318
+ native(port) {
319
+ if (!(port instanceof DatastoreRecordStorage))
320
+ throw new Error("Live Agent commit requires native Datastore transactions.");
321
+ return port;
322
+ }
323
+ /**
324
+ * Validates the Entity scope and exactly one next Version before I/O.
325
+ * @param id Captured Agent ID.
326
+ * @param version Initial Entity Version.
327
+ * @param mutation Optional prepared Entity mutation.
328
+ * @typeParam I Typed Entity identifier.
329
+ * @typeParam S Generated Entity state.
330
+ */
331
+ validateAgent(id, version, mutation) {
332
+ if (!Number.isSafeInteger(version) || version < 0)
333
+ throw new TypeError("Agent commit requires a valid initial Entity Version.");
334
+ if (mutation === undefined)
335
+ return;
336
+ this.validate(mutation);
337
+ if (this.input.id.key(id) !== this.input.id.key(mutation.entityId) ||
338
+ mutation.next.version?.number !== version + 1)
339
+ throw new Error("Agent commit requires the same Entity ID and one new Version.");
340
+ }
341
+ /**
342
+ * Executes a captured Agent transition in one native transaction.
343
+ * @param rows Retained native families.
344
+ * @param id Captured Agent ID.
345
+ * @param version Initial Entity version.
346
+ * @param expected Initial preference image.
347
+ * @param next Complete replacement preferences.
348
+ * @param mutation Optional Entity mutation.
349
+ * @param history Final audit entries.
350
+ * @typeParam I Typed Entity identifier.
351
+ * @typeParam S Generated Entity state.
352
+ * @returns Completion after the native transaction settles.
353
+ */
354
+ async agentTransaction(rows, id, version, expected, next, mutation, history) {
355
+ const tx = rows.preferences.transaction();
123
356
  try {
124
- await this.run(input, current, states, diagnostics, events);
357
+ await tx.run();
358
+ const staged = [];
359
+ await this.agentLocked(tx, staged, rows, id, version, expected, next, mutation, history);
360
+ NativeTransactions.validateRows(staged);
361
+ for (const row of staged)
362
+ tx.save(row);
363
+ await tx.commit();
125
364
  }
126
- finally {
127
- this.closeRecords(current, states, diagnostics, events);
365
+ catch (error) {
366
+ await tx.rollback().catch(() => undefined);
367
+ throw error;
128
368
  }
129
369
  }
130
370
  /**
131
- * Retries the prepared Datastore transaction after bounded aborts.
132
- *
133
- * @typeParam I Entity identifier type.
134
- * @typeParam S Entity state type.
135
- * @param input Supplies current and immutable records.
136
- * @param current Provides current Entity storage.
137
- * @param states Provides state history when enabled.
138
- * @param diagnostics Provides diagnostic history when enabled.
139
- * @param events Provides delivery Event storage.
140
- * @returns Completion after a successful transaction.
141
- */
142
- async run(input, current, states, diagnostics, events) {
143
- const prepared = this.prepare(input, current, states, diagnostics, events);
144
- validateCommitSize(prepared.map((row) => row.entity));
145
- for (let attempt = 0; attempt < 3; attempt += 1) {
146
- try {
147
- await this.tryCommit(current, prepared);
148
- return;
149
- }
150
- catch (error) {
151
- if (isAborted(error) && attempt < 2) {
152
- await abortBackoff(attempt);
153
- continue;
154
- }
155
- throw entityTransactionError(error);
156
- }
371
+ * Checks both current images and stages the complete final group.
372
+ * @param tx Native transaction.
373
+ * @param staged Rows saved only after all checks pass.
374
+ * @param rows Retained native families.
375
+ * @param id Captured Agent identifier.
376
+ * @param version Initial Entity Version.
377
+ * @param expected Initial preference image.
378
+ * @param next Replacement preference record.
379
+ * @param mutation Optional Entity mutation.
380
+ * @param history Final audit entries.
381
+ * @typeParam I Typed Entity identifier.
382
+ * @typeParam S Generated Entity state.
383
+ * @returns Completion after all conditional rows are staged.
384
+ */
385
+ async agentLocked(tx, staged, rows, id, version, expected, next, mutation, history) {
386
+ if (next.key === undefined)
387
+ throw new Error("Agent preference identity is missing.");
388
+ const slot = `v1:${AgentHistoryHash.value(AgentModelPreferences.text(next.key))}`;
389
+ const prior = await this.agentRead(tx, rows.preferences, slot);
390
+ if (prior !== undefined)
391
+ AgentModelPreferences.requireIdentity(prior, next.key);
392
+ if (!AgentModelPreferences.same(prior?.preferences, expected))
393
+ throw new Error("Agent model preferences changed during the live turn.");
394
+ const current = await this.agentRead(tx, rows.current, id);
395
+ if ((current?.version?.number ?? 0) !== version)
396
+ throw new Error("Agent initial Entity Version is no longer current.");
397
+ for (const record of mutation?.states ?? [])
398
+ await this.agentImmutable(tx, staged, this.requiredNative(rows.states), record);
399
+ for (const event of mutation?.diagnostics ?? [])
400
+ await this.agentImmutable(tx, staged, this.requiredNative(rows.diagnostics), event);
401
+ for (const event of mutation?.events ?? [])
402
+ await this.agentImmutable(tx, staged, this.requiredNative(rows.events), event);
403
+ if (mutation !== undefined)
404
+ staged.push(rows.current.transactionEntity(mutation.next));
405
+ await this.appendAgentAudit(tx, staged, rows.history, id, history);
406
+ staged.push(rows.preferences.transactionEntity(next));
407
+ }
408
+ /**
409
+ * Records final Agent audit rows in the admitted native transaction.
410
+ * @param history Final Agent audit entries.
411
+ * @param id Captured typed Agent identifier.
412
+ * @param staged Rows staged for atomic commit.
413
+ * @param storage Retained provider record family.
414
+ * @param tx Active native transaction.
415
+ * @returns Completion after final audit rows are staged.
416
+ */
417
+ async appendAgentAudit(tx, staged, storage, id, history) {
418
+ for (const entry of history) {
419
+ if (AgentHistoryKeys.indexValue(AgentHistoryKeys.fromEntry(entry)).length > 1500)
420
+ throw new RangeError("Agent history order exceeds Datastore indexed-value capacity.");
421
+ const record = AgentHistoryRecords.record(this.input.stateSchema.typeName, this.input.id.key(id), entry);
422
+ await this.agentImmutable(tx, staged, storage, record);
157
423
  }
158
- throw new Error("Datastore transaction did not complete.");
159
424
  }
160
425
  /**
161
- * Builds affected rows before opening a transaction.
426
+ * Reads a native row within the Agent transaction.
427
+ * @param tx Active native transaction.
428
+ * @param storage Retained record family.
429
+ * @param id Typed record identifier.
430
+ * @returns Decoded row or absence.
431
+ * @typeParam Id Typed record identifier.
432
+ * @typeParam R Generated record.
433
+ */
434
+ async agentRead(tx, storage, id) {
435
+ const raw = NativeTransactions.first((await tx.get(storage.transactionKey(id), { wrapNumbers: true })));
436
+ return raw === undefined ? undefined : storage.decodeTransactionEntity(raw);
437
+ }
438
+ /**
439
+ * Checks one immutable final record before staging it.
440
+ * @param tx Active native transaction.
441
+ * @param staged Collected native rows.
442
+ * @param storage Retained record family.
443
+ * @param record Proposed immutable record.
444
+ * @typeParam Id Typed record identifier.
445
+ * @typeParam R Generated record.
446
+ * @returns Completion after the immutable row is checked and staged.
447
+ */
448
+ async agentImmutable(tx, staged, storage, record) {
449
+ const raw = NativeTransactions.first((await tx.get(storage.transactionEntity(record).key, { wrapNumbers: true })));
450
+ if (raw === undefined)
451
+ staged.push(storage.transactionEntity(record));
452
+ else if (!storage.matchesTransactionEntity(raw, record))
453
+ throw new Error("Immutable Agent history record has divergent content.");
454
+ }
455
+ /**
456
+ * Checks an enabled native history family.
457
+ * @param storage Optional native family.
458
+ * @returns Enabled native record family.
459
+ * @typeParam Id Typed record ID.
460
+ * @typeParam R Generated record.
461
+ */
462
+ requiredNative(storage) {
463
+ if (storage === undefined)
464
+ throw new Error("Agent Entity history storage is disabled.");
465
+ return storage;
466
+ }
467
+ /**
468
+ * Persists an admitted captured mutation and one safe retry.
162
469
  *
163
- * @typeParam I Entity identifier type.
164
- * @param input Supplies records to materialize.
165
- * @param current Provides current Entity storage.
166
- * @param states Provides state history when enabled.
167
- * @param diagnostics Provides diagnostic history when enabled.
168
- * @param events Provides delivery Event storage.
169
- * @returns Deduplicated rows for the transaction.
470
+ * @param prepared The immutable prepared mutation.
471
+ * @returns Completion after the captured mutation is persisted.
472
+ * @typeParam I Entity identifier or record type.
473
+ * @typeParam S Generated Entity state message type.
170
474
  */
171
- prepare(input, current, states, diagnostics, events) {
172
- return coalesceImmutableRows([
173
- ...preparedRows(current, [input.next], false),
174
- ...preparedRows(states, input.states ?? [], true),
175
- ...preparedRows(diagnostics, input.diagnostics ?? [], true),
176
- ...preparedRows(events, input.events ?? [], true),
177
- ]);
475
+ async persist(prepared) {
476
+ const [current, states, diagnostics, events] = this.openCommit(prepared);
477
+ try {
478
+ await this.apply(prepared, current, states, diagnostics, events);
479
+ }
480
+ catch (error) {
481
+ if (!this.retryable(error))
482
+ throw error;
483
+ await this.apply(prepared, current, states, diagnostics, events);
484
+ }
178
485
  }
179
486
  /**
180
- * Applies one prepared transaction attempt and rolls back on failure.
487
+ * Acquires only missing requested families and rolls back a failed group.
181
488
  *
182
- * @typeParam I Entity identifier type.
183
- * @param current Opens a transaction for the Entity scope.
184
- * @param prepared Supplies current and immutable rows.
185
- * @returns Completion after the transaction commits.
489
+ * @param input The captured Entity persistence input.
490
+ * @returns The retained records required by this commit.
491
+ * @typeParam I Entity identifier or record type.
492
+ * @typeParam S Generated Entity state message type.
186
493
  */
187
- async tryCommit(current, prepared) {
188
- const transaction = current.transaction();
494
+ openCommit(input) {
495
+ const acquired = [];
189
496
  try {
190
- await transaction.run();
191
- const values = await this.load(transaction, prepared);
192
- this.validateImmutable(prepared, values);
193
- this.apply(transaction, prepared, values);
194
- await transaction.commit();
497
+ const current = this.#current ?? this.openRecords(this.input.recordSpec);
498
+ if (this.#current === undefined)
499
+ acquired.push(current);
500
+ const stateLayout = stateHistorySpec(this.input.stateSchema);
501
+ const diagnosticLayout = eventHistorySpec(this.input.stateSchema);
502
+ const states = input.states?.length
503
+ ? (this.#states ?? this.openRecords(stateLayout.spec, stateLayout.group))
504
+ : undefined;
505
+ if (states !== undefined && this.#states === undefined)
506
+ acquired.push(states);
507
+ const diagnostics = input.diagnostics?.length
508
+ ? (this.#diagnostics ?? this.openRecords(diagnosticLayout.spec, diagnosticLayout.group))
509
+ : undefined;
510
+ if (diagnostics !== undefined && this.#diagnostics === undefined)
511
+ acquired.push(diagnostics);
512
+ const events = input.events?.length
513
+ ? (this.#events ??
514
+ this.openRecords(eventStoreRecordSpec, EventStoreGroups.forContext(this.input.context.name)))
515
+ : undefined;
516
+ if (events !== undefined && this.#events === undefined)
517
+ acquired.push(events);
518
+ this.#current = current;
519
+ this.#states ??= states;
520
+ this.#diagnostics ??= diagnostics;
521
+ this.#events ??= events;
522
+ return [current, states, diagnostics, events];
195
523
  }
196
524
  catch (error) {
197
- await rollback(transaction);
525
+ const failures = this.closeGroup(acquired);
526
+ if (failures.length)
527
+ throw new AggregateError([error, ...failures], "Datastore family acquisition failed.");
198
528
  throw error;
199
529
  }
200
530
  }
201
531
  /**
202
- * Loads affected keys in stable order within one transaction.
532
+ * Writes independent record families in Entity commit order.
203
533
  *
204
- * @param transaction Supplies the active Datastore transaction.
205
- * @param prepared Supplies affected rows.
206
- * @returns Existing data indexed by key.
534
+ * @typeParam I Entity identifier type.
535
+ * @typeParam S Entity state message type.
536
+ * @param input Supplies immutable prepared records.
537
+ * @param current Stores current Entity state.
538
+ * @param states Stores state history when requested.
539
+ * @param diagnostics Stores diagnostic Events when requested.
540
+ * @param events Stores delivery Events when requested.
541
+ * @returns Completion after every requested record write.
207
542
  */
208
- async load(transaction, prepared) {
209
- const keys = [...uniqueKeys(prepared.map((row) => row.entity.key))].sort((left, right) => keyId(left).localeCompare(keyId(right)));
210
- const values = [];
211
- for (const key of keys)
212
- values.push([
213
- keyId(key),
214
- first(await transaction.get(key)),
215
- ]);
216
- return new Map(values);
543
+ async apply(input, current, states, diagnostics, events) {
544
+ if (states !== undefined)
545
+ for (const record of input.states ?? [])
546
+ await immutable(states, record);
547
+ if (diagnostics !== undefined)
548
+ for (const event of input.diagnostics ?? [])
549
+ await immutable(diagnostics, event);
550
+ if (events !== undefined)
551
+ for (const event of input.events ?? [])
552
+ await immutable(events, event);
553
+ const previous = await current.read(input.entityId);
554
+ if (previous === undefined ||
555
+ !same(toBinary(EntityRecordSchema, previous), toBinary(EntityRecordSchema, input.next)))
556
+ await current.write(input.next);
217
557
  }
218
558
  /**
219
- * Rejects divergent data already stored under immutable keys.
559
+ * Determines whether a Datastore abort occurred through provider error causes.
220
560
  *
221
- * @param prepared Supplies proposed immutable rows.
222
- * @param values Supplies existing data for affected keys.
561
+ * @param error Supplies the operation failure.
562
+ * @returns Whether one captured-input replay is safe.
223
563
  */
224
- validateImmutable(prepared, values) {
225
- for (const row of prepared.filter((item) => item.immutable)) {
226
- const previous = values.get(keyId(row.entity.key));
227
- if (previous !== undefined && !sameData(previous, row.entity.data))
228
- throw new Error("Immutable history record has divergent content.");
564
+ retryable(error) {
565
+ let current = error;
566
+ for (let depth = 0; depth < 4; depth += 1) {
567
+ if (typeof current !== "object" || current === null)
568
+ return false;
569
+ const failure = current;
570
+ if (failure.code === 10)
571
+ return true;
572
+ current = failure.cause;
229
573
  }
574
+ return false;
230
575
  }
231
576
  /**
232
- * Writes missing immutable rows and changed current state in the transaction.
577
+ * Closes this commit storage to further commits.
233
578
  *
234
- * @param transaction Supplies the active Datastore transaction.
235
- * @param prepared Supplies current and immutable rows.
236
- * @param values Supplies existing data for affected keys.
579
+ * @returns Completion after admitted work and record cleanup settle.
237
580
  */
238
- apply(transaction, prepared, values) {
239
- for (const row of prepared) {
240
- const previous = values.get(keyId(row.entity.key));
241
- if (previous === undefined || (!row.immutable && !sameData(previous, row.entity.data)))
242
- transaction.save(row.entity);
581
+ close() {
582
+ this.#open = false;
583
+ if (this.#idleOutcome !== undefined)
584
+ return (this.#closing ??=
585
+ this.#idleOutcome.error === undefined
586
+ ? Promise.resolve()
587
+ : Promise.reject(this.#idleOutcome.error));
588
+ return (this.#closing ??= this.drain());
589
+ }
590
+ /**
591
+ * Closes only when no commit remains admitted.
592
+ */
593
+ closeIdle() {
594
+ this.assertIdle();
595
+ if (this.#idleOutcome !== undefined) {
596
+ if (this.#idleOutcome.error !== undefined)
597
+ throw this.#idleOutcome.error;
598
+ return;
243
599
  }
600
+ this.#open = false;
601
+ const failure = this.closeRetained();
602
+ this.#idleOutcome = failure === undefined ? {} : { error: failure };
603
+ this.onClose();
604
+ if (failure !== undefined)
605
+ throw failure;
244
606
  }
245
607
  /**
246
- * Closes temporary record-family handles after the commit attempt.
608
+ * Rejects synchronous disposal while asynchronous work remains admitted.
609
+ */
610
+ assertIdle() {
611
+ if (this.#active.size || (this.#closing !== undefined && this.#idleOutcome === undefined))
612
+ throw new Error("Entity commit cannot close idle while work is active.");
613
+ }
614
+ /**
615
+ * Waits for every admitted commit before completing close.
247
616
  *
248
- * @param current Supplies current Entity storage.
249
- * @param states Supplies state history when enabled.
250
- * @param diagnostics Supplies diagnostic history when enabled.
251
- * @param events Supplies delivery Event storage.
617
+ * @returns Completion after admitted work and cleanup settle.
252
618
  */
253
- closeRecords(current, states, diagnostics, events) {
254
- current.close();
255
- states?.close();
256
- diagnostics?.close();
257
- events.close();
619
+ async drain() {
620
+ await Promise.allSettled([...this.#active]);
621
+ const failure = this.closeRetained();
622
+ this.onClose();
623
+ if (failure !== undefined)
624
+ throw failure;
258
625
  }
259
626
  /**
260
- * Closes this commit storage to further commits.
627
+ * Closes every retained record once after admitted work settles.
628
+ *
629
+ * @returns A cleanup failure, if any.
261
630
  */
262
- close() {
263
- this.#open = false;
631
+ closeRetained() {
632
+ const ports = [
633
+ this.#events,
634
+ this.#diagnostics,
635
+ this.#states,
636
+ this.#history,
637
+ this.#preferences,
638
+ this.#current,
639
+ ].filter((port) => port !== undefined);
640
+ this.#events = undefined;
641
+ this.#diagnostics = undefined;
642
+ this.#states = undefined;
643
+ this.#history = undefined;
644
+ this.#preferences = undefined;
645
+ this.#current = undefined;
646
+ const failures = this.closeGroup(ports);
647
+ if (failures.length === 1)
648
+ return failures[0];
649
+ return failures.length
650
+ ? new AggregateError(failures, "Datastore commit close failed.")
651
+ : undefined;
652
+ }
653
+ /**
654
+ * Closes every newly acquired handle and collects cleanup failures.
655
+ *
656
+ * @param ports The acquired record handles.
657
+ * @returns Cleanup failures from acquired handles.
658
+ */
659
+ closeGroup(ports) {
660
+ const failures = [];
661
+ for (const port of ports) {
662
+ try {
663
+ port.close();
664
+ }
665
+ catch (error) {
666
+ failures.push(error);
667
+ }
668
+ }
669
+ return failures;
264
670
  }
265
671
  /**
266
672
  * Validates the Entity, tenant, history, and Event boundaries of a commit.
@@ -270,15 +676,16 @@ export class DatastoreEntityCommitStorage {
270
676
  * @param input Supplies the proposed commit.
271
677
  */
272
678
  validate(input) {
273
- if (!this.#open)
274
- throw new Error("Entity commit storage is closed.");
275
679
  if (input.entity.sourceType.typeName !== this.input.sourceType.typeName ||
276
- TenantBoundary.of(input.context).key !== TenantBoundary.of(this.input.context).key)
680
+ input.entity.stateSchema.typeName !== this.input.stateSchema.typeName ||
681
+ TenantBoundary.of(input.context).key !== TenantBoundary.of(this.input.context).key ||
682
+ TenantBoundary.of(input.entity.context).key !== TenantBoundary.of(this.input.context).key)
277
683
  throw new Error("Entity commit handle cannot commit another Entity source or tenant.");
278
684
  validateCommitEntityId(input);
279
- if ((input.states?.length ?? 0) > 0 && !input.entity.stateHistory)
685
+ if ((input.states?.length ?? 0) > 0 && (!this.input.stateHistory || !input.entity.stateHistory))
280
686
  throw new Error("Entity commit cannot append state history when it is disabled.");
281
- if ((input.diagnostics?.length ?? 0) > 0 && !input.entity.eventHistory)
687
+ if ((input.diagnostics?.length ?? 0) > 0 &&
688
+ (!this.input.eventHistory || !input.entity.eventHistory))
282
689
  throw new Error("Entity commit cannot append event history when it is disabled.");
283
690
  validateEvents(input.events ?? []);
284
691
  }
@@ -404,7 +811,7 @@ class StateHistory {
404
811
  const found = page.entries[0]?.record;
405
812
  return found?.state === undefined
406
813
  ? undefined
407
- : fromBinary(this.input.stateSchema, found.state.value);
814
+ : AnyPacker.unpack(found.state, this.input.stateSchema);
408
815
  }
409
816
  /**
410
817
  * Stores no more than the newest state-history records for an Entity.
@@ -416,6 +823,8 @@ class StateHistory {
416
823
  async trim(id, keep) {
417
824
  this.requireOpen();
418
825
  requireKeep(keep);
826
+ let retained = 0;
827
+ let cursor;
419
828
  for (;;) {
420
829
  const page = await this.provider().queryProviderPage({
421
830
  filters: [entityFilter(this.input, id)],
@@ -424,11 +833,15 @@ class StateHistory {
424
833
  { property: "created", direction: "desc" },
425
834
  ],
426
835
  limit: 128,
836
+ ...(cursor === undefined ? {} : { cursor }),
427
837
  });
428
- const removable = page.entries.slice(keep);
838
+ const retainHere = Math.min(page.entries.length, Math.max(0, keep - retained));
839
+ retained += retainHere;
840
+ const removable = page.entries.slice(retainHere);
429
841
  await this.provider().deleteProviderEntries(removable);
430
842
  if (!page.hasMore)
431
843
  break;
844
+ cursor = page.cursor;
432
845
  }
433
846
  }
434
847
  /**
@@ -617,58 +1030,17 @@ class EventHistory {
617
1030
  */
618
1031
  async function immutable(records, record) {
619
1032
  const id = records.recordSpec.idValueIn(record);
620
- if (await records.compareAndSet(id, undefined, record))
621
- return;
622
- const existing = await records.read(id);
623
- if (existing === undefined)
624
- throw new Error("Immutable history record was not retained.");
625
- if (!same(toBinary(records.recordSpec.recordType, existing), toBinary(records.recordSpec.recordType, record)))
626
- throw new Error("Immutable history record has divergent content.");
627
- }
628
- /**
629
- * Prepares Datastore rows for one record family.
630
- *
631
- * @typeParam I Record identifier type.
632
- * @typeParam R Stored Protobuf message type.
633
- * @param storage Provides the record-family handle when enabled.
634
- * @param records Supplies records to prepare.
635
- * @param immutable Marks whether existing rows must remain unchanged.
636
- * @returns The prepared rows for the transaction.
637
- */
638
- function preparedRows(storage, records, immutable) {
639
- if (storage === undefined) {
640
- if (records.length > 0)
641
- throw new Error("Entity commit history is disabled.");
642
- return [];
643
- }
644
- return records.map((record) => ({
645
- storage,
646
- record,
647
- immutable,
648
- entity: storage.transactionEntity(record),
649
- }));
650
- }
651
- /**
652
- * Removes duplicate immutable rows and rejects conflicting content.
653
- *
654
- * @typeParam R Prepared row type.
655
- * @param rows Supplies prepared current and history rows.
656
- * @returns Rows with duplicate immutable keys removed.
657
- */
658
- function coalesceImmutableRows(rows) {
659
- const seen = new Map();
660
- return rows.filter((row) => {
661
- if (!row.immutable)
662
- return true;
663
- const previous = seen.get(keyId(row.entity.key));
664
- if (previous === undefined) {
665
- seen.set(keyId(row.entity.key), row);
666
- return true;
667
- }
668
- if (!sameData(previous.entity.data, row.entity.data))
1033
+ for (let attempt = 0; attempt < 3; attempt += 1) {
1034
+ if (await records.compareAndSet(id, undefined, record))
1035
+ return;
1036
+ const existing = await records.read(id);
1037
+ if (existing === undefined)
1038
+ continue;
1039
+ if (!same(toBinary(records.recordSpec.recordType, existing), toBinary(records.recordSpec.recordType, record)))
669
1040
  throw new Error("Immutable history record has divergent content.");
670
- return false;
671
- });
1041
+ return;
1042
+ }
1043
+ throw new Error("Immutable history record was not retained.");
672
1044
  }
673
1045
  function same(left, right) {
674
1046
  return left.length === right.length && left.every((value, index) => value === right[index]);
@@ -699,61 +1071,6 @@ function numberFilter(property, operator, value) {
699
1071
  function timestampFilter(property, operator, value) {
700
1072
  return { property, operator, value };
701
1073
  }
702
- function first(value) {
703
- return Array.isArray(value) ? value[0] : undefined;
704
- }
705
- function keyId(key) {
706
- return JSON.stringify(key);
707
- }
708
- function uniqueKeys(keys) {
709
- return [...new Map(keys.map((key) => [keyId(key), key])).values()];
710
- }
711
- function sameData(left, right) {
712
- if (left === undefined || right === undefined)
713
- return left === right;
714
- const bytes = left.bytes;
715
- return (bytes instanceof Uint8Array && right.bytes instanceof Uint8Array && same(bytes, right.bytes));
716
- }
717
- function isAborted(error) {
718
- return typeof error === "object" && error !== null && Reflect.get(error, "code") === 10;
719
- }
720
- function entityTransactionError(error) {
721
- if (error instanceof Error && error.message.startsWith("Immutable history record"))
722
- return error;
723
- return new Error("Datastore Entity transaction failed.");
724
- }
725
- function abortBackoff(attempt) {
726
- const delayMs = 20 * (attempt + 1) + Math.floor(Math.random() * 40);
727
- return new Promise((resolve) => setTimeout(resolve, delayMs));
728
- }
729
- /**
730
- * Rolls back a failed Datastore transaction when possible.
731
- *
732
- * @param transaction Supplies the transaction to roll back.
733
- * @returns Completion after rollback finishes or fails.
734
- */
735
- async function rollback(transaction) {
736
- try {
737
- await transaction.rollback();
738
- }
739
- catch {
740
- /* original provider error remains authoritative */
741
- }
742
- }
743
- function validateCommitSize(rows) {
744
- if (rows.length > 500)
745
- throw new Error("Entity commit exceeds the 500-mutation limit.");
746
- if (uniqueKeys(rows.map((row) => row.key)).length > 25)
747
- throw new Error("Entity commit exceeds the 25 entity-group limit.");
748
- const bytes = rows.reduce((total, row) => total +
749
- Buffer.byteLength(JSON.stringify(row.data, (_key, value) => value instanceof Uint8Array
750
- ? Buffer.from(value).toString("base64")
751
- : typeof value === "bigint"
752
- ? value.toString()
753
- : value), "utf8"), 0);
754
- if (bytes > 9 * 1024 * 1024)
755
- throw new Error("Entity commit exceeds the transaction payload limit.");
756
- }
757
1074
  function validateEvents(events) {
758
1075
  const ids = events.map((event) => event.id?.value);
759
1076
  if (ids.some((id) => id === undefined || id.trim().length === 0) ||