@aws-blocks/bb-distributed-table 0.1.0

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 (48) hide show
  1. package/LICENSE +174 -0
  2. package/README.md +292 -0
  3. package/dist/errors.d.ts +111 -0
  4. package/dist/errors.d.ts.map +1 -0
  5. package/dist/errors.js +135 -0
  6. package/dist/gsi-manager-lambda/index.js +221 -0
  7. package/dist/gsi-manager-lambda.d.ts +26 -0
  8. package/dist/gsi-manager-lambda.d.ts.map +1 -0
  9. package/dist/gsi-manager-lambda.js +244 -0
  10. package/dist/index.aws.d.ts +59 -0
  11. package/dist/index.aws.d.ts.map +1 -0
  12. package/dist/index.aws.js +310 -0
  13. package/dist/index.browser.d.ts +5 -0
  14. package/dist/index.browser.d.ts.map +1 -0
  15. package/dist/index.browser.js +7 -0
  16. package/dist/index.cdk.d.ts +27 -0
  17. package/dist/index.cdk.d.ts.map +1 -0
  18. package/dist/index.cdk.js +180 -0
  19. package/dist/index.cdk.test.d.ts +2 -0
  20. package/dist/index.cdk.test.d.ts.map +1 -0
  21. package/dist/index.cdk.test.js +93 -0
  22. package/dist/index.mock.d.ts +101 -0
  23. package/dist/index.mock.d.ts.map +1 -0
  24. package/dist/index.mock.js +301 -0
  25. package/dist/index.test.d.ts +2 -0
  26. package/dist/index.test.d.ts.map +1 -0
  27. package/dist/index.test.js +555 -0
  28. package/dist/parity.test.d.ts +2 -0
  29. package/dist/parity.test.d.ts.map +1 -0
  30. package/dist/parity.test.js +557 -0
  31. package/dist/types.d.ts +143 -0
  32. package/dist/types.d.ts.map +1 -0
  33. package/dist/types.js +3 -0
  34. package/dist/version.d.ts +3 -0
  35. package/dist/version.d.ts.map +1 -0
  36. package/dist/version.js +3 -0
  37. package/package.json +49 -0
  38. package/src/errors.ts +145 -0
  39. package/src/gsi-manager-lambda.ts +305 -0
  40. package/src/index.aws.ts +400 -0
  41. package/src/index.browser.ts +8 -0
  42. package/src/index.cdk.test.ts +107 -0
  43. package/src/index.cdk.ts +220 -0
  44. package/src/index.mock.ts +363 -0
  45. package/src/index.test.ts +657 -0
  46. package/src/parity.test.ts +763 -0
  47. package/src/types.ts +163 -0
  48. package/src/version.ts +3 -0
