@spine-event-engine/storage 2.0.0-snapshot.10

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 (137) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +190 -0
  3. package/REFERENCE.md +122 -0
  4. package/dist/entity/entity-history-record-spec.d.ts +26 -0
  5. package/dist/entity/entity-history-record-spec.d.ts.map +1 -0
  6. package/dist/entity/entity-history-record-spec.js +77 -0
  7. package/dist/entity/entity-history-record-spec.js.map +1 -0
  8. package/dist/entity/entity-history-storage.d.ts +111 -0
  9. package/dist/entity/entity-history-storage.d.ts.map +1 -0
  10. package/dist/entity/entity-history-storage.js +40 -0
  11. package/dist/entity/entity-history-storage.js.map +1 -0
  12. package/dist/entity/entity-record.d.ts +31 -0
  13. package/dist/entity/entity-record.d.ts.map +1 -0
  14. package/dist/entity/entity-record.js +15 -0
  15. package/dist/entity/entity-record.js.map +1 -0
  16. package/dist/entity/history-conformance.d.ts +55 -0
  17. package/dist/entity/history-conformance.d.ts.map +1 -0
  18. package/dist/entity/history-conformance.js +165 -0
  19. package/dist/entity/history-conformance.js.map +1 -0
  20. package/dist/event/event-store.d.ts +141 -0
  21. package/dist/event/event-store.d.ts.map +1 -0
  22. package/dist/event/event-store.js +407 -0
  23. package/dist/event/event-store.js.map +1 -0
  24. package/dist/index.d.ts +22 -0
  25. package/dist/index.d.ts.map +1 -0
  26. package/dist/index.js +29 -0
  27. package/dist/index.js.map +1 -0
  28. package/dist/internal/delivery-cleanup.d.ts +99 -0
  29. package/dist/internal/delivery-cleanup.d.ts.map +1 -0
  30. package/dist/internal/delivery-cleanup.js +38 -0
  31. package/dist/internal/delivery-cleanup.js.map +1 -0
  32. package/dist/internal/entity-commit.d.ts +108 -0
  33. package/dist/internal/entity-commit.d.ts.map +1 -0
  34. package/dist/internal/entity-commit.js +32 -0
  35. package/dist/internal/entity-commit.js.map +1 -0
  36. package/dist/internal/entity-history.d.ts +12 -0
  37. package/dist/internal/entity-history.d.ts.map +1 -0
  38. package/dist/internal/entity-history.js +17 -0
  39. package/dist/internal/entity-history.js.map +1 -0
  40. package/dist/internal/event-store.d.ts +8 -0
  41. package/dist/internal/event-store.d.ts.map +1 -0
  42. package/dist/internal/event-store.js +21 -0
  43. package/dist/internal/event-store.js.map +1 -0
  44. package/dist/internal/query-values.d.ts +5 -0
  45. package/dist/internal/query-values.d.ts.map +1 -0
  46. package/dist/internal/query-values.js +18 -0
  47. package/dist/internal/query-values.js.map +1 -0
  48. package/dist/internal/tenancy.d.ts +89 -0
  49. package/dist/internal/tenancy.d.ts.map +1 -0
  50. package/dist/internal/tenancy.js +97 -0
  51. package/dist/internal/tenancy.js.map +1 -0
  52. package/dist/memory/canonical-utf8.d.ts +21 -0
  53. package/dist/memory/canonical-utf8.d.ts.map +1 -0
  54. package/dist/memory/canonical-utf8.js +65 -0
  55. package/dist/memory/canonical-utf8.js.map +1 -0
  56. package/dist/memory/in-memory-entity-commit.d.ts +35 -0
  57. package/dist/memory/in-memory-entity-commit.d.ts.map +1 -0
  58. package/dist/memory/in-memory-entity-commit.js +183 -0
  59. package/dist/memory/in-memory-entity-commit.js.map +1 -0
  60. package/dist/memory/in-memory-entity-history.d.ts +379 -0
  61. package/dist/memory/in-memory-entity-history.d.ts.map +1 -0
  62. package/dist/memory/in-memory-entity-history.js +756 -0
  63. package/dist/memory/in-memory-entity-history.js.map +1 -0
  64. package/dist/memory/in-memory-record-storage.d.ts +78 -0
  65. package/dist/memory/in-memory-record-storage.d.ts.map +1 -0
  66. package/dist/memory/in-memory-record-storage.js +127 -0
  67. package/dist/memory/in-memory-record-storage.js.map +1 -0
  68. package/dist/memory/in-memory-storage-backend.d.ts +38 -0
  69. package/dist/memory/in-memory-storage-backend.d.ts.map +1 -0
  70. package/dist/memory/in-memory-storage-backend.js +108 -0
  71. package/dist/memory/in-memory-storage-backend.js.map +1 -0
  72. package/dist/memory/in-memory-storage-factory.d.ts +58 -0
  73. package/dist/memory/in-memory-storage-factory.d.ts.map +1 -0
  74. package/dist/memory/in-memory-storage-factory.js +148 -0
  75. package/dist/memory/in-memory-storage-factory.js.map +1 -0
  76. package/dist/memory/memory-delivery-cleanup.d.ts +32 -0
  77. package/dist/memory/memory-delivery-cleanup.d.ts.map +1 -0
  78. package/dist/memory/memory-delivery-cleanup.js +61 -0
  79. package/dist/memory/memory-delivery-cleanup.js.map +1 -0
  80. package/dist/memory/tenant-records.d.ts +66 -0
  81. package/dist/memory/tenant-records.d.ts.map +1 -0
  82. package/dist/memory/tenant-records.js +573 -0
  83. package/dist/memory/tenant-records.js.map +1 -0
  84. package/dist/provider.d.ts +18 -0
  85. package/dist/provider.d.ts.map +1 -0
  86. package/dist/provider.js +23 -0
  87. package/dist/provider.js.map +1 -0
  88. package/dist/query/query-execution.d.ts +50 -0
  89. package/dist/query/query-execution.d.ts.map +1 -0
  90. package/dist/query/query-execution.js +222 -0
  91. package/dist/query/query-execution.js.map +1 -0
  92. package/dist/query/query-policy.d.ts +115 -0
  93. package/dist/query/query-policy.d.ts.map +1 -0
  94. package/dist/query/query-policy.js +294 -0
  95. package/dist/query/query-policy.js.map +1 -0
  96. package/dist/record/column-mapping.d.ts +41 -0
  97. package/dist/record/column-mapping.d.ts.map +1 -0
  98. package/dist/record/column-mapping.js +36 -0
  99. package/dist/record/column-mapping.js.map +1 -0
  100. package/dist/record/column-type.d.ts +75 -0
  101. package/dist/record/column-type.d.ts.map +1 -0
  102. package/dist/record/column-type.js +69 -0
  103. package/dist/record/column-type.js.map +1 -0
  104. package/dist/record/record-column.d.ts +23 -0
  105. package/dist/record/record-column.d.ts.map +1 -0
  106. package/dist/record/record-column.js +41 -0
  107. package/dist/record/record-column.js.map +1 -0
  108. package/dist/record/record-mask.d.ts +11 -0
  109. package/dist/record/record-mask.d.ts.map +1 -0
  110. package/dist/record/record-mask.js +82 -0
  111. package/dist/record/record-mask.js.map +1 -0
  112. package/dist/record/record-query.d.ts +102 -0
  113. package/dist/record/record-query.d.ts.map +1 -0
  114. package/dist/record/record-query.js +44 -0
  115. package/dist/record/record-query.js.map +1 -0
  116. package/dist/record/record-spec.d.ts +101 -0
  117. package/dist/record/record-spec.d.ts.map +1 -0
  118. package/dist/record/record-spec.js +159 -0
  119. package/dist/record/record-spec.js.map +1 -0
  120. package/dist/record/record-storage.d.ts +202 -0
  121. package/dist/record/record-storage.d.ts.map +1 -0
  122. package/dist/record/record-storage.js +234 -0
  123. package/dist/record/record-storage.js.map +1 -0
  124. package/dist/record/storage-group.d.ts +17 -0
  125. package/dist/record/storage-group.d.ts.map +1 -0
  126. package/dist/record/storage-group.js +37 -0
  127. package/dist/record/storage-group.js.map +1 -0
  128. package/dist/storage/storage-factory.d.ts +44 -0
  129. package/dist/storage/storage-factory.d.ts.map +1 -0
  130. package/dist/storage/storage-factory.js +54 -0
  131. package/dist/storage/storage-factory.js.map +1 -0
  132. package/dist/storage/storage.d.ts +62 -0
  133. package/dist/storage/storage.d.ts.map +1 -0
  134. package/dist/storage/storage.js +15 -0
  135. package/dist/storage/storage.js.map +1 -0
  136. package/dist/tsconfig.tsbuildinfo +1 -0
  137. package/package.json +35 -0
