@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,220 @@
1
+ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import { Construct } from 'constructs';
5
+ import { Table, type ITable, AttributeType, BillingMode } from 'aws-cdk-lib/aws-dynamodb';
6
+ import * as cdk from 'aws-cdk-lib';
7
+ import { CustomResource, Duration } from 'aws-cdk-lib';
8
+ import { Code, Function as LambdaFunction } from 'aws-cdk-lib/aws-lambda';
9
+ import { Provider } from 'aws-cdk-lib/custom-resources';
10
+ import { PolicyStatement } from 'aws-cdk-lib/aws-iam';
11
+ import { Scope, synthGuard, DEFAULT_NODE_RUNTIME } from '@aws-blocks/core/cdk';
12
+ import type { ScopeParent } from '@aws-blocks/core';
13
+ import type { ExternalTableRef } from './types.js';
14
+ import { fileURLToPath } from 'node:url';
15
+ import { dirname, join } from 'node:path';
16
+
17
+ export { DistributedTableErrors } from './errors.js';
18
+ export type { DistributedTableOptions, TableKeyConfig, TableKey, PutOptions, DeleteOptions, QueryOptions, ScanOptions, ExternalTableRef } from './types.js';
19
+
20
+ export class DistributedTable<T = any> extends Scope {
21
+ private table: ITable;
22
+
23
+ /**
24
+ * Reference an existing DynamoDB table instead of provisioning a new one.
25
+ * Mirrors the same factory exposed by the runtime build so the same code
26
+ * works in both contexts. The customer is responsible for ensuring the
27
+ * pre-existing table already has any required GSIs configured — Blocks
28
+ * will not modify the table when this factory is used.
29
+ */
30
+ static fromExisting(tableName: string): ExternalTableRef {
31
+ return { __brand: 'ExternalTableRef' as const, tableName };
32
+ }
33
+
34
+ constructor(scope: ScopeParent, id: string, public options: any) {
35
+ super(id, { parent: scope });
36
+
37
+ const config = options;
38
+
39
+ if (config?.table) {
40
+ // `fromExisting`: don't provision; bind to the pre-existing table by name
41
+ // and grant the runtime Lambda read/write + index query access.
42
+ // We deliberately skip the GSI custom resource — the customer owns the
43
+ // table's index lifecycle when they bring their own.
44
+ this.table = Table.fromTableName(this, 'table', config.table.tableName);
45
+ this.table.grantReadWriteData(this.handler);
46
+ this.handler.addToRolePolicy(new PolicyStatement({
47
+ actions: ['dynamodb:Query'],
48
+ resources: [`${this.table.tableArn}/index/*`],
49
+ }));
50
+ return;
51
+ }
52
+
53
+ const tableName = this.fullId.substring(0, 255);
54
+ const isSandbox = this.node.tryGetContext('sandboxMode') === 'true';
55
+
56
+ // Probe the schema's validate() to determine if a key field is numeric.
57
+ // Sends a test value of 0 for the field — if validation doesn't flag it,
58
+ // the field accepts numbers. Uses only the StandardSchemaV1 interface.
59
+ const isNumericField = (fieldName: string): boolean => {
60
+ const probe = { [fieldName]: 0 };
61
+ const result = config.schema['~standard'].validate(probe);
62
+ // validate may return sync or async; at synth time schemas are sync
63
+ if (result && 'issues' in result && result.issues) {
64
+ return !result.issues.some(
65
+ (i: any) => i.path?.length === 1 && i.path[0] === fieldName,
66
+ );
67
+ }
68
+ return true; // no issues for this field → numeric
69
+ };
70
+
71
+ const getDdbType = (fieldName: string): AttributeType =>
72
+ isNumericField(fieldName) ? AttributeType.NUMBER : AttributeType.STRING;
73
+
74
+ this.table = new Table(this, 'table', {
75
+ tableName,
76
+ partitionKey: {
77
+ name: config.key.partitionKey,
78
+ type: getDdbType(config.key.partitionKey),
79
+ },
80
+ sortKey: config.key.sortKey ? {
81
+ name: config.key.sortKey,
82
+ type: getDdbType(config.key.sortKey),
83
+ } : undefined,
84
+ billingMode: BillingMode.PAY_PER_REQUEST,
85
+ timeToLiveAttribute: config.ttl || undefined,
86
+ });
87
+
88
+ this.table.grantReadWriteData(this.handler);
89
+
90
+ // Explicit index query permissions
91
+ this.handler.addToRolePolicy(new PolicyStatement({
92
+ actions: ['dynamodb:Query'],
93
+ resources: [`${this.table.tableArn}/index/*`],
94
+ }));
95
+
96
+ // Add GSI manager if indexes are defined
97
+ if (config.indexes && Object.keys(config.indexes).length > 0) {
98
+ const gsiProvider = getOrCreateGsiProvider(cdk.Stack.of(this));
99
+ gsiProvider.addTableArn(this.table.tableArn, isSandbox);
100
+
101
+ const indexesWithTypes: Record<string, any> = {};
102
+ for (const [indexName, indexConfig] of Object.entries(config.indexes) as [string, any][]) {
103
+ indexesWithTypes[indexName] = {
104
+ partitionKey: indexConfig.partitionKey,
105
+ sortKey: indexConfig.sortKey,
106
+ partitionKeyType: getDdbType(indexConfig.partitionKey) === AttributeType.NUMBER ? 'N' : 'S',
107
+ sortKeyType: indexConfig.sortKey
108
+ ? (getDdbType(indexConfig.sortKey) === AttributeType.NUMBER ? 'N' : 'S')
109
+ : undefined,
110
+ };
111
+ }
112
+
113
+ const gsiResource = new CustomResource(this, 'gsi-resource', {
114
+ serviceToken: gsiProvider.serviceToken,
115
+ properties: {
116
+ TableName: this.table.tableName,
117
+ Indexes: indexesWithTypes,
118
+ SandboxMode: isSandbox ? 'true' : 'false',
119
+ Version: '3',
120
+ },
121
+ });
122
+
123
+ gsiResource.node.addDependency(this.table);
124
+ }
125
+ }
126
+
127
+ // ── Runtime methods are not available during CDK synth ────────────────
128
+ // Under `--conditions=cdk` a DistributedTable resolves to this construct,
129
+ // which only provisions infrastructure. The data methods live in the runtime
130
+ // build; calling them at module top-level (which runs during synth) would
131
+ // otherwise fail with a cryptic `X is not a function`. These stubs turn that
132
+ // into an actionable message.
133
+ get(..._args: unknown[]): never { return synthGuard('DistributedTable', 'get'); }
134
+ put(..._args: unknown[]): never { return synthGuard('DistributedTable', 'put'); }
135
+ delete(..._args: unknown[]): never { return synthGuard('DistributedTable', 'delete'); }
136
+ query(..._args: unknown[]): never { return synthGuard('DistributedTable', 'query'); }
137
+ scan(..._args: unknown[]): never { return synthGuard('DistributedTable', 'scan'); }
138
+ getBatch(..._args: unknown[]): never { return synthGuard('DistributedTable', 'getBatch'); }
139
+ putBatch(..._args: unknown[]): never { return synthGuard('DistributedTable', 'putBatch'); }
140
+ deleteBatch(..._args: unknown[]): never { return synthGuard('DistributedTable', 'deleteBatch'); }
141
+ }
142
+
143
+ // ── Shared GSI Manager Provider (one per stack) ─────────────────────────────
144
+
145
+ const GSI_PROVIDER_KEY = Symbol.for('BLOCKS_GSI_MANAGER_PROVIDER');
146
+
147
+ interface SharedGsiProvider {
148
+ serviceToken: string;
149
+ addTableArn: (tableArn: string, isSandbox: boolean) => void;
150
+ }
151
+
152
+ function getOrCreateGsiProvider(stack: cdk.Stack): SharedGsiProvider {
153
+ const existing = (stack as any)[GSI_PROVIDER_KEY] as SharedGsiProvider | undefined;
154
+ if (existing) return existing;
155
+
156
+ const __dirname = dirname(fileURLToPath(import.meta.url));
157
+
158
+ const tableArns: string[] = [];
159
+ const sandboxTableArns: string[] = [];
160
+
161
+ const gsiManagerLambda = new LambdaFunction(stack, 'KitGsiManager', {
162
+ runtime: DEFAULT_NODE_RUNTIME,
163
+ handler: 'index.handler',
164
+ code: Code.fromAsset(join(__dirname, 'gsi-manager-lambda')),
165
+ timeout: Duration.minutes(15),
166
+ });
167
+
168
+ const gsiIsCompleteLambda = new LambdaFunction(stack, 'KitGsiIsComplete', {
169
+ runtime: DEFAULT_NODE_RUNTIME,
170
+ handler: 'index.isCompleteHandler',
171
+ code: Code.fromAsset(join(__dirname, 'gsi-manager-lambda')),
172
+ timeout: Duration.minutes(1),
173
+ });
174
+
175
+ // Production permissions — lazily resolved so ARNs accumulate as tables register
176
+ gsiManagerLambda.addToRolePolicy(new PolicyStatement({
177
+ actions: ['dynamodb:DescribeTable', 'dynamodb:UpdateTable'],
178
+ resources: cdk.Lazy.list({ produce: () => tableArns }),
179
+ }));
180
+
181
+ gsiIsCompleteLambda.addToRolePolicy(new PolicyStatement({
182
+ actions: ['dynamodb:DescribeTable', 'dynamodb:UpdateTable'],
183
+ resources: cdk.Lazy.list({ produce: () => tableArns }),
184
+ }));
185
+
186
+ // Sandbox permissions — only added if any table requests sandbox mode
187
+ let sandboxPolicyAdded = false;
188
+
189
+ const provider = new Provider(stack, 'KitGsiProvider', {
190
+ onEventHandler: gsiManagerLambda,
191
+ isCompleteHandler: gsiIsCompleteLambda,
192
+ queryInterval: Duration.seconds(10),
193
+ totalTimeout: Duration.hours(2),
194
+ });
195
+
196
+ const shared: SharedGsiProvider = {
197
+ serviceToken: provider.serviceToken,
198
+ addTableArn: (tableArn: string, isSandbox: boolean) => {
199
+ tableArns.push(tableArn);
200
+ if (isSandbox) {
201
+ sandboxTableArns.push(tableArn);
202
+ if (!sandboxPolicyAdded) {
203
+ sandboxPolicyAdded = true;
204
+ gsiManagerLambda.addToRolePolicy(new PolicyStatement({
205
+ actions: [
206
+ 'dynamodb:DeleteTable',
207
+ 'dynamodb:CreateTable',
208
+ 'dynamodb:Scan',
209
+ 'dynamodb:BatchWriteItem',
210
+ ],
211
+ resources: cdk.Lazy.list({ produce: () => sandboxTableArns }),
212
+ }));
213
+ }
214
+ }
215
+ },
216
+ };
217
+
218
+ (stack as any)[GSI_PROVIDER_KEY] = shared;
219
+ return shared;
220
+ }
@@ -0,0 +1,363 @@
1
+ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import { Scope, registerSdkIdentifiers } from '@aws-blocks/core';
5
+ import { getMockDataDir } from '@aws-blocks/core/bb-utils';
6
+ import type { ScopeParent } from '@aws-blocks/core';
7
+ import type { StandardSchemaV1 } from '@standard-schema/spec';
8
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
9
+ import { join } from 'node:path';
10
+ import { BB_NAME, BB_VERSION } from './version.js';
11
+
12
+ export { DistributedTableErrors } from './errors.js';
13
+ export type {
14
+ TableKeyConfig,
15
+ DistributedTableOptions,
16
+ ExternalTableRef,
17
+ TableKey,
18
+ PartitionKeyCondition,
19
+ SortKeyCondition,
20
+ KeyCondition,
21
+ QueryOptions,
22
+ ScanOptions,
23
+ PutOptions,
24
+ DeleteOptions,
25
+ } from './types.js';
26
+
27
+ import type {
28
+ TableKeyConfig,
29
+ DistributedTableOptions,
30
+ ExternalTableRef,
31
+ SortKeyCondition,
32
+ ScanOptions,
33
+ PutOptions,
34
+ DeleteOptions,
35
+ TableKey,
36
+ } from './types.js';
37
+ import { DistributedTableErrors, DistributedTableMessages, blocksError, normalizeSortKeyCondition } from './errors.js';
38
+
39
+ // ── Helpers ─────────────────────────────────────────────────────────────────
40
+
41
+ const MAX_ITEM_BYTES = 400 * 1024;
42
+
43
+ async function validateSchema<T>(schema: StandardSchemaV1<T>, value: unknown): Promise<void> {
44
+ const result = schema['~standard'].validate(value);
45
+ const resolved = result instanceof Promise ? await result : result;
46
+ if (resolved.issues) {
47
+ throw blocksError(DistributedTableErrors.ValidationFailed, resolved.issues[0].message);
48
+ }
49
+ }
50
+
51
+ function matchesSortKeyCondition(value: any, condition: SortKeyCondition<any>): boolean {
52
+ if ('equals' in condition && condition.equals !== undefined && value !== condition.equals) return false;
53
+ if ('greaterThan' in condition && condition.greaterThan !== undefined && !(value > condition.greaterThan)) return false;
54
+ if ('greaterThanOrEqual' in condition && condition.greaterThanOrEqual !== undefined && !(value >= condition.greaterThanOrEqual)) return false;
55
+ if ('lessThan' in condition && condition.lessThan !== undefined && !(value < condition.lessThan)) return false;
56
+ if ('lessThanOrEqual' in condition && condition.lessThanOrEqual !== undefined && !(value <= condition.lessThanOrEqual)) return false;
57
+ if ('between' in condition && condition.between && !(value >= condition.between[0] && value <= condition.between[1])) return false;
58
+ if ('beginsWith' in condition && condition.beginsWith !== undefined) {
59
+ if (typeof value !== 'string' || !value.startsWith(condition.beginsWith as string)) return false;
60
+ }
61
+ return true;
62
+ }
63
+
64
+ /**
65
+ * Order-independent structural equality, used to compare `ifFieldEquals` values
66
+ * against stored attributes. DynamoDB Maps are an unordered collection of
67
+ * name-value pairs, so `{ a, b }` and `{ b, a }` are the same value — a plain
68
+ * `JSON.stringify` compare would wrongly treat them as different and fail the
69
+ * condition. Arrays remain order-sensitive (DynamoDB Lists are ordered).
70
+ */
71
+ function deepEqual(a: unknown, b: unknown): boolean {
72
+ if (a === b) return true;
73
+ if (typeof a !== typeof b) return false;
74
+ if (a === null || b === null) return a === b;
75
+
76
+ if (Array.isArray(a) || Array.isArray(b)) {
77
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
78
+ return a.every((v, i) => deepEqual(v, b[i]));
79
+ }
80
+
81
+ if (typeof a === 'object' && typeof b === 'object') {
82
+ const aKeys = Object.keys(a as object);
83
+ const bKeys = Object.keys(b as object);
84
+ if (aKeys.length !== bKeys.length) return false;
85
+ return aKeys.every(k =>
86
+ Object.prototype.hasOwnProperty.call(b, k) &&
87
+ deepEqual((a as any)[k], (b as any)[k]),
88
+ );
89
+ }
90
+
91
+ return false;
92
+ }
93
+
94
+ // ── DistributedTable (mock) ─────────────────────────────────────────────────
95
+
96
+ /**
97
+ * Structured data storage backed by DynamoDB with secondary indexes and
98
+ * rich query capabilities.
99
+ *
100
+ * **When to use:** You need to query by multiple fields, use composite keys,
101
+ * or perform sort-key-based range queries. Good for entities with relationships,
102
+ * time-series data, and access patterns that require multiple indexes.
103
+ *
104
+ * **When NOT to use:** If you only need single-key lookups, use `KVStore`.
105
+ * If you need full SQL (joins, aggregations), use `Database`.
106
+ *
107
+ * **Best practices:**
108
+ * - Design partition keys for even data distribution (e.g., `userId`, `tenantId`)
109
+ * - Use sort keys for range queries (e.g., timestamps, alphabetical ordering)
110
+ * - Define GSIs upfront for known access patterns — adding them later requires backfill
111
+ * - Use `{ ifNotExists: true }` for idempotent creates
112
+ * - Use `{ ifFieldEquals }` for optimistic locking (compare-and-swap)
113
+ *
114
+ * **Scaling:** PAY_PER_REQUEST billing. Single-digit ms reads/writes.
115
+ * Throughput scales automatically. Items limited to 400 KB.
116
+ * GSIs have separate throughput and may throttle independently.
117
+ */
118
+ export class DistributedTable<
119
+ T,
120
+ K extends TableKeyConfig<T> = TableKeyConfig<T>,
121
+ Indexes extends Record<string, TableKeyConfig<T>> = Record<string, TableKeyConfig<T>>,
122
+ > extends Scope {
123
+ private filePath: string;
124
+ private data: Map<string, T>;
125
+ private schema: StandardSchemaV1<T>;
126
+ private keyConfig: K;
127
+ private indexes: Indexes;
128
+
129
+ /** @internal Logger for internal operations. Defaults to error-level when not provided. */
130
+ protected log: ChildLogger;
131
+
132
+ constructor(scope: ScopeParent, id: string, public options: DistributedTableOptions<T, K, Indexes>) {
133
+ super(id, { parent: scope, bbName: BB_NAME, bbVersion: BB_VERSION });
134
+ this.log = options?.logger ?? new Logger(this, 'logger', { level: 'error' });
135
+ this.filePath = join(getMockDataDir(this), 'data.json');
136
+ this.data = this.loadFromDisk();
137
+ this.schema = options.schema;
138
+ this.keyConfig = options.key;
139
+ this.indexes = (options.indexes ?? {}) as Indexes;
140
+ registerSdkIdentifiers(this.fullId, { tableName: `mock-${this.fullId}`.substring(0, 255) });
141
+ }
142
+
143
+ async get(key: TableKey<T, K>): Promise<T | null> {
144
+ return this.data.get(this.serializeKey(key)) ?? null;
145
+ }
146
+
147
+ async put(item: T, options?: PutOptions<T>): Promise<void> {
148
+ await validateSchema(this.schema, item);
149
+
150
+ const serialized = JSON.stringify(item);
151
+ if (Buffer.byteLength(serialized, 'utf8') > MAX_ITEM_BYTES) {
152
+ throw blocksError(DistributedTableErrors.ItemTooLarge, DistributedTableMessages.itemTooLarge(Buffer.byteLength(serialized, 'utf8')));
153
+ }
154
+
155
+ const keyStr = this.serializeKey(item as any);
156
+
157
+ if (options?.ifNotExists && this.data.has(keyStr)) {
158
+ throw blocksError(DistributedTableErrors.ConditionalCheckFailed, 'The conditional request failed');
159
+ }
160
+ if (options?.ifFieldEquals) {
161
+ this.checkFieldEquals(keyStr, options.ifFieldEquals);
162
+ }
163
+
164
+ this.data.set(keyStr, item);
165
+ this.flushToDisk();
166
+ }
167
+
168
+ async delete(key: TableKey<T, K>, options?: DeleteOptions<T>): Promise<void> {
169
+ const keyStr = this.serializeKey(key);
170
+
171
+ if (options?.ifExists && !this.data.has(keyStr)) {
172
+ throw blocksError(DistributedTableErrors.ConditionalCheckFailed, 'The conditional request failed');
173
+ }
174
+ if (options?.ifFieldEquals) {
175
+ this.checkFieldEquals(keyStr, options.ifFieldEquals);
176
+ }
177
+
178
+ this.data.delete(keyStr);
179
+ this.flushToDisk();
180
+ }
181
+
182
+ /**
183
+ * Query items by index. The input object's fields are determined by the index:
184
+ * - Partition key field: required, `{ equals: value }`
185
+ * - Sort key field (if defined): optional, supports `equals`, `greaterThan`,
186
+ * `lessThan`, `between`, `beginsWith` (strings only), etc.
187
+ *
188
+ * @example
189
+ * ```typescript
190
+ * // Primary key query
191
+ * for await (const item of table.query({ where: { userId: { equals: 'u1' } } })) { ... }
192
+ *
193
+ * // GSI query with limit and reverse order
194
+ * for await (const item of table.query({
195
+ * index: 'byStatus',
196
+ * where: { status: { equals: 'pending' } },
197
+ * limit: 10,
198
+ * order: 'desc',
199
+ * })) { ... }
200
+ * ```
201
+ *
202
+ * @throws {DistributedTableErrors.InvalidQuery} If `options.index` does not exist,
203
+ * the `where` clause is missing, the partition key is not given as
204
+ * `{ equals: value }`, or more than one sort-key condition is supplied
205
+ * (DynamoDB allows only one per query — use `between` for ranges).
206
+ */
207
+ async *query(
208
+ options: QueryOptions<T, K, Indexes>,
209
+ ): AsyncIterable<T> {
210
+ const indexConfig = options.index ? this.indexes[options.index as keyof Indexes] : this.keyConfig;
211
+ if (!indexConfig) throw blocksError(DistributedTableErrors.InvalidQuery, DistributedTableMessages.indexNotFound(options.index));
212
+
213
+ const pkField = indexConfig.partitionKey;
214
+ const skField = indexConfig.sortKey;
215
+
216
+ if (!options.where) {
217
+ throw blocksError(DistributedTableErrors.InvalidQuery, DistributedTableMessages.whereRequired(pkField));
218
+ }
219
+ const pkValue = (options.where as any)[pkField]?.equals;
220
+ if (pkValue === undefined) {
221
+ throw blocksError(DistributedTableErrors.InvalidQuery, DistributedTableMessages.partitionKeyEqualsRequired(pkField));
222
+ }
223
+
224
+ // Normalize the sort-key condition up front — before scanning data — so the
225
+ // mock behaves identically to the AWS runtime regardless of stored data:
226
+ // a present-but-empty condition ({} / all-undefined) becomes "no filter"
227
+ // (query the whole partition) and multiple conditions are rejected eagerly,
228
+ // even on an empty table or a partition with no matches (where the per-item
229
+ // filter would never run).
230
+ const skCondition = skField
231
+ ? normalizeSortKeyCondition((options.where as any)[skField] as SortKeyCondition<any> | undefined)
232
+ : undefined;
233
+
234
+ const items: T[] = [];
235
+
236
+ for (const item of this.data.values()) {
237
+ if ((item as any)[pkField] !== pkValue) continue;
238
+
239
+ if (skField && skCondition) {
240
+ if (!matchesSortKeyCondition((item as any)[skField], skCondition)) continue;
241
+ }
242
+
243
+ items.push(item);
244
+ }
245
+
246
+ if (skField) {
247
+ const dir = options.order === 'desc' ? -1 : 1;
248
+ items.sort((a, b) => {
249
+ const av = (a as any)[skField], bv = (b as any)[skField];
250
+ return (av < bv ? -1 : av > bv ? 1 : 0) * dir;
251
+ });
252
+ }
253
+
254
+ let count = 0;
255
+ for (const item of items) {
256
+ yield item;
257
+ if (options.limit && ++count >= options.limit) return;
258
+ }
259
+ }
260
+
261
+ async *scan(options?: ScanOptions): AsyncIterable<T> {
262
+ let count = 0;
263
+ for (const item of this.data.values()) {
264
+ yield item;
265
+ if (options?.limit && ++count >= options.limit) return;
266
+ }
267
+ }
268
+
269
+ /**
270
+ * Fetch multiple items by key in batches. Returns results positionally —
271
+ * `null` for keys with no matching item.
272
+ *
273
+ * @throws {DistributedTableErrors.BatchIncomplete} (AWS runtime only) If DynamoDB
274
+ * leaves keys unprocessed after the retry budget is exhausted, typically under
275
+ * sustained throttling. The local mock never throttles, so it does not throw this.
276
+ */
277
+ async getBatch(keys: TableKey<T, K>[]): Promise<(T | null)[]> {
278
+ return keys.map(key => this.data.get(this.serializeKey(key)) ?? null);
279
+ }
280
+
281
+ /**
282
+ * Write multiple items in batches. Each item is schema-validated first.
283
+ *
284
+ * @throws {DistributedTableErrors.BatchIncomplete} (AWS runtime only) If DynamoDB
285
+ * leaves writes unprocessed after the retry budget is exhausted, typically under
286
+ * sustained throttling. The local mock never throttles, so it does not throw this.
287
+ */
288
+ async putBatch(items: T[]): Promise<void> {
289
+ for (const item of items) {
290
+ await validateSchema(this.schema, item);
291
+ const serialized = JSON.stringify(item);
292
+ if (Buffer.byteLength(serialized, 'utf8') > MAX_ITEM_BYTES) {
293
+ throw blocksError(DistributedTableErrors.ItemTooLarge, DistributedTableMessages.itemTooLarge(Buffer.byteLength(serialized, 'utf8')));
294
+ }
295
+ }
296
+ for (const item of items) {
297
+ this.data.set(this.serializeKey(item as any), item);
298
+ }
299
+ this.flushToDisk();
300
+ }
301
+
302
+ /**
303
+ * Delete multiple items by key in batches.
304
+ *
305
+ * @throws {DistributedTableErrors.BatchIncomplete} (AWS runtime only) If DynamoDB
306
+ * leaves deletes unprocessed after the retry budget is exhausted, typically under
307
+ * sustained throttling. The local mock never throttles, so it does not throw this.
308
+ */
309
+ async deleteBatch(keys: TableKey<T, K>[]): Promise<void> {
310
+ for (const key of keys) this.data.delete(this.serializeKey(key));
311
+ this.flushToDisk();
312
+ }
313
+
314
+ static fromExisting(tableName: string): ExternalTableRef {
315
+ return { __brand: 'ExternalTableRef' as const, tableName };
316
+ }
317
+
318
+ // ── Internal ────────────────────────────────────────────────────────────
319
+
320
+ private checkFieldEquals(keyStr: string, fields: Partial<T>): void {
321
+ const entries = Object.entries(fields).filter(([, v]) => v !== undefined);
322
+ if (entries.length === 0) {
323
+ throw blocksError(DistributedTableErrors.InvalidQuery, DistributedTableMessages.emptyIfFieldEquals);
324
+ }
325
+
326
+ const existing = this.data.get(keyStr);
327
+ if (!existing) {
328
+ throw blocksError(DistributedTableErrors.ConditionalCheckFailed, 'The conditional request failed');
329
+ }
330
+ for (const [field, value] of entries) {
331
+ if (!deepEqual((existing as any)[field], value)) {
332
+ throw blocksError(DistributedTableErrors.ConditionalCheckFailed, 'The conditional request failed');
333
+ }
334
+ }
335
+ }
336
+
337
+ private serializeKey(key: TableKey<T, K>): string {
338
+ const parts = [(key as any)[this.keyConfig.partitionKey]];
339
+ if (this.keyConfig.sortKey) parts.push((key as any)[this.keyConfig.sortKey]);
340
+ return JSON.stringify(parts);
341
+ }
342
+
343
+ private loadFromDisk(): Map<string, T> {
344
+ if (!existsSync(this.filePath)) return new Map();
345
+ try {
346
+ return new Map(JSON.parse(readFileSync(this.filePath, 'utf8')));
347
+ } catch { return new Map(); }
348
+ }
349
+
350
+ private flushToDisk(): void {
351
+ writeFileSync(this.filePath, JSON.stringify([...this.data.entries()], null, 2));
352
+ }
353
+ }
354
+
355
+ // ── Query input helper type ─────────────────────────────────────────────────
356
+ // This is defined here (not in types.ts) because it needs to resolve the
357
+ // Indexes generic from the class. types.ts exports the building blocks;
358
+ // this assembles them for the method signature.
359
+
360
+ import type { PartitionKeyCondition, SortKeyCondition as SKC, KeyCondition, QueryOptions } from './types.js';
361
+ import { Logger } from '@aws-blocks/bb-logger';
362
+ import type { ChildLogger } from '@aws-blocks/bb-logger';
363
+