@spine-event-engine/storage-datastore 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 (42) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +262 -0
  3. package/REFERENCE.md +147 -0
  4. package/dist/datastore/column-mapping.d.ts +28 -0
  5. package/dist/datastore/column-mapping.d.ts.map +1 -0
  6. package/dist/datastore/column-mapping.js +141 -0
  7. package/dist/datastore/column-mapping.js.map +1 -0
  8. package/dist/datastore/delivery-cleanup.d.ts +31 -0
  9. package/dist/datastore/delivery-cleanup.d.ts.map +1 -0
  10. package/dist/datastore/delivery-cleanup.js +111 -0
  11. package/dist/datastore/delivery-cleanup.js.map +1 -0
  12. package/dist/datastore/entity-history.d.ts +82 -0
  13. package/dist/datastore/entity-history.d.ts.map +1 -0
  14. package/dist/datastore/entity-history.js +541 -0
  15. package/dist/datastore/entity-history.js.map +1 -0
  16. package/dist/datastore/id-column.d.ts +34 -0
  17. package/dist/datastore/id-column.d.ts.map +1 -0
  18. package/dist/datastore/id-column.js +103 -0
  19. package/dist/datastore/id-column.js.map +1 -0
  20. package/dist/datastore/namespace.d.ts +81 -0
  21. package/dist/datastore/namespace.d.ts.map +1 -0
  22. package/dist/datastore/namespace.js +123 -0
  23. package/dist/datastore/namespace.js.map +1 -0
  24. package/dist/datastore/record-storage.d.ts +223 -0
  25. package/dist/datastore/record-storage.d.ts.map +1 -0
  26. package/dist/datastore/record-storage.js +855 -0
  27. package/dist/datastore/record-storage.js.map +1 -0
  28. package/dist/datastore/storage-factory.d.ts +200 -0
  29. package/dist/datastore/storage-factory.d.ts.map +1 -0
  30. package/dist/datastore/storage-factory.js +224 -0
  31. package/dist/datastore/storage-factory.js.map +1 -0
  32. package/dist/datastore/tenant-catalog.d.ts +50 -0
  33. package/dist/datastore/tenant-catalog.d.ts.map +1 -0
  34. package/dist/datastore/tenant-catalog.js +154 -0
  35. package/dist/datastore/tenant-catalog.js.map +1 -0
  36. package/dist/index.d.ts +6 -0
  37. package/dist/index.d.ts.map +1 -0
  38. package/dist/index.js +19 -0
  39. package/dist/index.js.map +1 -0
  40. package/dist/tsconfig.tsbuildinfo +1 -0
  41. package/index.yaml +1 -0
  42. package/package.json +40 -0