@@ -0,0 +1,756 @@
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 { clone, create, fromBinary, toBinary } from "@bufbuild/protobuf";
15
+ import { TimestampSchema } from "@bufbuild/protobuf/wkt";
16
+ import { EventSchema } from "@spine-event-engine/proto";
17
+ import { EntityRecordSchema, } from "@spine-event-engine/proto/generated/spine/server/entity/entity_pb.js";
18
+ import { EntityStateKeySchema, } from "@spine-event-engine/proto/generated/spine/server/entity/state_key_pb.js";
19
+ import { disabledEventHistoryPort, disabledStateHistoryPort, } from "../entity/entity-history-storage.js";
20
+ import { TenantBoundary } from "../internal/tenancy.js";
21
+ import { CanonicalUtf8 } from "./canonical-utf8.js";
22
+ import { InMemoryStorageBackend } from "./in-memory-storage-backend.js";
23
+ import { RecordColumn } from "../record/record-column.js";
24
+ import { StorageQueryEvaluator } from "../query/query-execution.js";
25
+ import { StorageQueryPolicy } from "../query/query-policy.js";
26
+ const storageHost = globalThis;
27
+ /**
28
+ * Shared in-memory entity-storage factory for adapter conformance.
29
+ */
30
+ export class MemoryEntityStorageFactory {
31
+ #backend;
32
+ /**
33
+ * Creates a factory with a fresh backend, or deliberately shares `backend`.
34
+ *
35
+ * @param backend Supplies the backend to share between factory handles.
36
+ */
37
+ constructor(backend = new InMemoryStorageBackend()) {
38
+ this.#backend = backend;
39
+ }
40
+ /**
41
+ * Creates one scoped entity-storage handle.
42
+ *
43
+ * @param input Supplies the entity storage configuration.
44
+ * @returns Returns the scoped in-memory entity storage.
45
+ */
46
+ create(input) {
47
+ return new InMemoryEntityStorage(input, this.backend(input));
48
+ }
49
+ /**
50
+ * Opens the provider-owned maps used by an atomic in-memory commit.
51
+ *
52
+ * @param input Supplies the Entity storage configuration.
53
+ * @returns The compatible backend maps for this Entity scope.
54
+ */
55
+ backend(input) {
56
+ const tenant = TenantBoundary.of(input.context);
57
+ return InMemoryStorageBackend.bind(this.#backend, "entity", tenant, input.sourceType.typeName, () => ({
58
+ current: new Map(),
59
+ mutationQueue: new KeyedSerialQueue(),
60
+ }));
61
+ }
62
+ }
63
+ /**
64
+ * One scoped in-memory current/state/event storage handle.
65
+ */
66
+ export class InMemoryEntityStorage {
67
+ // prettier-ignore
68
+ /**
69
+ * Provides current-record storage for this entity scope.
70
+ */
71
+ current;
72
+ /**
73
+ * Provides event-history storage for this entity scope.
74
+ */
75
+ events;
76
+ /**
77
+ * Provides state-history storage for this entity scope.
78
+ */
79
+ states;
80
+ /**
81
+ * Creates the current, event, and state storage adapters over `backend`.
82
+ *
83
+ * @param input Supplies the entity storage configuration.
84
+ * @param backend Supplies the scoped in-memory data structures.
85
+ */
86
+ constructor(input, backend) {
87
+ this.current = new MemoryEntityRecordStorage({
88
+ idKey: input.id.key,
89
+ idClone: input.id.clone,
90
+ columns: input.columns,
91
+ unpackId: input.id.unpack,
92
+ records: backend.current,
93
+ queue: backend.mutationQueue,
94
+ });
95
+ this.events =
96
+ input.eventHistoryStorage === undefined
97
+ ? disabledEventHistoryPort()
98
+ : new MemoryEntityEventHistory({
99
+ id: input.id,
100
+ records: input.eventHistoryStorage,
101
+ queue: backend.mutationQueue,
102
+ });
103
+ this.states =
104
+ input.stateHistoryStorage === undefined
105
+ ? disabledStateHistoryPort()
106
+ : new InMemoryEntityHistory({
107
+ id: input.id,
108
+ records: input.stateHistoryStorage,
109
+ stateSchema: input.stateSchema,
110
+ queue: backend.mutationQueue,
111
+ });
112
+ }
113
+ /**
114
+ * Closes this independently owned provider handle without affecting siblings.
115
+ */
116
+ close() {
117
+ this.events.close();
118
+ this.states.close();
119
+ }
120
+ }
121
+ /**
122
+ * Identifies the single mutation sequence for one physical Entity storage scope.
123
+ */
124
+ export const ENTITY_SCOPE_MUTATION_KEY = "entity-storage-scope";
125
+ /**
126
+ * Captures immutable entity values and their durable identity.
127
+ */
128
+ const EntitySnapshots = {
129
+ // prettier-ignore
130
+ /**
131
+ * Clones one entity ID with the platform structured-clone operation.
132
+ */
133
+ cloneId(id) {
134
+ return storageHost.structuredClone(id);
135
+ },
136
+ /**
137
+ * Copies an immutable latest-state record.
138
+ */
139
+ copyCurrent(record) {
140
+ return clone(EntityRecordSchema, record);
141
+ },
142
+ };
143
+ /**
144
+ * In-memory latest-state storage used by all entity families.
145
+ */
146
+ export class MemoryEntityRecordStorage {
147
+ #idKey;
148
+ #idClone;
149
+ #records;
150
+ #unpackId;
151
+ #columns;
152
+ #queue;
153
+ /**
154
+ * Creates a current-record adapter over supplied or fresh record storage.
155
+ *
156
+ * @param input Supplies state, ID, column, and record-storage configuration.
157
+ */
158
+ constructor(input) {
159
+ this.#idKey = input.idKey;
160
+ this.#idClone = input.idClone ?? ((id) => EntitySnapshots.cloneId(id));
161
+ this.#records = input.records ?? new Map();
162
+ this.#unpackId = input.unpackId;
163
+ this.#columns = input.columns;
164
+ this.#queue = input.queue;
165
+ }
166
+ /**
167
+ * Reads the current record for one entity identifier.
168
+ *
169
+ * @param id Supplies the entity identifier to read.
170
+ * @returns Resolves to an independent current record, when present.
171
+ */
172
+ read(id) {
173
+ return Promise.resolve().then(() => {
174
+ const record = this.#records.get(this.#idKey(id));
175
+ return record === undefined ? undefined : EntitySnapshots.copyCurrent(record);
176
+ });
177
+ }
178
+ /**
179
+ * Stores an independent copy of one current entity record.
180
+ *
181
+ * @param record Supplies the current record to store.
182
+ * @returns Completes when the record is stored.
183
+ */
184
+ write(record) {
185
+ const write = () => {
186
+ const id = record.entityId === undefined ? undefined : this.#unpackId(record.entityId);
187
+ if (id === undefined)
188
+ throw new Error("Entity current record ID does not match its Entity ID schema.");
189
+ this.#records.set(this.#idKey(id), EntitySnapshots.copyCurrent(record));
190
+ return Promise.resolve();
191
+ };
192
+ return this.#queue === undefined
193
+ ? Promise.resolve().then(write)
194
+ : this.#queue.run(ENTITY_SCOPE_MUTATION_KEY, write);
195
+ }
196
+ /**
197
+ * Returns non-deleted current records matching the normalized plan.
198
+ *
199
+ * @param plan Supplies the normalized record-query plan.
200
+ * @returns Resolves to ordered matching current-record entries.
201
+ */
202
+ query(plan) {
203
+ return Promise.resolve().then(() => {
204
+ StorageQueryPolicy.validate(plan, {
205
+ comparisons: ["equal", "greaterThan", "lessThan", "greaterOrEqual", "lessOrEqual"],
206
+ features: ["either", "nested", "order", "mask", "limit"],
207
+ });
208
+ const limit = plan.candidateLimit ?? 10_000;
209
+ const candidates = [];
210
+ for (const record of this.#records.values()) {
211
+ if (record.lifecycleFlags?.deleted)
212
+ continue;
213
+ candidates.push(record);
214
+ if (candidates.length > limit)
215
+ break;
216
+ }
217
+ if (candidates.length > limit) {
218
+ throw new Error(`Storage query exceeded the candidate limit of ${String(limit)}.`);
219
+ }
220
+ const entries = candidates.flatMap((record) => {
221
+ const copied = EntitySnapshots.copyCurrent(record);
222
+ const id = copied.entityId === undefined ? undefined : this.#unpackId(copied.entityId);
223
+ if (id === undefined)
224
+ throw new Error("Entity current record ID does not match its Entity ID schema.");
225
+ return copied.lifecycleFlags?.deleted
226
+ ? []
227
+ : [
228
+ {
229
+ id: this.#idClone(id),
230
+ record: copied,
231
+ columns: new Map([
232
+ ...this.#columns.map((column) => [
233
+ column.name,
234
+ column.valueIn(copied),
235
+ ]),
236
+ ]),
237
+ },
238
+ ];
239
+ });
240
+ return StorageQueryEvaluator.evaluate(entries, plan);
241
+ });
242
+ }
243
+ }
244
+ /**
245
+ * In-memory immutable diagnostic event-history adapter.
246
+ */
247
+ export class MemoryEntityEventHistory {
248
+ #id;
249
+ #maintenance;
250
+ #records;
251
+ #queue;
252
+ /**
253
+ * Creates an event-history adapter over grouped generated record storage.
254
+ *
255
+ * @param input Supplies the ID codec, generated record storage, and maintenance seam.
256
+ */
257
+ constructor(input) {
258
+ this.#id = input.id;
259
+ this.#maintenance = input.maintenance;
260
+ HistoryLimits.requireBatchSize(input.maintenance?.batchSize);
261
+ HistoryLimits.requirePageSize(input.maintenance?.pageSize);
262
+ this.#records = input.records;
263
+ this.#queue = input.queue ?? new KeyedSerialQueue();
264
+ }
265
+ /**
266
+ * Stores one immutable event-history record idempotently.
267
+ *
268
+ * @param record Supplies the event-history record to append.
269
+ * @returns Completes when the record is stored.
270
+ */
271
+ async append(record) {
272
+ this.requireOpen();
273
+ await this.#queue.run(ENTITY_SCOPE_MUTATION_KEY, async () => {
274
+ this.requireOpen();
275
+ const storedEvent = this.validatedEvent(record);
276
+ const id = this.eventIdIn(storedEvent);
277
+ const stored = await this.#records.read(id);
278
+ if (stored !== undefined &&
279
+ !CanonicalBytes.equal(toBinary(EventSchema, stored), toBinary(EventSchema, storedEvent))) {
280
+ throw new Error("Event-history retry has divergent content.");
281
+ }
282
+ if (stored === undefined)
283
+ await this.#records.write(storedEvent);
284
+ });
285
+ }
286
+ /**
287
+ * Reads recent events in descending producer-version order.
288
+ *
289
+ * @param entityId Supplies the entity identifier to inspect.
290
+ * @param depth Limits the number of events returned.
291
+ * @param startingFromVersion Excludes events at or after this producer version.
292
+ * @returns Resolves to immutable event snapshots.
293
+ */
294
+ async backward(entityId, depth, startingFromVersion) {
295
+ this.requireOpen();
296
+ HistoryLimits.requireDepth(depth);
297
+ const selected = [];
298
+ await HistoryPaging.scan(this.#records, {
299
+ filters: [{ column: "entity_id", value: this.#id.pack(entityId) }],
300
+ }, [
301
+ { field: "version", direction: "desc" },
302
+ { field: "created", direction: "desc" },
303
+ { field: "id", direction: "desc" },
304
+ ], this.pageSize(), (record, field) => field === "version" ? record.context?.version?.number : record.context?.timestamp, (page) => {
305
+ for (const { record } of page) {
306
+ const version = record.context?.version?.number;
307
+ if (startingFromVersion === undefined ||
308
+ (version !== undefined && BigInt(version) < startingFromVersion)) {
309
+ selected.push(record);
310
+ if (selected.length === depth)
311
+ return false;
312
+ }
313
+ }
314
+ return true;
315
+ });
316
+ return Object.freeze(selected
317
+ .sort((left, right) => (right.context?.version?.number ?? 0) - (left.context?.version?.number ?? 0) ||
318
+ HistoryOrdering.compareTime(right.context?.timestamp ?? create(TimestampSchema), left.context?.timestamp ?? create(TimestampSchema)) ||
319
+ CanonicalUtf8.compare(right.id?.value ?? "", left.id?.value ?? ""))
320
+ .slice(0, depth)
321
+ .map((record) => Object.freeze(clone(EventSchema, record))));
322
+ }
323
+ /**
324
+ * Deletes events created before the supplied timestamp in maintenance chunks.
325
+ *
326
+ * @param olderThan Specifies the exclusive event creation-time boundary.
327
+ * @returns Completes when maintenance finishes.
328
+ */
329
+ async truncate(olderThan) {
330
+ this.requireOpen();
331
+ await this.#queue.run(ENTITY_SCOPE_MUTATION_KEY, async () => {
332
+ await this.afterSelection();
333
+ await HistoryPaging.scan(this.#records, {}, [
334
+ { field: "created", direction: "asc" },
335
+ { field: "version", direction: "asc" },
336
+ ], this.pageSize(), (record, field) => field === "version" ? record.context?.version?.number : record.context?.timestamp, async (page) => {
337
+ const selected = page.filter((entry) => {
338
+ const created = entry.record.context?.timestamp;
339
+ return created !== undefined && HistoryOrdering.compareTime(created, olderThan) < 0;
340
+ });
341
+ await this.deleteEntries(selected);
342
+ return selected.length === page.length;
343
+ });
344
+ });
345
+ }
346
+ /**
347
+ * Closes this event-history adapter.
348
+ */
349
+ close() {
350
+ this.#records.close();
351
+ }
352
+ /**
353
+ * Returns whether this event-history adapter remains open.
354
+ *
355
+ * @returns Returns true while the adapter is open.
356
+ */
357
+ isOpen() {
358
+ return this.#records.isOpen();
359
+ }
360
+ eventIdIn(event) {
361
+ if (event.id === undefined || event.id.value.trim().length === 0) {
362
+ throw new Error("Event history requires an event ID.");
363
+ }
364
+ return event.id;
365
+ }
366
+ producerIdIn(event) {
367
+ const producerId = event.context?.producerId;
368
+ if (producerId === undefined)
369
+ throw new Error("Event history requires an EventContext producer ID.");
370
+ const entityId = this.#id.unpack(producerId);
371
+ if (entityId === undefined) {
372
+ throw new Error("Event-history producer ID does not match the configured Entity ID schema.");
373
+ }
374
+ return entityId;
375
+ }
376
+ validatedEvent(event) {
377
+ this.eventIdIn(event);
378
+ this.producerIdIn(event);
379
+ const context = event.context;
380
+ if (context?.version === undefined) {
381
+ throw new Error("Event history requires an EventContext version.");
382
+ }
383
+ if (context.timestamp === undefined) {
384
+ throw new Error("Event history requires an EventContext timestamp.");
385
+ }
386
+ const stored = clone(EventSchema, event);
387
+ if (stored.context !== undefined)
388
+ stored.context.enrichment = undefined;
389
+ return stored;
390
+ }
391
+ requireOpen() {
392
+ if (!this.isOpen())
393
+ throw new Error("Entity history storage is closed.");
394
+ }
395
+ async afterSelection() {
396
+ await this.#maintenance?.afterSelection?.();
397
+ this.requireOpen();
398
+ }
399
+ batchSize() {
400
+ return this.#maintenance?.batchSize ?? MAINTENANCE_BATCH_SIZE;
401
+ }
402
+ pageSize() {
403
+ return this.#maintenance?.pageSize ?? HISTORY_PAGE_SIZE;
404
+ }
405
+ async deleteEntries(entries) {
406
+ for (let start = 0; start < entries.length; start += this.batchSize()) {
407
+ for (const entry of entries.slice(start, start + this.batchSize()))
408
+ await this.#records.delete(entry.id);
409
+ await this.#maintenance?.onChunk?.();
410
+ this.requireOpen();
411
+ }
412
+ }
413
+ }
414
+ /**
415
+ * In-memory immutable, versioned state-history adapter.
416
+ */
417
+ export class InMemoryEntityHistory {
418
+ #id;
419
+ #records;
420
+ #stateSchema;
421
+ #maintenance;
422
+ #queue;
423
+ /**
424
+ * Creates a state-history adapter over grouped generated record storage.
425
+ *
426
+ * @param input Supplies the generated record storage, ID codec, state schema, and maintenance seam.
427
+ */
428
+ constructor(input) {
429
+ this.#id = input.id;
430
+ this.#records = input.records;
431
+ this.#stateSchema = input.stateSchema;
432
+ this.#maintenance = input.maintenance;
433
+ this.#queue = input.queue ?? new KeyedSerialQueue();
434
+ HistoryLimits.requireBatchSize(input.maintenance?.batchSize);
435
+ HistoryLimits.requirePageSize(input.maintenance?.pageSize);
436
+ }
437
+ /**
438
+ * Stores one generated Entity record in the grouped state history.
439
+ *
440
+ * @param record Supplies the Entity record to append.
441
+ * @returns Completes when the record is stored.
442
+ */
443
+ async append(record) {
444
+ this.requireOpen();
445
+ this.validateRecord(record);
446
+ await this.#queue.run(ENTITY_SCOPE_MUTATION_KEY, async () => {
447
+ this.requireOpen();
448
+ const key = this.keyFor(record);
449
+ const stored = await this.#records.read(key);
450
+ if (stored !== undefined && !HistoryIdentity.sameEntityRecord(stored, record)) {
451
+ throw new Error("State-history retry has divergent content.");
452
+ }
453
+ if (stored === undefined)
454
+ await this.#records.write(record);
455
+ });
456
+ }
457
+ /**
458
+ * Reads recent state records in descending version and creation-time order.
459
+ *
460
+ * @param entityId Supplies the entity identifier to inspect.
461
+ * @param depth Limits the number of state records returned.
462
+ * @param startingFromVersion Excludes records at or after this version.
463
+ * @returns Resolves to cloned generated Entity records.
464
+ */
465
+ async backward(entityId, depth, startingFromVersion) {
466
+ this.requireOpen();
467
+ HistoryLimits.requireDepth(depth);
468
+ const selected = [];
469
+ await HistoryPaging.scan(this.#records, {
470
+ filters: [{ column: "entity_id", value: this.#id.pack(entityId) }],
471
+ }, [
472
+ { field: "version", direction: "desc" },
473
+ { field: "created", direction: "desc" },
474
+ ], this.pageSize(), (record, field) => (field === "version" ? record.version?.number : record.version?.timestamp), (page) => {
475
+ for (const { record } of page) {
476
+ const version = record.version?.number;
477
+ if (startingFromVersion === undefined ||
478
+ version === undefined ||
479
+ BigInt(version) < startingFromVersion) {
480
+ selected.push(record);
481
+ if (selected.length === depth)
482
+ return false;
483
+ }
484
+ }
485
+ return true;
486
+ });
487
+ return Object.freeze(selected.map((record) => Object.freeze(clone(EntityRecordSchema, record))));
488
+ }
489
+ /**
490
+ * Reads the latest retained state at or before a timestamp.
491
+ *
492
+ * @param entityId Supplies the entity identifier to inspect.
493
+ * @param time Specifies the inclusive state creation-time boundary.
494
+ * @returns Resolves to a cloned state, when a compatible record is retained.
495
+ */
496
+ async stateAt(entityId, time) {
497
+ this.requireOpen();
498
+ let selected;
499
+ await HistoryPaging.scan(this.#records, {
500
+ filters: [{ column: "entity_id", value: this.#id.pack(entityId) }],
501
+ }, [
502
+ { field: "created", direction: "desc" },
503
+ { field: "version", direction: "desc" },
504
+ ], this.pageSize(), (record, field) => (field === "version" ? record.version?.number : record.version?.timestamp), (page) => {
505
+ selected = page
506
+ .map((entry) => entry.record)
507
+ .find((candidate) => {
508
+ const created = candidate.version?.timestamp;
509
+ return created !== undefined && HistoryOrdering.compareTime(created, time) <= 0;
510
+ });
511
+ return selected === undefined;
512
+ });
513
+ return selected === undefined ? undefined : this.unpackState(selected);
514
+ }
515
+ /**
516
+ * Deletes all but the requested number of newest records for one Entity.
517
+ *
518
+ * @param entityId Supplies the entity identifier to trim.
519
+ * @param keepMostRecent Specifies how many newest records to retain.
520
+ * @returns Completes when bounded deletion chunks finish.
521
+ */
522
+ async trim(entityId, keepMostRecent) {
523
+ this.requireOpen();
524
+ if (!Number.isSafeInteger(keepMostRecent) || keepMostRecent < 0) {
525
+ throw new Error("State-history trim count must be a non-negative safe integer.");
526
+ }
527
+ await this.#queue.run(ENTITY_SCOPE_MUTATION_KEY, async () => {
528
+ let skipped = 0;
529
+ await this.afterSelection();
530
+ await HistoryPaging.scan(this.#records, { filters: [{ column: "entity_id", value: this.#id.pack(entityId) }] }, [
531
+ { field: "version", direction: "desc" },
532
+ { field: "created", direction: "desc" },
533
+ ], this.pageSize(), (record, field) => field === "version" ? record.version?.number : record.version?.timestamp, async (page) => {
534
+ const selected = page.slice(Math.max(keepMostRecent - skipped, 0));
535
+ skipped += page.length;
536
+ await this.deleteEntries(selected);
537
+ return true;
538
+ });
539
+ });
540
+ }
541
+ /**
542
+ * Deletes records created before the supplied timestamp.
543
+ *
544
+ * @param olderThan Specifies the exclusive state creation-time boundary.
545
+ * @returns Completes when bounded deletion chunks finish.
546
+ */
547
+ async truncate(olderThan) {
548
+ this.requireOpen();
549
+ await this.#queue.run(ENTITY_SCOPE_MUTATION_KEY, async () => {
550
+ await this.afterSelection();
551
+ await HistoryPaging.scan(this.#records, {}, [
552
+ { field: "created", direction: "asc" },
553
+ { field: "version", direction: "asc" },
554
+ ], this.pageSize(), (record, field) => field === "version" ? record.version?.number : record.version?.timestamp, async (page) => {
555
+ const selected = page.filter((entry) => {
556
+ const created = entry.record.version?.timestamp;
557
+ return created !== undefined && HistoryOrdering.compareTime(created, olderThan) < 0;
558
+ });
559
+ await this.deleteEntries(selected);
560
+ return selected.length === page.length;
561
+ });
562
+ });
563
+ }
564
+ /**
565
+ * Closes the grouped record-storage handle.
566
+ */
567
+ close() {
568
+ this.#records.close();
569
+ }
570
+ /**
571
+ * Returns whether the grouped record-storage handle remains open.
572
+ *
573
+ * @returns Returns true while the adapter is open.
574
+ */
575
+ isOpen() {
576
+ return this.#records.isOpen();
577
+ }
578
+ async deleteEntries(entries) {
579
+ for (let start = 0; start < entries.length; start += this.batchSize()) {
580
+ for (const entry of entries.slice(start, start + this.batchSize())) {
581
+ await this.#records.delete(entry.id);
582
+ }
583
+ await this.afterMaintenanceChunk();
584
+ }
585
+ }
586
+ keyFor(record) {
587
+ if (record.entityId === undefined || record.version === undefined) {
588
+ throw new Error("State history requires EntityRecord.entityId and EntityRecord.version.");
589
+ }
590
+ return create(EntityStateKeySchema, {
591
+ entityId: record.entityId,
592
+ version: record.version.number,
593
+ });
594
+ }
595
+ entityIdIn(record) {
596
+ if (record.entityId === undefined) {
597
+ throw new Error("State history requires EntityRecord.entityId.");
598
+ }
599
+ const entityId = this.#id.unpack(record.entityId);
600
+ if (entityId === undefined) {
601
+ throw new Error("State-history record ID does not match the configured Entity ID schema.");
602
+ }
603
+ return entityId;
604
+ }
605
+ validateRecord(record) {
606
+ this.entityIdIn(record);
607
+ if (record.version?.timestamp === undefined) {
608
+ throw new Error("State history requires EntityRecord.version and EntityRecord.version.timestamp.");
609
+ }
610
+ this.unpackState(record);
611
+ }
612
+ unpackState(record) {
613
+ const state = record.state;
614
+ if (!state?.typeUrl.endsWith(`/${this.#stateSchema.typeName}`)) {
615
+ throw new Error("State-history record state does not match the configured Entity state schema.");
616
+ }
617
+ return Object.freeze(fromBinary(this.#stateSchema, state.value));
618
+ }
619
+ requireOpen() {
620
+ if (!this.isOpen())
621
+ throw new Error("Entity history storage is closed.");
622
+ }
623
+ async afterSelection() {
624
+ await this.#maintenance?.afterSelection?.();
625
+ this.requireOpen();
626
+ }
627
+ async afterMaintenanceChunk() {
628
+ await this.#maintenance?.onChunk?.();
629
+ this.requireOpen();
630
+ }
631
+ batchSize() {
632
+ return this.#maintenance?.batchSize ?? MAINTENANCE_BATCH_SIZE;
633
+ }
634
+ pageSize() {
635
+ return this.#maintenance?.pageSize ?? HISTORY_PAGE_SIZE;
636
+ }
637
+ }
638
+ /**
639
+ * Async FIFO mutexes shared by all history handles for one in-memory backend.
640
+ */
641
+ export class KeyedSerialQueue {
642
+ #tails = new Map();
643
+ /**
644
+ * Queues an operation after earlier operations with the same key finish.
645
+ *
646
+ * @param key Identifies the serialized operation sequence.
647
+ * @param operation Supplies the operation to run.
648
+ * @returns Resolves to the operation result.
649
+ */
650
+ async run(key, operation) {
651
+ const previous = this.#tails.get(key);
652
+ let release;
653
+ const tail = new Promise((resolve) => {
654
+ release = resolve;
655
+ });
656
+ this.#tails.set(key, tail);
657
+ if (previous !== undefined)
658
+ await previous;
659
+ try {
660
+ return await operation();
661
+ }
662
+ finally {
663
+ release();
664
+ if (this.#tails.get(key) === tail)
665
+ this.#tails.delete(key);
666
+ }
667
+ }
668
+ }
669
+ /**
670
+ * Orders history records by chronology, version, and stable keys.
671
+ */
672
+ const HistoryOrdering = {
673
+ // prettier-ignore
674
+ /**
675
+ * Compares timestamps by seconds and nanos.
676
+ */
677
+ compareTime(left, right) {
678
+ return Number(left.seconds - right.seconds) || left.nanos - right.nanos;
679
+ },
680
+ };
681
+ /**
682
+ * Traverses finite RecordStorage windows with an exact stable keyset continuation.
683
+ */
684
+ const HistoryPaging = {
685
+ async scan(records, query, sort, pageSize, valueIn, visit) {
686
+ let after;
687
+ do {
688
+ const page = await records.queryEntries({
689
+ ...query,
690
+ sort,
691
+ limit: pageSize,
692
+ ...(after === undefined ? {} : { after }),
693
+ });
694
+ const last = page.at(-1);
695
+ after =
696
+ last === undefined
697
+ ? undefined
698
+ : {
699
+ id: last.id,
700
+ values: sort.map((order) => ({
701
+ field: order.field,
702
+ value: order.field === "id" ? last.id : valueIn(last.record, order.field),
703
+ })),
704
+ };
705
+ if (!(await visit(page)) || page.length < pageSize)
706
+ return;
707
+ } while (after !== undefined);
708
+ },
709
+ };
710
+ /**
711
+ * Compares generated Entity records by durable binary content.
712
+ */
713
+ const HistoryIdentity = {
714
+ // prettier-ignore
715
+ /**
716
+ * Determines whether two generated Entity records have identical binary content.
717
+ */
718
+ sameEntityRecord(left, right) {
719
+ return CanonicalBytes.equal(toBinary(EntityRecordSchema, left), toBinary(EntityRecordSchema, right));
720
+ },
721
+ };
722
+ /**
723
+ * Validates bounded history requests.
724
+ */
725
+ const HistoryLimits = {
726
+ requireDepth(depth) {
727
+ if (!Number.isSafeInteger(depth) || depth <= 0) {
728
+ throw new Error("History depth must be a positive safe integer.");
729
+ }
730
+ },
731
+ requireBatchSize(batchSize) {
732
+ if (batchSize !== undefined && (!Number.isSafeInteger(batchSize) || batchSize <= 0)) {
733
+ throw new Error("In-memory maintenance batch size must be a positive safe integer.");
734
+ }
735
+ },
736
+ requirePageSize(pageSize) {
737
+ if (pageSize !== undefined && (!Number.isSafeInteger(pageSize) || pageSize <= 0)) {
738
+ throw new Error("In-memory history page size must be a positive safe integer.");
739
+ }
740
+ },
741
+ };
742
+ const MAINTENANCE_BATCH_SIZE = 128;
743
+ const HISTORY_PAGE_SIZE = 10_000;
744
+ /**
745
+ * Orders canonical UTF-8 values and compares durable bytes.
746
+ */
747
+ const CanonicalBytes = {
748
+ // prettier-ignore
749
+ /**
750
+ * Compares two durable byte sequences.
751
+ */
752
+ equal(left, right) {
753
+ return left.length === right.length && left.every((value, index) => value === right[index]);
754
+ },
755
+ };
756
+ //# sourceMappingURL=in-memory-entity-history.js.map