@@ -0,0 +1,93 @@
1
+ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * CDK-side regression tests for DistributedTable.
5
+ *
6
+ * History: DistributedTable.fromExisting was advertised in the runtime build
7
+ * but the CDK constructor unconditionally provisioned a new DynamoDB table
8
+ * AND the static factory was missing entirely from the CDK class. These
9
+ * tests pin the fix and ensure GSI custom resources are NOT created when
10
+ * binding to an external table.
11
+ */
12
+ import { test } from 'node:test';
13
+ import assert from 'node:assert';
14
+ import * as cdk from 'aws-cdk-lib';
15
+ import { Template } from 'aws-cdk-lib/assertions';
16
+ import { Scope, DEFAULT_NODE_RUNTIME } from '@aws-blocks/core/cdk';
17
+ import { z } from 'zod';
18
+ import { DistributedTable } from './index.cdk.js';
19
+ const userSchema = z.object({
20
+ userId: z.string(),
21
+ email: z.string(),
22
+ createdAt: z.number(),
23
+ });
24
+ class StubBlocksStack extends cdk.Stack {
25
+ handler;
26
+ id;
27
+ constructor(scope, id) {
28
+ super(scope, id);
29
+ this.id = id;
30
+ globalThis.CURRENT_BLOCKS_STACK = this;
31
+ this.handler = new cdk.aws_lambda.Function(this, 'StubHandler', {
32
+ runtime: DEFAULT_NODE_RUNTIME,
33
+ handler: 'index.handler',
34
+ code: cdk.aws_lambda.Code.fromInline('exports.handler = async () => {};'),
35
+ });
36
+ }
37
+ }
38
+ function setup() {
39
+ const app = new cdk.App();
40
+ const stack = new StubBlocksStack(app, 'TestStack');
41
+ const parent = new Scope('app');
42
+ return { stack, parent };
43
+ }
44
+ test('CDK: default DistributedTable provisions a DynamoDB table', () => {
45
+ const { stack, parent } = setup();
46
+ new DistributedTable(parent, 'users', {
47
+ schema: userSchema,
48
+ key: { partitionKey: 'userId', sortKey: 'createdAt' },
49
+ });
50
+ const template = Template.fromStack(stack);
51
+ template.resourceCountIs('AWS::DynamoDB::Table', 1);
52
+ });
53
+ test('CDK: DistributedTable.fromExisting does NOT provision a table (regression)', () => {
54
+ const { stack, parent } = setup();
55
+ new DistributedTable(parent, 'users', {
56
+ schema: userSchema,
57
+ key: { partitionKey: 'userId', sortKey: 'createdAt' },
58
+ table: DistributedTable.fromExisting('preexisting-users-table'),
59
+ });
60
+ const template = Template.fromStack(stack);
61
+ template.resourceCountIs('AWS::DynamoDB::Table', 0);
62
+ });
63
+ test('CDK: DistributedTable.fromExisting with indexes does NOT provision the GSI custom resource', () => {
64
+ const { stack, parent } = setup();
65
+ new DistributedTable(parent, 'users', {
66
+ schema: userSchema,
67
+ key: { partitionKey: 'userId', sortKey: 'createdAt' },
68
+ indexes: {
69
+ byEmail: { partitionKey: 'email' },
70
+ },
71
+ table: DistributedTable.fromExisting('preexisting-users-table'),
72
+ });
73
+ const template = Template.fromStack(stack);
74
+ // The GSI manager is realized as a Provider (Lambda + custom resource).
75
+ // `fromExisting` must opt out of touching indexes — the customer owns
76
+ // the existing table's index lifecycle.
77
+ template.resourceCountIs('AWS::CloudFormation::CustomResource', 0);
78
+ });
79
+ test('CDK: DistributedTable.fromExisting returns a branded ref', () => {
80
+ const ref = DistributedTable.fromExisting('foo');
81
+ assert.strictEqual(ref.tableName, 'foo');
82
+ assert.strictEqual(ref.__brand, 'ExternalTableRef');
83
+ });
84
+ test('CDK: calling a runtime data method throws an actionable error (not a cryptic TypeError)', () => {
85
+ const { parent } = setup();
86
+ const table = new DistributedTable(parent, 'users', {
87
+ schema: userSchema,
88
+ key: { partitionKey: 'userId', sortKey: 'createdAt' },
89
+ });
90
+ for (const method of ['get', 'put', 'delete', 'query', 'scan', 'getBatch', 'putBatch', 'deleteBatch']) {
91
+ assert.throws(() => table[method]('k'), /cannot be called during CDK synth/, `${method}() should throw the actionable synth-time error`);
92
+ }
93
+ });
@@ -0,0 +1,101 @@
1
+ import { Scope } from '@aws-blocks/core';
2
+ import type { ScopeParent } from '@aws-blocks/core';
3
+ export { DistributedTableErrors } from './errors.js';
4
+ export type { TableKeyConfig, DistributedTableOptions, ExternalTableRef, TableKey, PartitionKeyCondition, SortKeyCondition, KeyCondition, QueryOptions, ScanOptions, PutOptions, DeleteOptions, } from './types.js';
5
+ import type { TableKeyConfig, DistributedTableOptions, ExternalTableRef, ScanOptions, PutOptions, DeleteOptions, TableKey } from './types.js';
6
+ /**
7
+ * Structured data storage backed by DynamoDB with secondary indexes and
8
+ * rich query capabilities.
9
+ *
10
+ * **When to use:** You need to query by multiple fields, use composite keys,
11
+ * or perform sort-key-based range queries. Good for entities with relationships,
12
+ * time-series data, and access patterns that require multiple indexes.
13
+ *
14
+ * **When NOT to use:** If you only need single-key lookups, use `KVStore`.
15
+ * If you need full SQL (joins, aggregations), use `Database`.
16
+ *
17
+ * **Best practices:**
18
+ * - Design partition keys for even data distribution (e.g., `userId`, `tenantId`)
19
+ * - Use sort keys for range queries (e.g., timestamps, alphabetical ordering)
20
+ * - Define GSIs upfront for known access patterns — adding them later requires backfill
21
+ * - Use `{ ifNotExists: true }` for idempotent creates
22
+ * - Use `{ ifFieldEquals }` for optimistic locking (compare-and-swap)
23
+ *
24
+ * **Scaling:** PAY_PER_REQUEST billing. Single-digit ms reads/writes.
25
+ * Throughput scales automatically. Items limited to 400 KB.
26
+ * GSIs have separate throughput and may throttle independently.
27
+ */
28
+ export declare class DistributedTable<T, K extends TableKeyConfig<T> = TableKeyConfig<T>, Indexes extends Record<string, TableKeyConfig<T>> = Record<string, TableKeyConfig<T>>> extends Scope {
29
+ options: DistributedTableOptions<T, K, Indexes>;
30
+ private filePath;
31
+ private data;
32
+ private schema;
33
+ private keyConfig;
34
+ private indexes;
35
+ /** @internal Logger for internal operations. Defaults to error-level when not provided. */
36
+ protected log: ChildLogger;
37
+ constructor(scope: ScopeParent, id: string, options: DistributedTableOptions<T, K, Indexes>);
38
+ get(key: TableKey<T, K>): Promise<T | null>;
39
+ put(item: T, options?: PutOptions<T>): Promise<void>;
40
+ delete(key: TableKey<T, K>, options?: DeleteOptions<T>): Promise<void>;
41
+ /**
42
+ * Query items by index. The input object's fields are determined by the index:
43
+ * - Partition key field: required, `{ equals: value }`
44
+ * - Sort key field (if defined): optional, supports `equals`, `greaterThan`,
45
+ * `lessThan`, `between`, `beginsWith` (strings only), etc.
46
+ *
47
+ * @example
48
+ * ```typescript
49
+ * // Primary key query
50
+ * for await (const item of table.query({ where: { userId: { equals: 'u1' } } })) { ... }
51
+ *
52
+ * // GSI query with limit and reverse order
53
+ * for await (const item of table.query({
54
+ * index: 'byStatus',
55
+ * where: { status: { equals: 'pending' } },
56
+ * limit: 10,
57
+ * order: 'desc',
58
+ * })) { ... }
59
+ * ```
60
+ *
61
+ * @throws {DistributedTableErrors.InvalidQuery} If `options.index` does not exist,
62
+ * the `where` clause is missing, the partition key is not given as
63
+ * `{ equals: value }`, or more than one sort-key condition is supplied
64
+ * (DynamoDB allows only one per query — use `between` for ranges).
65
+ */
66
+ query(options: QueryOptions<T, K, Indexes>): AsyncIterable<T>;
67
+ scan(options?: ScanOptions): AsyncIterable<T>;
68
+ /**
69
+ * Fetch multiple items by key in batches. Returns results positionally —
70
+ * `null` for keys with no matching item.
71
+ *
72
+ * @throws {DistributedTableErrors.BatchIncomplete} (AWS runtime only) If DynamoDB
73
+ * leaves keys unprocessed after the retry budget is exhausted, typically under
74
+ * sustained throttling. The local mock never throttles, so it does not throw this.
75
+ */
76
+ getBatch(keys: TableKey<T, K>[]): Promise<(T | null)[]>;
77
+ /**
78
+ * Write multiple items in batches. Each item is schema-validated first.
79
+ *
80
+ * @throws {DistributedTableErrors.BatchIncomplete} (AWS runtime only) If DynamoDB
81
+ * leaves writes unprocessed after the retry budget is exhausted, typically under
82
+ * sustained throttling. The local mock never throttles, so it does not throw this.
83
+ */
84
+ putBatch(items: T[]): Promise<void>;
85
+ /**
86
+ * Delete multiple items by key in batches.
87
+ *
88
+ * @throws {DistributedTableErrors.BatchIncomplete} (AWS runtime only) If DynamoDB
89
+ * leaves deletes unprocessed after the retry budget is exhausted, typically under
90
+ * sustained throttling. The local mock never throttles, so it does not throw this.
91
+ */
92
+ deleteBatch(keys: TableKey<T, K>[]): Promise<void>;
93
+ static fromExisting(tableName: string): ExternalTableRef;
94
+ private checkFieldEquals;
95
+ private serializeKey;
96
+ private loadFromDisk;
97
+ private flushToDisk;
98
+ }
99
+ import type { QueryOptions } from './types.js';
100
+ import type { ChildLogger } from '@aws-blocks/bb-logger';
101
+ //# sourceMappingURL=index.mock.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.mock.d.ts","sourceRoot":"","sources":["../src/index.mock.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,301 @@
1
+ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ import { Scope, registerSdkIdentifiers } from '@aws-blocks/core';
4
+ import { getMockDataDir } from '@aws-blocks/core/bb-utils';
5
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+ import { BB_NAME, BB_VERSION } from './version.js';
8
+ export { DistributedTableErrors } from './errors.js';
9
+ import { DistributedTableErrors, DistributedTableMessages, blocksError, normalizeSortKeyCondition } from './errors.js';
10
+ // ── Helpers ─────────────────────────────────────────────────────────────────
11
+ const MAX_ITEM_BYTES = 400 * 1024;
12
+ async function validateSchema(schema, value) {
13
+ const result = schema['~standard'].validate(value);
14
+ const resolved = result instanceof Promise ? await result : result;
15
+ if (resolved.issues) {
16
+ throw blocksError(DistributedTableErrors.ValidationFailed, resolved.issues[0].message);
17
+ }
18
+ }
19
+ function matchesSortKeyCondition(value, condition) {
20
+ if ('equals' in condition && condition.equals !== undefined && value !== condition.equals)
21
+ return false;
22
+ if ('greaterThan' in condition && condition.greaterThan !== undefined && !(value > condition.greaterThan))
23
+ return false;
24
+ if ('greaterThanOrEqual' in condition && condition.greaterThanOrEqual !== undefined && !(value >= condition.greaterThanOrEqual))
25
+ return false;
26
+ if ('lessThan' in condition && condition.lessThan !== undefined && !(value < condition.lessThan))
27
+ return false;
28
+ if ('lessThanOrEqual' in condition && condition.lessThanOrEqual !== undefined && !(value <= condition.lessThanOrEqual))
29
+ return false;
30
+ if ('between' in condition && condition.between && !(value >= condition.between[0] && value <= condition.between[1]))
31
+ return false;
32
+ if ('beginsWith' in condition && condition.beginsWith !== undefined) {
33
+ if (typeof value !== 'string' || !value.startsWith(condition.beginsWith))
34
+ return false;
35
+ }
36
+ return true;
37
+ }
38
+ /**
39
+ * Order-independent structural equality, used to compare `ifFieldEquals` values
40
+ * against stored attributes. DynamoDB Maps are an unordered collection of
41
+ * name-value pairs, so `{ a, b }` and `{ b, a }` are the same value — a plain
42
+ * `JSON.stringify` compare would wrongly treat them as different and fail the
43
+ * condition. Arrays remain order-sensitive (DynamoDB Lists are ordered).
44
+ */
45
+ function deepEqual(a, b) {
46
+ if (a === b)
47
+ return true;
48
+ if (typeof a !== typeof b)
49
+ return false;
50
+ if (a === null || b === null)
51
+ return a === b;
52
+ if (Array.isArray(a) || Array.isArray(b)) {
53
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length)
54
+ return false;
55
+ return a.every((v, i) => deepEqual(v, b[i]));
56
+ }
57
+ if (typeof a === 'object' && typeof b === 'object') {
58
+ const aKeys = Object.keys(a);
59
+ const bKeys = Object.keys(b);
60
+ if (aKeys.length !== bKeys.length)
61
+ return false;
62
+ return aKeys.every(k => Object.prototype.hasOwnProperty.call(b, k) &&
63
+ deepEqual(a[k], b[k]));
64
+ }
65
+ return false;
66
+ }
67
+ // ── DistributedTable (mock) ─────────────────────────────────────────────────
68
+ /**
69
+ * Structured data storage backed by DynamoDB with secondary indexes and
70
+ * rich query capabilities.
71
+ *
72
+ * **When to use:** You need to query by multiple fields, use composite keys,
73
+ * or perform sort-key-based range queries. Good for entities with relationships,
74
+ * time-series data, and access patterns that require multiple indexes.
75
+ *
76
+ * **When NOT to use:** If you only need single-key lookups, use `KVStore`.
77
+ * If you need full SQL (joins, aggregations), use `Database`.
78
+ *
79
+ * **Best practices:**
80
+ * - Design partition keys for even data distribution (e.g., `userId`, `tenantId`)
81
+ * - Use sort keys for range queries (e.g., timestamps, alphabetical ordering)
82
+ * - Define GSIs upfront for known access patterns — adding them later requires backfill
83
+ * - Use `{ ifNotExists: true }` for idempotent creates
84
+ * - Use `{ ifFieldEquals }` for optimistic locking (compare-and-swap)
85
+ *
86
+ * **Scaling:** PAY_PER_REQUEST billing. Single-digit ms reads/writes.
87
+ * Throughput scales automatically. Items limited to 400 KB.
88
+ * GSIs have separate throughput and may throttle independently.
89
+ */
90
+ export class DistributedTable extends Scope {
91
+ options;
92
+ filePath;
93
+ data;
94
+ schema;
95
+ keyConfig;
96
+ indexes;
97
+ /** @internal Logger for internal operations. Defaults to error-level when not provided. */
98
+ log;
99
+ constructor(scope, id, options) {
100
+ super(id, { parent: scope, bbName: BB_NAME, bbVersion: BB_VERSION });
101
+ this.options = options;
102
+ this.log = options?.logger ?? new Logger(this, 'logger', { level: 'error' });
103
+ this.filePath = join(getMockDataDir(this), 'data.json');
104
+ this.data = this.loadFromDisk();
105
+ this.schema = options.schema;
106
+ this.keyConfig = options.key;
107
+ this.indexes = (options.indexes ?? {});
108
+ registerSdkIdentifiers(this.fullId, { tableName: `mock-${this.fullId}`.substring(0, 255) });
109
+ }
110
+ async get(key) {
111
+ return this.data.get(this.serializeKey(key)) ?? null;
112
+ }
113
+ async put(item, options) {
114
+ await validateSchema(this.schema, item);
115
+ const serialized = JSON.stringify(item);
116
+ if (Buffer.byteLength(serialized, 'utf8') > MAX_ITEM_BYTES) {
117
+ throw blocksError(DistributedTableErrors.ItemTooLarge, DistributedTableMessages.itemTooLarge(Buffer.byteLength(serialized, 'utf8')));
118
+ }
119
+ const keyStr = this.serializeKey(item);
120
+ if (options?.ifNotExists && this.data.has(keyStr)) {
121
+ throw blocksError(DistributedTableErrors.ConditionalCheckFailed, 'The conditional request failed');
122
+ }
123
+ if (options?.ifFieldEquals) {
124
+ this.checkFieldEquals(keyStr, options.ifFieldEquals);
125
+ }
126
+ this.data.set(keyStr, item);
127
+ this.flushToDisk();
128
+ }
129
+ async delete(key, options) {
130
+ const keyStr = this.serializeKey(key);
131
+ if (options?.ifExists && !this.data.has(keyStr)) {
132
+ throw blocksError(DistributedTableErrors.ConditionalCheckFailed, 'The conditional request failed');
133
+ }
134
+ if (options?.ifFieldEquals) {
135
+ this.checkFieldEquals(keyStr, options.ifFieldEquals);
136
+ }
137
+ this.data.delete(keyStr);
138
+ this.flushToDisk();
139
+ }
140
+ /**
141
+ * Query items by index. The input object's fields are determined by the index:
142
+ * - Partition key field: required, `{ equals: value }`
143
+ * - Sort key field (if defined): optional, supports `equals`, `greaterThan`,
144
+ * `lessThan`, `between`, `beginsWith` (strings only), etc.
145
+ *
146
+ * @example
147
+ * ```typescript
148
+ * // Primary key query
149
+ * for await (const item of table.query({ where: { userId: { equals: 'u1' } } })) { ... }
150
+ *
151
+ * // GSI query with limit and reverse order
152
+ * for await (const item of table.query({
153
+ * index: 'byStatus',
154
+ * where: { status: { equals: 'pending' } },
155
+ * limit: 10,
156
+ * order: 'desc',
157
+ * })) { ... }
158
+ * ```
159
+ *
160
+ * @throws {DistributedTableErrors.InvalidQuery} If `options.index` does not exist,
161
+ * the `where` clause is missing, the partition key is not given as
162
+ * `{ equals: value }`, or more than one sort-key condition is supplied
163
+ * (DynamoDB allows only one per query — use `between` for ranges).
164
+ */
165
+ async *query(options) {
166
+ const indexConfig = options.index ? this.indexes[options.index] : this.keyConfig;
167
+ if (!indexConfig)
168
+ throw blocksError(DistributedTableErrors.InvalidQuery, DistributedTableMessages.indexNotFound(options.index));
169
+ const pkField = indexConfig.partitionKey;
170
+ const skField = indexConfig.sortKey;
171
+ if (!options.where) {
172
+ throw blocksError(DistributedTableErrors.InvalidQuery, DistributedTableMessages.whereRequired(pkField));
173
+ }
174
+ const pkValue = options.where[pkField]?.equals;
175
+ if (pkValue === undefined) {
176
+ throw blocksError(DistributedTableErrors.InvalidQuery, DistributedTableMessages.partitionKeyEqualsRequired(pkField));
177
+ }
178
+ // Normalize the sort-key condition up front — before scanning data — so the
179
+ // mock behaves identically to the AWS runtime regardless of stored data:
180
+ // a present-but-empty condition ({} / all-undefined) becomes "no filter"
181
+ // (query the whole partition) and multiple conditions are rejected eagerly,
182
+ // even on an empty table or a partition with no matches (where the per-item
183
+ // filter would never run).
184
+ const skCondition = skField
185
+ ? normalizeSortKeyCondition(options.where[skField])
186
+ : undefined;
187
+ const items = [];
188
+ for (const item of this.data.values()) {
189
+ if (item[pkField] !== pkValue)
190
+ continue;
191
+ if (skField && skCondition) {
192
+ if (!matchesSortKeyCondition(item[skField], skCondition))
193
+ continue;
194
+ }
195
+ items.push(item);
196
+ }
197
+ if (skField) {
198
+ const dir = options.order === 'desc' ? -1 : 1;
199
+ items.sort((a, b) => {
200
+ const av = a[skField], bv = b[skField];
201
+ return (av < bv ? -1 : av > bv ? 1 : 0) * dir;
202
+ });
203
+ }
204
+ let count = 0;
205
+ for (const item of items) {
206
+ yield item;
207
+ if (options.limit && ++count >= options.limit)
208
+ return;
209
+ }
210
+ }
211
+ async *scan(options) {
212
+ let count = 0;
213
+ for (const item of this.data.values()) {
214
+ yield item;
215
+ if (options?.limit && ++count >= options.limit)
216
+ return;
217
+ }
218
+ }
219
+ /**
220
+ * Fetch multiple items by key in batches. Returns results positionally —
221
+ * `null` for keys with no matching item.
222
+ *
223
+ * @throws {DistributedTableErrors.BatchIncomplete} (AWS runtime only) If DynamoDB
224
+ * leaves keys unprocessed after the retry budget is exhausted, typically under
225
+ * sustained throttling. The local mock never throttles, so it does not throw this.
226
+ */
227
+ async getBatch(keys) {
228
+ return keys.map(key => this.data.get(this.serializeKey(key)) ?? null);
229
+ }
230
+ /**
231
+ * Write multiple items in batches. Each item is schema-validated first.
232
+ *
233
+ * @throws {DistributedTableErrors.BatchIncomplete} (AWS runtime only) If DynamoDB
234
+ * leaves writes unprocessed after the retry budget is exhausted, typically under
235
+ * sustained throttling. The local mock never throttles, so it does not throw this.
236
+ */
237
+ async putBatch(items) {
238
+ for (const item of items) {
239
+ await validateSchema(this.schema, item);
240
+ const serialized = JSON.stringify(item);
241
+ if (Buffer.byteLength(serialized, 'utf8') > MAX_ITEM_BYTES) {
242
+ throw blocksError(DistributedTableErrors.ItemTooLarge, DistributedTableMessages.itemTooLarge(Buffer.byteLength(serialized, 'utf8')));
243
+ }
244
+ }
245
+ for (const item of items) {
246
+ this.data.set(this.serializeKey(item), item);
247
+ }
248
+ this.flushToDisk();
249
+ }
250
+ /**
251
+ * Delete multiple items by key in batches.
252
+ *
253
+ * @throws {DistributedTableErrors.BatchIncomplete} (AWS runtime only) If DynamoDB
254
+ * leaves deletes unprocessed after the retry budget is exhausted, typically under
255
+ * sustained throttling. The local mock never throttles, so it does not throw this.
256
+ */
257
+ async deleteBatch(keys) {
258
+ for (const key of keys)
259
+ this.data.delete(this.serializeKey(key));
260
+ this.flushToDisk();
261
+ }
262
+ static fromExisting(tableName) {
263
+ return { __brand: 'ExternalTableRef', tableName };
264
+ }
265
+ // ── Internal ────────────────────────────────────────────────────────────
266
+ checkFieldEquals(keyStr, fields) {
267
+ const entries = Object.entries(fields).filter(([, v]) => v !== undefined);
268
+ if (entries.length === 0) {
269
+ throw blocksError(DistributedTableErrors.InvalidQuery, DistributedTableMessages.emptyIfFieldEquals);
270
+ }
271
+ const existing = this.data.get(keyStr);
272
+ if (!existing) {
273
+ throw blocksError(DistributedTableErrors.ConditionalCheckFailed, 'The conditional request failed');
274
+ }
275
+ for (const [field, value] of entries) {
276
+ if (!deepEqual(existing[field], value)) {
277
+ throw blocksError(DistributedTableErrors.ConditionalCheckFailed, 'The conditional request failed');
278
+ }
279
+ }
280
+ }
281
+ serializeKey(key) {
282
+ const parts = [key[this.keyConfig.partitionKey]];
283
+ if (this.keyConfig.sortKey)
284
+ parts.push(key[this.keyConfig.sortKey]);
285
+ return JSON.stringify(parts);
286
+ }
287
+ loadFromDisk() {
288
+ if (!existsSync(this.filePath))
289
+ return new Map();
290
+ try {
291
+ return new Map(JSON.parse(readFileSync(this.filePath, 'utf8')));
292
+ }
293
+ catch {
294
+ return new Map();
295
+ }
296
+ }
297
+ flushToDisk() {
298
+ writeFileSync(this.filePath, JSON.stringify([...this.data.entries()], null, 2));
299
+ }
300
+ }
301
+ import { Logger } from '@aws-blocks/bb-logger';
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=index.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.test.d.ts","sourceRoot":"","sources":["../src/index.test.ts"],"names":[],"mappings":""}