@@ -0,0 +1,855 @@
1
+ /*
2
+ * Copyright 2026, CodeMatters. All rights reserved.
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
5
+ * in compliance with the License. You may obtain a copy of the License at
6
+ *
7
+ * https://www.apache.org/licenses/LICENSE-2.0
8
+ *
9
+ * Unless required by applicable law or agreed to in writing, software distributed under the License
10
+ * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
11
+ * or implied. See the License for the specific language governing permissions and limitations under
12
+ * the License.
13
+ */
14
+ import { fromBinary, toBinary } from "@bufbuild/protobuf";
15
+ import { and, Datastore, or, PropertyFilter } from "@google-cloud/datastore";
16
+ import { StringifierRegistry } from "@spine-event-engine/core";
17
+ import { ColumnMappings, defaultQueryCandidateLimit, RecordStorage, } from "@spine-event-engine/storage";
18
+ import { TenantBoundary } from "@spine-event-engine/storage/provider";
19
+ import { StorageQueryValues } from "@spine-event-engine/storage/provider";
20
+ import { DatastoreColumnMapping } from "./column-mapping.js";
21
+ import { DatastoreIdColumn } from "./id-column.js";
22
+ import { DefaultNamespaceConverter, NamespaceAssignments, } from "./namespace.js";
23
+ const payloadProperty = "bytes";
24
+ const maxMutationsPerBatch = 500;
25
+ const maxCasAttempts = 3;
26
+ const casRetryDelayMs = 100;
27
+ const wrappedReadOptions = { wrapNumbers: true };
28
+ /**
29
+ * Private flat Datastore-entity codec for one record storage handle.
30
+ */
31
+ class FlatEntityCodec {
32
+ recordSpec;
33
+ maxClientSideScan;
34
+ group;
35
+ #kind;
36
+ #namespace;
37
+ #idColumn;
38
+ #columnMapping;
39
+ constructor(context, recordSpec, client, maxClientSideScan, group, kind, namespaceConverter, stringifiers) {
40
+ this.recordSpec = recordSpec;
41
+ this.maxClientSideScan = maxClientSideScan;
42
+ this.group = group;
43
+ this.#kind =
44
+ kind ??
45
+ (group === undefined
46
+ ? recordSpec.sourceType.typeName
47
+ : `${group.name}_${recordSpec.recordType.typeName.split(".").at(-1) ?? ""}`);
48
+ if (this.#kind.trim().length === 0 || Buffer.byteLength(this.#kind, "utf8") > 1_500)
49
+ throw new Error("Datastore record kind must be non-blank and at most 1,500 bytes.");
50
+ const boundary = TenantBoundary.of(context);
51
+ this.#namespace = boundary.single
52
+ ? client.namespace
53
+ : namespaceConverter.toNamespace(boundary.tenantId);
54
+ this.#idColumn = new DatastoreIdColumn(recordSpec.idType, stringifiers);
55
+ this.#columnMapping = new DatastoreColumnMapping(stringifiers);
56
+ }
57
+ key(client, id) {
58
+ return client.key({
59
+ path: [this.#kind, this.#idColumn.value(id)],
60
+ ...(this.#namespace === undefined ? {} : { namespace: this.#namespace }),
61
+ });
62
+ }
63
+ encode(record, columns) {
64
+ const data = {
65
+ [payloadProperty]: Buffer.from(toBinary(this.recordSpec.recordType, record, { writeUnknownFields: false })),
66
+ };
67
+ for (const [name, value] of columns) {
68
+ data[name] = this.columnValue(name, value);
69
+ }
70
+ return data;
71
+ }
72
+ id(entity, client) {
73
+ const key = datastoreKey(entity, client.KEY);
74
+ const name = datastoreKeyName(key);
75
+ if (typeof name !== "string") {
76
+ throw new Error("Datastore entity has no valid Spine record identifier.");
77
+ }
78
+ return this.#idColumn.read(name);
79
+ }
80
+ columns(entity) {
81
+ return new Map(this.recordSpec.columns.map((column) => [column.name, column.valueIn(this.decode(entity))]));
82
+ }
83
+ unindexedProperties(data) {
84
+ void data;
85
+ return [payloadProperty];
86
+ }
87
+ createQuery(client) {
88
+ return this.#namespace === undefined
89
+ ? client.createQuery(this.#kind)
90
+ : client.createQuery(this.#namespace, this.#kind);
91
+ }
92
+ columnProperty(column) {
93
+ if (!this.recordSpec.columns.some((candidate) => candidate.name === column)) {
94
+ throw new Error(`Datastore record column "${column}" is not declared.`);
95
+ }
96
+ return column;
97
+ }
98
+ queryLimit() {
99
+ return this.maxClientSideScan + 1;
100
+ }
101
+ namespace() {
102
+ return this.#namespace;
103
+ }
104
+ columnValue(column, value) {
105
+ const declared = this.recordSpec.columns.find((candidate) => candidate.name === column);
106
+ if (declared === undefined)
107
+ throw new Error(`Datastore record column "${column}" is not declared.`);
108
+ return ColumnMappings.value(this.#columnMapping, declared.type, value);
109
+ }
110
+ pageCursor(entity, order, keySymbol) {
111
+ const key = datastoreKey(entity, keySymbol);
112
+ const record = this.decode(entity);
113
+ const values = order.map((item) => {
114
+ if (item.property === "__key__")
115
+ return key;
116
+ const column = this.recordSpec.columns.find((candidate) => candidate.name === item.property);
117
+ return column === undefined
118
+ ? undefined
119
+ : ColumnMappings.value(this.#columnMapping, column.type, column.valueIn(record));
120
+ });
121
+ if (key === undefined || values.some((value) => value === undefined))
122
+ throw new Error("Datastore provider page continuation is malformed.");
123
+ return { values, key };
124
+ }
125
+ decode(entity) {
126
+ const payload = entity[payloadProperty];
127
+ if (!(payload instanceof Uint8Array)) {
128
+ throw new Error("Datastore entity cannot be decoded.");
129
+ }
130
+ try {
131
+ return fromBinary(this.recordSpec.recordType, payload, { readUnknownFields: false });
132
+ }
133
+ catch {
134
+ throw new Error("Datastore entity cannot be decoded.");
135
+ }
136
+ }
137
+ }
138
+ function datastoreKey(entity, property) {
139
+ return typeof property === "symbol" ? entity[property] : undefined;
140
+ }
141
+ function datastoreKeyName(key) {
142
+ if (typeof key !== "object" || key === null)
143
+ return undefined;
144
+ const value = key;
145
+ return value.name ?? (Array.isArray(value.path) ? value.path.at(-1) : undefined);
146
+ }
147
+ function keysetFilter(order, cursor, keySymbol) {
148
+ if (cursor.values.length !== order.length)
149
+ throw new Error("Datastore provider page continuation is malformed.");
150
+ const fields = [...order, { property: "__key__", direction: order.at(-1)?.direction ?? "asc" }];
151
+ const values = [...cursor.values, cursor.key];
152
+ const alternatives = fields.map((field, index) => {
153
+ const equal = fields
154
+ .slice(0, index)
155
+ .map((prior, priorIndex) => new PropertyFilter(prior.property, "=", values[priorIndex]));
156
+ const operator = field.direction === "asc" ? ">" : "<";
157
+ const current = new PropertyFilter(field.property, operator, values[index]);
158
+ return equal.length === 0 ? current : and([...equal, current]);
159
+ });
160
+ if (cursor.key === undefined || keySymbol === undefined)
161
+ throw new Error("Datastore provider page continuation is malformed.");
162
+ return or(alternatives);
163
+ }
164
+ function sameCursor(left, right) {
165
+ return (right !== undefined && JSON.stringify(left, cursorJson) === JSON.stringify(right, cursorJson));
166
+ }
167
+ function cursorJson(_key, value) {
168
+ return typeof value === "bigint" ? value.toString() : value;
169
+ }
170
+ /**
171
+ * Provides one Datastore-backed storage handle for a record family.
172
+ */
173
+ export class DatastoreRecordStorage extends RecordStorage {
174
+ client;
175
+ // prettier-ignore
176
+ /**
177
+ * Declares atomic conditional mutations for compatible Datastore handles.
178
+ */
179
+ atomicCompareAndSet = true;
180
+ #codec;
181
+ /**
182
+ * Creates a Datastore-backed record storage handle.
183
+ *
184
+ * @param context Storage tenancy and naming context.
185
+ * @param recordSpec Schema, identifier, and column materialization contract.
186
+ * @param client Injected Datastore client owned by the factory.
187
+ * @param maxClientSideScan Maximum candidate records to materialize locally.
188
+ * @param group The optional generated storage group.
189
+ * @param kind The optional physical Datastore kind override.
190
+ * @param namespaceConverter Converts complete tenants to native namespaces.
191
+ * @param stringifiers Converts message-valued IDs and declared columns.
192
+ */
193
+ constructor(context, recordSpec, client, maxClientSideScan, group, kind, namespaceConverter = new DefaultNamespaceConverter(), stringifiers = new StringifierRegistry()) {
194
+ super(context, recordSpec);
195
+ this.client = client;
196
+ this.#codec = new FlatEntityCodec(context, recordSpec, client, maxClientSideScan, group, kind, namespaceConverter instanceof NamespaceAssignments
197
+ ? namespaceConverter
198
+ : new NamespaceAssignments(namespaceConverter), stringifiers);
199
+ }
200
+ /**
201
+ * Prepares one resolved physical row for an enclosing transaction.
202
+ *
203
+ * @param record The record to materialize.
204
+ * @returns The Datastore key and property map.
205
+ * @internal
206
+ */
207
+ transactionEntity(record) {
208
+ const materialized = this.recordSpec.materialize(record);
209
+ return {
210
+ key: this.#codec.key(this.client, materialized.id),
211
+ data: this.#codec.encode(materialized.record, materialized.columns),
212
+ excludeFromIndexes: [payloadProperty],
213
+ };
214
+ }
215
+ /**
216
+ * Decodes one entity read by a provider-owned multi-record transaction.
217
+ *
218
+ * @param entity The raw Datastore entity returned inside that transaction.
219
+ * @returns The decoded record.
220
+ * @internal
221
+ */
222
+ decodeTransactionEntity(entity) {
223
+ return this.#codec.decode(entity);
224
+ }
225
+ /**
226
+ * Checks whether one transaction-read entity is the exact expected record.
227
+ *
228
+ * @param entity The raw Datastore entity returned inside that transaction.
229
+ * @param expected The expected record snapshot.
230
+ * @returns Whether the persisted payload exactly matches the snapshot.
231
+ * @internal
232
+ */
233
+ matchesTransactionEntity(entity, expected) {
234
+ const expectedData = this.transactionEntity(expected).data[payloadProperty];
235
+ return RecordValues.payloadEqual(entity[payloadProperty], expectedData);
236
+ }
237
+ /**
238
+ * Reads one bounded, fully provider-executed page.
239
+ *
240
+ * Entity history uses this internal seam for records that would otherwise
241
+ * exceed the public query materialization budget.
242
+ *
243
+ * @param request The provider filters, ordering, continuation, and row limit.
244
+ * @returns The decoded page and an explicit continuation when more rows remain.
245
+ * @internal
246
+ */
247
+ async queryProviderPage(request) {
248
+ if (!Number.isSafeInteger(request.limit) || request.limit <= 0 || request.limit > 128)
249
+ throw new Error("Datastore provider page limit must be a positive integer no greater than 128.");
250
+ const query = this.#codec.createQuery(this.client);
251
+ for (const filter of request.filters ?? []) {
252
+ query.filter(new PropertyFilter(filter.property, filter.operator, this.#codec.columnValue(filter.property, filter.value)));
253
+ }
254
+ const order = request.order ?? [];
255
+ for (const item of order)
256
+ query.order(item.property, { descending: item.direction === "desc" });
257
+ if (!order.some((item) => item.property === "__key__"))
258
+ query.order("__key__", {
259
+ descending: order.at(-1)?.direction === "desc",
260
+ });
261
+ if (request.cursor !== undefined) {
262
+ const continuation = keysetFilter(order, request.cursor, this.client.KEY);
263
+ query.filter(continuation);
264
+ }
265
+ query.limit(request.limit);
266
+ const response = await this.provider(() => this.client.runQuery(query, wrappedReadOptions));
267
+ const entities = DatastoreResults.entities(response);
268
+ const info = DatastoreResults.queryInfo(response);
269
+ const entries = entities.map((entity) => ({
270
+ id: this.#codec.id(entity, this.client),
271
+ record: this.#codec.decode(entity),
272
+ }));
273
+ const cursor = entities.at(-1);
274
+ if (info.more && cursor === undefined)
275
+ throw new Error("Datastore provider page continuation is malformed.");
276
+ const next = cursor === undefined ? undefined : this.#codec.pageCursor(cursor, order, this.client.KEY);
277
+ if (info.more && request.cursor !== undefined && sameCursor(request.cursor, next))
278
+ throw new Error("Datastore provider page continuation did not advance.");
279
+ return {
280
+ entries,
281
+ cursor: info.more ? next : undefined,
282
+ hasMore: info.more,
283
+ };
284
+ }
285
+ /**
286
+ * Deletes a bounded group of provider entries.
287
+ *
288
+ * @param entries The entries selected by a preceding provider page.
289
+ * @returns Completes after the provider deletes the group.
290
+ * @internal
291
+ */
292
+ async deleteProviderEntries(entries) {
293
+ if (entries.length === 0)
294
+ return;
295
+ if (entries.length > 128)
296
+ throw new Error("Datastore provider delete group must contain no more than 128 entries.");
297
+ await this.provider(() => this.client.delete(entries.map((entry) => this.#codec.key(this.client, entry.id))));
298
+ }
299
+ /**
300
+ * Deletes the record at one storage slot.
301
+ * @param id The storage slot identifier.
302
+ * @returns Whether a record was deleted.
303
+ */
304
+ async deleteRecord(id) {
305
+ const key = this.#codec.key(this.client, id);
306
+ const entity = DatastoreResults.first(await this.provider(() => this.client.get(key, wrappedReadOptions)));
307
+ if (entity === undefined) {
308
+ return false;
309
+ }
310
+ await this.provider(() => this.client.delete(key));
311
+ return true;
312
+ }
313
+ /**
314
+ * Returns records matching a query.
315
+ * @param _query The record query.
316
+ * @returns The matching storage entries.
317
+ */
318
+ async queryRecordEntries(_query) {
319
+ const query = this.#codec.createQuery(this.client);
320
+ const providerLimit = DatastoreRecordQuery.limit(_query, this.#codec.queryLimit());
321
+ DatastoreQueryPushdown.translate(query, _query, (id) => this.#codec.key(this.client, id), (column) => this.#codec.columnProperty(column), (column, value) => this.#codec.columnValue(column, value));
322
+ query.limit(providerLimit);
323
+ const entities = DatastoreResults.entities(await this.provider(() => this.client.runQuery(query, wrappedReadOptions)));
324
+ if (providerLimit === this.#codec.queryLimit() && entities.length >= providerLimit) {
325
+ throw new DatastoreQueryLimitError(providerLimit - 1);
326
+ }
327
+ const entries = entities.map((entity) => ({
328
+ id: this.#codec.id(entity, this.client),
329
+ record: this.#codec.decode(entity),
330
+ columns: this.#codec.columns(entity),
331
+ }));
332
+ if (DatastoreRecordQuery.isKeysetPage(_query))
333
+ return entries.map(({ id, record }) => ({ id, record }));
334
+ return LocalQueryResults.apply(entries, _query);
335
+ }
336
+ /**
337
+ * Returns the supported Datastore query capabilities.
338
+ * @returns The supported query capabilities.
339
+ */
340
+ queryCapabilities() {
341
+ return {
342
+ comparisons: ["equal", "greaterThan", "lessThan", "greaterOrEqual", "lessOrEqual"],
343
+ features: ["order", "mask", "limit"],
344
+ };
345
+ }
346
+ /**
347
+ * Returns candidate records for a normalized query plan.
348
+ * @param plan The normalized query plan.
349
+ * @returns The candidate storage entries.
350
+ */
351
+ async queryPlanRecordEntries(plan) {
352
+ if (plan.predicate !== undefined && DatastoreQueryPushdown.legal(plan) === undefined)
353
+ throw new TypeError("Datastore normalized query has an illegal predicate or inequality ordering.");
354
+ for (const order of plan.order ?? [])
355
+ this.#codec.columnProperty(order.column);
356
+ const query = this.#codec.createQuery(this.client);
357
+ DatastoreQueryPushdown.plan(query, plan, (id) => this.#codec.key(this.client, id), (column) => this.#codec.columnProperty(column), (column, value) => this.#codec.columnValue(column, value));
358
+ const providerLimit = Math.min(this.#codec.queryLimit(), plan.limit ?? Number.POSITIVE_INFINITY, (plan.candidateLimit ?? defaultQueryCandidateLimit) + 1);
359
+ query.limit(providerLimit);
360
+ const entities = DatastoreResults.entities(await this.provider(() => this.client.runQuery(query, wrappedReadOptions)));
361
+ if (entities.length >= providerLimit && providerLimit === this.#codec.queryLimit()) {
362
+ throw new DatastoreQueryLimitError(providerLimit - 1);
363
+ }
364
+ return entities.map((entity) => ({
365
+ id: this.#codec.id(entity, this.client),
366
+ record: this.#codec.decode(entity),
367
+ }));
368
+ }
369
+ /**
370
+ * Reads the record at one storage slot.
371
+ * @param id The storage slot identifier.
372
+ * @returns The stored record, if present.
373
+ */
374
+ async readRecord(id) {
375
+ const entity = DatastoreResults.first(await this.provider(() => this.client.get(this.#codec.key(this.client, id), wrappedReadOptions)));
376
+ return entity === undefined ? undefined : this.#codec.decode(entity);
377
+ }
378
+ /**
379
+ * Compares and conditionally replaces the record at one storage slot.
380
+ * @param id The storage slot identifier.
381
+ * @param expected The expected materialized record.
382
+ * @param next The replacement materialized record, if any.
383
+ * @returns Whether the conditional mutation was applied.
384
+ */
385
+ async compareAndSetRecord(id, expected, next) {
386
+ for (let attempt = 0; attempt < maxCasAttempts; attempt += 1) {
387
+ try {
388
+ return await this.attemptCompareAndSet(id, expected, next);
389
+ }
390
+ catch (error) {
391
+ if (attempt + 1 < maxCasAttempts && DatastoreTransactions.retry(error)) {
392
+ await DatastoreTransactions.wait(attempt);
393
+ continue;
394
+ }
395
+ throw DatastoreTransactions.redact(error);
396
+ }
397
+ }
398
+ throw new Error("Datastore compare-and-set retry limit was reached.");
399
+ }
400
+ async attemptCompareAndSet(id, expected, next) {
401
+ const key = this.#codec.key(this.client, id);
402
+ const transaction = this.transaction();
403
+ try {
404
+ await transaction.run();
405
+ const entity = DatastoreResults.first(await transaction.get(key, wrappedReadOptions));
406
+ const expectedPayload = expected === undefined
407
+ ? undefined
408
+ : this.#codec.encode(expected.record, expected.columns)[payloadProperty];
409
+ if (entity === undefined
410
+ ? expected !== undefined
411
+ : !RecordValues.payloadEqual(entity[payloadProperty], expectedPayload)) {
412
+ await transaction.rollback();
413
+ return false;
414
+ }
415
+ if (next === undefined)
416
+ transaction.delete(key);
417
+ else {
418
+ const data = this.#codec.encode(next.record, next.columns);
419
+ transaction.save({ key, data, excludeFromIndexes: this.#codec.unindexedProperties(data) });
420
+ }
421
+ await transaction.commit();
422
+ return true;
423
+ }
424
+ catch (error) {
425
+ await transaction.rollback().catch(() => undefined);
426
+ throw error;
427
+ }
428
+ }
429
+ /**
430
+ * Writes materialized records.
431
+ * @param records The materialized records to write.
432
+ * @returns Completes when the records are written.
433
+ */
434
+ async writeAllRecords(records) {
435
+ for (let offset = 0; offset < records.length; offset += maxMutationsPerBatch) {
436
+ const rows = records.slice(offset, offset + maxMutationsPerBatch).map((record) => {
437
+ const data = this.#codec.encode(record.record, record.columns);
438
+ return {
439
+ key: this.#codec.key(this.client, record.id),
440
+ data,
441
+ excludeFromIndexes: this.#codec.unindexedProperties(data),
442
+ };
443
+ });
444
+ await this.provider(() => this.client.save(rows));
445
+ }
446
+ }
447
+ /**
448
+ * Writes one materialized record.
449
+ * @param record The materialized record to write.
450
+ * @returns Completes when the record is written.
451
+ */
452
+ async writeRecord(record) {
453
+ const data = this.#codec.encode(record.record, record.columns);
454
+ const key = this.#codec.key(this.client, record.id);
455
+ await this.provider(() => this.client.save({
456
+ key,
457
+ data,
458
+ excludeFromIndexes: this.#codec.unindexedProperties(data),
459
+ }));
460
+ }
461
+ async provider(operation) {
462
+ try {
463
+ return await operation();
464
+ }
465
+ catch {
466
+ throw new Error("Datastore provider operation failed.");
467
+ }
468
+ }
469
+ /**
470
+ * Creates a transaction scoped to this handle's selected namespace.
471
+ *
472
+ * @returns The namespace-scoped Datastore transaction.
473
+ * @internal
474
+ */
475
+ transaction() {
476
+ const transaction = this.client.transaction();
477
+ const namespace = this.#codec.namespace();
478
+ if (namespace !== undefined)
479
+ transaction.namespace = namespace;
480
+ return transaction;
481
+ }
482
+ }
483
+ /**
484
+ * Converts values between Spine records and the Datastore provider representation.
485
+ */
486
+ const RecordValues = Object.freeze(new (class {
487
+ // prettier-ignore
488
+ /**
489
+ * Compares canonical local values for equality.
490
+ */
491
+ equal(left, right) {
492
+ return StorageQueryValues.equal(left, right);
493
+ }
494
+ /**
495
+ * Compares canonical local values for ordering.
496
+ */
497
+ compare(left, right) {
498
+ return StorageQueryValues.compare(left, right);
499
+ }
500
+ /**
501
+ * Compares two persisted payload byte arrays.
502
+ */
503
+ payloadEqual(left, right) {
504
+ return (left instanceof Uint8Array &&
505
+ right instanceof Uint8Array &&
506
+ left.byteLength === right.byteLength &&
507
+ left.every((value, index) => value === right[index]));
508
+ }
509
+ })());
510
+ /**
511
+ * Raised when a query would exceed the adapter's configured finite client-side scan budget.
512
+ */
513
+ export class DatastoreQueryLimitError extends Error {
514
+ maxClientSideScan;
515
+ // prettier-ignore
516
+ /**
517
+ * Creates the scan-budget error.
518
+ *
519
+ * @param maxClientSideScan Configured maximum number of locally scanned candidates.
520
+ */
521
+ constructor(maxClientSideScan) {
522
+ super(`Datastore query exceeded the client-side scan limit of ${String(maxClientSideScan)}.`);
523
+ this.maxClientSideScan = maxClientSideScan;
524
+ this.name = "DatastoreQueryLimitError";
525
+ }
526
+ }
527
+ /**
528
+ * Translates safe normalized record queries to Datastore query operations.
529
+ */
530
+ class DatastoreQueryPushdownHelper {
531
+ // prettier-ignore
532
+ /**
533
+ * Translates the legacy record-query surface.
534
+ */
535
+ translate(query, recordQuery, keyFor, columnProperty, columnValue) {
536
+ if (recordQuery.ids !== undefined && recordQuery.ids.length > 0) {
537
+ const keys = recordQuery.ids.map(keyFor);
538
+ const first = recordQuery.ids[0];
539
+ if (first === undefined)
540
+ throw new Error("Datastore record query ID is invalid.");
541
+ const key = keyFor(first);
542
+ query.filter(new PropertyFilter("__key__", keys.length === 1 ? "=" : "IN", keys.length === 1 ? key : keys));
543
+ }
544
+ for (const filter of recordQuery.filters ?? []) {
545
+ const values = Array.isArray(filter.value) ? filter.value : [filter.value];
546
+ const providerValues = filter.column === "id"
547
+ ? values.map((value) => keyFor(value))
548
+ : values.map((value) => columnValue(filter.column, value));
549
+ query.filter(new PropertyFilter(filter.column === "id" ? "__key__" : columnProperty(filter.column), values.length === 1 ? "=" : "IN", values.length === 1 ? providerValues[0] : providerValues));
550
+ }
551
+ for (const order of recordQuery.sort ?? []) {
552
+ query.order(order.field === "id" ? "__key__" : columnProperty(order.field), {
553
+ descending: order.direction === "desc",
554
+ });
555
+ }
556
+ if (!(recordQuery.sort ?? []).some((order) => order.field === "id"))
557
+ query.order("__key__");
558
+ if (recordQuery.after !== undefined && DatastoreRecordQuery.isKeysetPage(recordQuery))
559
+ query.filter(new PropertyFilter("__key__", ">", keyFor(recordQuery.after.id)));
560
+ }
561
+ /**
562
+ * Applies the all-or-nothing legal portion of a normalized query plan.
563
+ */
564
+ plan(query, plan, keyFor, columnProperty, columnValue) {
565
+ const legal = this.legal(plan);
566
+ if (legal !== undefined) {
567
+ this.predicate(query, legal, keyFor, columnProperty, columnValue);
568
+ }
569
+ if (legal !== undefined || plan.predicate === undefined) {
570
+ for (const order of plan.order ?? []) {
571
+ query.order(columnProperty(order.column), { descending: order.direction === "desc" });
572
+ }
573
+ if ((plan.order?.length ?? 0) > 0)
574
+ query.order("__key__");
575
+ }
576
+ }
577
+ /**
578
+ * Returns the predicate only when every clause satisfies provider restrictions.
579
+ */
580
+ legal(plan) {
581
+ const predicate = plan.predicate;
582
+ if (predicate === undefined)
583
+ return undefined;
584
+ const leaves = predicate.kind === "all" ? predicate.predicates : [predicate];
585
+ let idCount = 0;
586
+ const inequalityColumns = new Set();
587
+ for (const leaf of leaves) {
588
+ if (leaf.kind !== "ids" && leaf.kind !== "comparison")
589
+ return undefined;
590
+ if (leaf.kind === "ids") {
591
+ idCount += leaf.ids.length;
592
+ if (idCount > 30)
593
+ return undefined;
594
+ continue;
595
+ }
596
+ if (leaf.operator !== "equal") {
597
+ inequalityColumns.add(leaf.column);
598
+ }
599
+ }
600
+ if (inequalityColumns.size > 1)
601
+ return undefined;
602
+ const inequalityColumn = inequalityColumns.values().next().value;
603
+ if (inequalityColumn !== undefined && plan.order?.[0]?.column !== inequalityColumn) {
604
+ return undefined;
605
+ }
606
+ return predicate;
607
+ }
608
+ /**
609
+ * Adds one already-legal normalized predicate to the provider query.
610
+ */
611
+ predicate(query, predicate, keyFor, columnProperty, columnValue) {
612
+ if (predicate.kind === "all") {
613
+ predicate.predicates.forEach((child) => {
614
+ this.predicate(query, child, keyFor, columnProperty, columnValue);
615
+ });
616
+ return;
617
+ }
618
+ if (predicate.kind === "ids") {
619
+ const keys = predicate.ids.map(keyFor);
620
+ const firstKey = keys[0];
621
+ if (keys.length === 1 && firstKey !== undefined) {
622
+ query.filter(new PropertyFilter("__key__", "=", firstKey));
623
+ }
624
+ else {
625
+ query.filter(new PropertyFilter("__key__", "IN", keys));
626
+ }
627
+ return;
628
+ }
629
+ if (predicate.kind !== "comparison")
630
+ return;
631
+ const operator = predicate.operator === "equal"
632
+ ? "="
633
+ : predicate.operator === "greaterThan"
634
+ ? ">"
635
+ : predicate.operator === "lessThan"
636
+ ? "<"
637
+ : predicate.operator === "greaterOrEqual"
638
+ ? ">="
639
+ : "<=";
640
+ query.filter(new PropertyFilter(columnProperty(predicate.column), operator, columnValue(predicate.column, predicate.value)));
641
+ }
642
+ }
643
+ /**
644
+ * Frozen Datastore query pushdown operations.
645
+ */
646
+ const DatastoreQueryPushdown = Object.freeze(new DatastoreQueryPushdownHelper());
647
+ /**
648
+ * Selects safe provider bounds for direct record queries.
649
+ */
650
+ const DatastoreRecordQuery = Object.freeze(new (class {
651
+ // prettier-ignore
652
+ /**
653
+ * Returns a caller limit only when it cannot change local query semantics.
654
+ *
655
+ * @param query Supplies the requested record query.
656
+ * @param scanLimit Supplies the configured materialization limit.
657
+ * @returns The finite provider row bound.
658
+ */
659
+ limit(query, scanLimit) {
660
+ const requestedLimit = query.limit;
661
+ if (requestedLimit !== undefined && this.isKeysetPage(query))
662
+ return requestedLimit;
663
+ if (query.limit !== undefined &&
664
+ query.ids === undefined &&
665
+ query.filters === undefined &&
666
+ query.sort === undefined &&
667
+ query.after === undefined &&
668
+ query.offset === undefined)
669
+ return query.limit;
670
+ return scanLimit;
671
+ }
672
+ /**
673
+ * Returns whether a legacy query is exactly an ascending identifier keyset page.
674
+ *
675
+ * @param query The record query to classify.
676
+ * @returns `true` when Datastore can apply its limit and continuation completely.
677
+ */
678
+ isKeysetPage(query) {
679
+ const order = query.sort;
680
+ const continuation = query.after;
681
+ const continuationValue = continuation?.values[0];
682
+ return (query.limit !== undefined &&
683
+ query.ids === undefined &&
684
+ query.filters === undefined &&
685
+ query.offset === undefined &&
686
+ order?.length === 1 &&
687
+ order[0]?.field === "id" &&
688
+ order[0].direction !== "desc" &&
689
+ (continuation === undefined ||
690
+ (continuation.values.length === 1 &&
691
+ continuationValue?.field === "id" &&
692
+ RecordValues.equal(continuationValue.value, continuation.id))));
693
+ }
694
+ })());
695
+ /**
696
+ * Applies record-query filtering, ordering, continuation, and paging locally.
697
+ */
698
+ class LocalQueryResultsHelper {
699
+ // prettier-ignore
700
+ /**
701
+ * Applies one legacy record query to materialized candidate entries.
702
+ */
703
+ apply(entries, query) {
704
+ const matching = entries.filter((entry) => this.matches(entry, query));
705
+ const orders = query.sort ?? [];
706
+ const sorted = [...matching].sort((left, right) => this.order(left, right, orders));
707
+ const continuation = query.after;
708
+ const continued = continuation === undefined
709
+ ? sorted
710
+ : sorted.filter((entry) => this.continuation(entry, orders, continuation) > 0);
711
+ const start = query.offset ?? 0;
712
+ const end = query.limit === undefined ? undefined : start + query.limit;
713
+ return continued.slice(start, end).map(({ id, record }) => ({ id, record }));
714
+ }
715
+ /**
716
+ * Tests whether a candidate satisfies the ID and equality filters.
717
+ */
718
+ matches(entry, query) {
719
+ return ((query.ids === undefined ||
720
+ query.ids.length === 0 ||
721
+ query.ids.some((id) => RecordValues.equal(id, entry.id))) &&
722
+ (query.filters ?? []).every((filter) => {
723
+ const actual = filter.column === "id" ? entry.id : entry.columns.get(filter.column);
724
+ const expected = Array.isArray(filter.value) ? filter.value : [filter.value];
725
+ return expected.some((value) => RecordValues.equal(value, actual));
726
+ }));
727
+ }
728
+ /**
729
+ * Orders candidates by requested sort fields and canonical ID tie-breaker.
730
+ */
731
+ order(left, right, orders) {
732
+ for (const order of orders) {
733
+ const comparison = RecordValues.compare(this.value(left, order.field), this.value(right, order.field));
734
+ if (comparison !== 0)
735
+ return order.direction === "desc" ? comparison * -1 : comparison;
736
+ }
737
+ return RecordValues.compare(left.id, right.id);
738
+ }
739
+ /**
740
+ * Compares a candidate with a normalized continuation cursor.
741
+ */
742
+ continuation(entry, orders, after) {
743
+ for (let index = 0; index < orders.length; index += 1) {
744
+ const order = orders[index];
745
+ if (order === undefined)
746
+ throw new Error("Record query continuation sort order is invalid.");
747
+ const comparison = RecordValues.compare(this.value(entry, order.field), after.values[index]?.value);
748
+ if (comparison !== 0)
749
+ return order.direction === "desc" ? comparison * -1 : comparison;
750
+ }
751
+ return RecordValues.compare(entry.id, after.id);
752
+ }
753
+ /**
754
+ * Obtains an ID or indexed column value from a candidate.
755
+ */
756
+ value(entry, field) {
757
+ return field === "id" ? entry.id : entry.columns.get(field);
758
+ }
759
+ }
760
+ /**
761
+ * Frozen local record-query reconciliation operations.
762
+ */
763
+ const LocalQueryResults = Object.freeze(new LocalQueryResultsHelper());
764
+ /**
765
+ * Protects transaction failures and coordinates bounded CAS retries.
766
+ */
767
+ class DatastoreTransactionsHelper {
768
+ // prettier-ignore
769
+ /**
770
+ * Redacts credential-like errors while preserving safe provider failures.
771
+ */
772
+ redact(error) {
773
+ void error;
774
+ return new Error("Datastore transaction failed.");
775
+ }
776
+ /**
777
+ * Identifies a safe-to-retry Datastore transaction conflict.
778
+ */
779
+ retry(error) {
780
+ return (error instanceof Error &&
781
+ !/(credential|payload|secret)/i.test(error.message) &&
782
+ Reflect.get(error, "code") === 10);
783
+ }
784
+ /**
785
+ * Waits using exponential jitter before a bounded CAS retry.
786
+ */
787
+ async wait(attempt) {
788
+ const exponentialDelay = casRetryDelayMs * 2 ** attempt;
789
+ const jitter = Math.floor(Math.random() * exponentialDelay);
790
+ await new Promise((resolve) => setTimeout(resolve, exponentialDelay + jitter));
791
+ }
792
+ }
793
+ /**
794
+ * Frozen Datastore transaction retry and redaction operations.
795
+ */
796
+ const DatastoreTransactions = Object.freeze(new DatastoreTransactionsHelper());
797
+ /**
798
+ * Validates Datastore read and query response shapes.
799
+ */
800
+ class DatastoreResultsHelper {
801
+ // prettier-ignore
802
+ /**
803
+ * Extracts the optional first entity from a Datastore read response.
804
+ */
805
+ first(response) {
806
+ if (!Array.isArray(response))
807
+ throw new Error("Datastore returned an invalid entity response.");
808
+ const entity = response[0];
809
+ return entity === undefined ? undefined : this.entity(entity);
810
+ }
811
+ /**
812
+ * Extracts entity rows from a Datastore query response.
813
+ */
814
+ entities(response) {
815
+ if (!Array.isArray(response) || !Array.isArray(response[0])) {
816
+ throw new Error("Datastore returned an invalid query response.");
817
+ }
818
+ return response[0].map((value) => this.entity(value));
819
+ }
820
+ /**
821
+ * Extracts the provider continuation and the remaining-row indication.
822
+ */
823
+ queryInfo(response) {
824
+ if (!Array.isArray(response) || typeof response[1] !== "object" || response[1] === null)
825
+ throw new Error("Datastore returned an invalid query response.");
826
+ const info = response[1];
827
+ const cursor = info.endCursor;
828
+ const moreResults = info.moreResults;
829
+ if (moreResults !== "NO_MORE_RESULTS" &&
830
+ moreResults !== "MORE_RESULTS_AFTER_LIMIT" &&
831
+ moreResults !== "MORE_RESULTS_AFTER_CURSOR")
832
+ throw new Error("Datastore returned an invalid query response.");
833
+ const more = moreResults === "MORE_RESULTS_AFTER_LIMIT" || moreResults === "MORE_RESULTS_AFTER_CURSOR";
834
+ if (more && !(cursor instanceof Buffer || typeof cursor === "string"))
835
+ throw new Error("Datastore returned an invalid query response.");
836
+ return {
837
+ cursor: cursor instanceof Buffer || typeof cursor === "string" ? cursor : undefined,
838
+ more,
839
+ };
840
+ }
841
+ /**
842
+ * Validates one provider entity object.
843
+ */
844
+ entity(value) {
845
+ if (typeof value !== "object" || value === null) {
846
+ throw new Error("Datastore returned an invalid entity response.");
847
+ }
848
+ return value;
849
+ }
850
+ }
851
+ /**
852
+ * Frozen Datastore provider response parsing operations.
853
+ */
854
+ const DatastoreResults = Object.freeze(new DatastoreResultsHelper());
855
+ //# sourceMappingURL=record-storage.js.map