@aws-blocks/bb-distributed-table 0.1.3 → 0.1.5
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.
- package/DESIGN.md +23 -0
- package/README.md +40 -0
- package/dist/errors.d.ts +25 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +71 -0
- package/dist/index.aws.d.ts +10 -2
- package/dist/index.aws.d.ts.map +1 -1
- package/dist/index.aws.js +22 -8
- package/dist/index.cdk.d.ts +1 -1
- package/dist/index.cdk.d.ts.map +1 -1
- package/dist/index.cdk.js +5 -0
- package/dist/index.cdk.test.js +24 -2
- package/dist/index.mock.d.ts +10 -2
- package/dist/index.mock.d.ts.map +1 -1
- package/dist/index.mock.js +21 -7
- package/dist/index.test.js +172 -0
- package/dist/parity.test.js +49 -0
- package/dist/types.d.ts +51 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +14 -4
- package/src/errors.ts +85 -0
- package/src/index.aws.ts +27 -8
- package/src/index.cdk.test.ts +26 -2
- package/src/index.cdk.ts +6 -1
- package/src/index.mock.ts +26 -7
- package/src/index.test.ts +205 -0
- package/src/parity.test.ts +76 -0
- package/src/types.ts +54 -0
- package/src/version.ts +1 -1
package/dist/index.test.js
CHANGED
|
@@ -173,6 +173,178 @@ describe('DistributedTable', () => {
|
|
|
173
173
|
]), (err) => err.name === 'ValidationFailedException');
|
|
174
174
|
});
|
|
175
175
|
});
|
|
176
|
+
// ── readValidation (off | coerce | strict) ────────────────────────────────
|
|
177
|
+
// Regression for the bug bash finding #1007: get() returned raw stored values
|
|
178
|
+
// without reconciling them against the schema, so after a schema change a legacy
|
|
179
|
+
// row no longer conformed to T — an added field was absent (a schema .default()
|
|
180
|
+
// was neither applied nor persisted on write-back) and a required-no-default
|
|
181
|
+
// field made the put() half of the read-modify-write cycle throw ValidationFailed.
|
|
182
|
+
// `readValidation` defaults to 'coerce', which closes the coercible gap.
|
|
183
|
+
describe('readValidation', () => {
|
|
184
|
+
// V1: no `currency`. V2: adds `currency` with a default.
|
|
185
|
+
const orderV1 = z.object({ orderId: z.string(), total: z.number() });
|
|
186
|
+
const orderV2 = z.object({
|
|
187
|
+
orderId: z.string(),
|
|
188
|
+
total: z.number(),
|
|
189
|
+
currency: z.string().default('USD'),
|
|
190
|
+
});
|
|
191
|
+
// Fixed scope so a V1 writer and a V2 reader share the same on-disk table,
|
|
192
|
+
// simulating a schema augmentation over pre-existing (legacy) rows.
|
|
193
|
+
function legacyScope() {
|
|
194
|
+
return new Scope(`dt-legacy-${++scopeCounter}-${Date.now()}`);
|
|
195
|
+
}
|
|
196
|
+
test("default is 'coerce': get() coerces the legacy row through the current schema (fills default)", async () => {
|
|
197
|
+
const scope = legacyScope();
|
|
198
|
+
const v1 = new DistributedTable(scope, 'orders', { schema: orderV1, key: { partitionKey: 'orderId' } });
|
|
199
|
+
await v1.put({ orderId: 'o1', total: 10 });
|
|
200
|
+
// No readValidation option → defaults to 'coerce'.
|
|
201
|
+
const v2 = new DistributedTable(scope, 'orders', { schema: orderV2, key: { partitionKey: 'orderId' } });
|
|
202
|
+
const row = await v2.get({ orderId: 'o1' });
|
|
203
|
+
assert.deepEqual(row, { orderId: 'o1', total: 10, currency: 'USD' });
|
|
204
|
+
});
|
|
205
|
+
test("default 'coerce': coerced read can be written back (fixes the read-modify-write cycle)", async () => {
|
|
206
|
+
const scope = legacyScope();
|
|
207
|
+
const v1 = new DistributedTable(scope, 'orders', { schema: orderV1, key: { partitionKey: 'orderId' } });
|
|
208
|
+
await v1.put({ orderId: 'o1', total: 10 });
|
|
209
|
+
const v2 = new DistributedTable(scope, 'orders', { schema: orderV2, key: { partitionKey: 'orderId' } });
|
|
210
|
+
const row = await v2.get({ orderId: 'o1' });
|
|
211
|
+
assert.ok(row);
|
|
212
|
+
await v2.put({ ...row, total: 20 }); // must NOT throw ValidationFailed
|
|
213
|
+
assert.deepEqual(await v2.get({ orderId: 'o1' }), { orderId: 'o1', total: 20, currency: 'USD' });
|
|
214
|
+
});
|
|
215
|
+
test("'off': get() returns the raw legacy row unchanged (opt-out of coercion)", async () => {
|
|
216
|
+
const scope = legacyScope();
|
|
217
|
+
const v1 = new DistributedTable(scope, 'orders', { schema: orderV1, key: { partitionKey: 'orderId' } });
|
|
218
|
+
await v1.put({ orderId: 'o1', total: 10 });
|
|
219
|
+
const v2 = new DistributedTable(scope, 'orders', {
|
|
220
|
+
schema: orderV2, key: { partitionKey: 'orderId' }, readValidation: 'off',
|
|
221
|
+
});
|
|
222
|
+
const row = await v2.get({ orderId: 'o1' });
|
|
223
|
+
assert.deepEqual(row, { orderId: 'o1', total: 10 }); // no currency injected
|
|
224
|
+
});
|
|
225
|
+
test("'coerce': an unrecoverable row is returned RAW (never throws) so it stays readable", async () => {
|
|
226
|
+
const scope = legacyScope();
|
|
227
|
+
// Write a row that violates the strict schema (total is a string, no coercion path).
|
|
228
|
+
const loose = z.object({ orderId: z.string(), total: z.any() });
|
|
229
|
+
const strict = z.object({ orderId: z.string(), total: z.number() });
|
|
230
|
+
const w = new DistributedTable(scope, 'orders', { schema: loose, key: { partitionKey: 'orderId' } });
|
|
231
|
+
await w.put({ orderId: 'bad', total: 'not-a-number' });
|
|
232
|
+
const r = new DistributedTable(scope, 'orders', {
|
|
233
|
+
schema: strict, key: { partitionKey: 'orderId' }, readValidation: 'coerce',
|
|
234
|
+
});
|
|
235
|
+
const row = await r.get({ orderId: 'bad' }); // must NOT throw
|
|
236
|
+
assert.deepEqual(row, { orderId: 'bad', total: 'not-a-number' }); // raw fallback
|
|
237
|
+
});
|
|
238
|
+
test("'coerce' PRESERVES stored keys not in the schema (no silent data loss)", async () => {
|
|
239
|
+
const scope = legacyScope();
|
|
240
|
+
// Row stored under a schema that had an extra `legacyNote` field.
|
|
241
|
+
const wide = z.object({ orderId: z.string(), total: z.number(), legacyNote: z.string() });
|
|
242
|
+
const narrow = z.object({ orderId: z.string(), total: z.number() }); // current schema no longer declares legacyNote
|
|
243
|
+
const w = new DistributedTable(scope, 'orders', { schema: wide, key: { partitionKey: 'orderId' } });
|
|
244
|
+
await w.put({ orderId: 'o1', total: 10, legacyNote: 'keep me' });
|
|
245
|
+
// coerce (default): unknown key is preserved (coerced output merged over the raw item).
|
|
246
|
+
const coerceReader = new DistributedTable(scope, 'orders', { schema: narrow, key: { partitionKey: 'orderId' } });
|
|
247
|
+
assert.deepEqual(await coerceReader.get({ orderId: 'o1' }), { orderId: 'o1', total: 10, legacyNote: 'keep me' });
|
|
248
|
+
// off also preserves (raw passthrough) — same observable result here.
|
|
249
|
+
const offReader = new DistributedTable(scope, 'orders', {
|
|
250
|
+
schema: narrow, key: { partitionKey: 'orderId' }, readValidation: 'off',
|
|
251
|
+
});
|
|
252
|
+
assert.deepEqual(await offReader.get({ orderId: 'o1' }), { orderId: 'o1', total: 10, legacyNote: 'keep me' });
|
|
253
|
+
});
|
|
254
|
+
test("'coerce' read-modify-write does NOT drop an unknown stored key (the reviewer's scenario)", async () => {
|
|
255
|
+
const scope = legacyScope();
|
|
256
|
+
const wide = z.object({ orderId: z.string(), total: z.number(), couponCode: z.string() });
|
|
257
|
+
const narrow = z.object({ orderId: z.string(), total: z.number() }); // couponCode no longer in schema
|
|
258
|
+
const seed = new DistributedTable(scope, 'orders', { schema: wide, key: { partitionKey: 'orderId' } });
|
|
259
|
+
await seed.put({ orderId: 'A1', total: 10, couponCode: 'SAVE10' });
|
|
260
|
+
// Read, change an unrelated field, write back — couponCode must survive.
|
|
261
|
+
const t = new DistributedTable(scope, 'orders', { schema: narrow, key: { partitionKey: 'orderId' } });
|
|
262
|
+
const row = await t.get({ orderId: 'A1' });
|
|
263
|
+
assert.ok(row);
|
|
264
|
+
await t.put({ ...row, total: 50 });
|
|
265
|
+
const after = await t.get({ orderId: 'A1' });
|
|
266
|
+
assert.deepEqual(after, { orderId: 'A1', total: 50, couponCode: 'SAVE10' });
|
|
267
|
+
});
|
|
268
|
+
test("'coerce' adds a new default AND preserves an unknown key at the same time", async () => {
|
|
269
|
+
const scope = legacyScope();
|
|
270
|
+
const v1 = new DistributedTable(scope, 'orders', {
|
|
271
|
+
schema: z.object({ orderId: z.string(), total: z.number(), couponCode: z.string() }),
|
|
272
|
+
key: { partitionKey: 'orderId' },
|
|
273
|
+
});
|
|
274
|
+
await v1.put({ orderId: 'o1', total: 10, couponCode: 'X' });
|
|
275
|
+
// V2 adds currency (default) and no longer declares couponCode.
|
|
276
|
+
const v2 = new DistributedTable(scope, 'orders', {
|
|
277
|
+
schema: z.object({ orderId: z.string(), total: z.number(), currency: z.string().default('USD') }),
|
|
278
|
+
key: { partitionKey: 'orderId' },
|
|
279
|
+
});
|
|
280
|
+
assert.deepEqual(await v2.get({ orderId: 'o1' }), { orderId: 'o1', total: 10, currency: 'USD', couponCode: 'X' });
|
|
281
|
+
});
|
|
282
|
+
test("'coerce' preserves unknown keys nested inside a known object (deep)", async () => {
|
|
283
|
+
const scope = legacyScope();
|
|
284
|
+
const wide = z.object({ orderId: z.string(), meta: z.object({ a: z.number(), legacy: z.boolean() }) });
|
|
285
|
+
const narrow = z.object({ orderId: z.string(), meta: z.object({ a: z.number() }) }); // dropped meta.legacy
|
|
286
|
+
const w = new DistributedTable(scope, 'orders', { schema: wide, key: { partitionKey: 'orderId' } });
|
|
287
|
+
await w.put({ orderId: 'o1', meta: { a: 1, legacy: true } });
|
|
288
|
+
const t = new DistributedTable(scope, 'orders', { schema: narrow, key: { partitionKey: 'orderId' } });
|
|
289
|
+
assert.deepEqual(await t.get({ orderId: 'o1' }), { orderId: 'o1', meta: { a: 1, legacy: true } });
|
|
290
|
+
});
|
|
291
|
+
test("'coerce' replaces arrays wholesale — never duplicates elements", async () => {
|
|
292
|
+
const scope = legacyScope();
|
|
293
|
+
const schema = z.object({ orderId: z.string(), tags: z.array(z.string()) });
|
|
294
|
+
const w = new DistributedTable(scope, 'orders', { schema, key: { partitionKey: 'orderId' } });
|
|
295
|
+
await w.put({ orderId: 'o1', tags: ['a', 'b'] });
|
|
296
|
+
// Same schema on read: coerced tags === raw tags; merge must NOT concat them.
|
|
297
|
+
const t = new DistributedTable(scope, 'orders', { schema, key: { partitionKey: 'orderId' } });
|
|
298
|
+
assert.deepEqual(await t.get({ orderId: 'o1' }), { orderId: 'o1', tags: ['a', 'b'] });
|
|
299
|
+
});
|
|
300
|
+
test("'strict': get() throws ValidationFailed on a non-conforming stored row", async () => {
|
|
301
|
+
const scope = legacyScope();
|
|
302
|
+
const loose = z.object({ orderId: z.string(), total: z.any() });
|
|
303
|
+
const strict = z.object({ orderId: z.string(), total: z.number() });
|
|
304
|
+
const w = new DistributedTable(scope, 'orders', { schema: loose, key: { partitionKey: 'orderId' } });
|
|
305
|
+
await w.put({ orderId: 'bad', total: 'not-a-number' });
|
|
306
|
+
const r = new DistributedTable(scope, 'orders', {
|
|
307
|
+
schema: strict, key: { partitionKey: 'orderId' }, readValidation: 'strict',
|
|
308
|
+
});
|
|
309
|
+
await assert.rejects(() => r.get({ orderId: 'bad' }), (err) => err.name === 'ValidationFailedException');
|
|
310
|
+
});
|
|
311
|
+
test("'strict': a conforming row reads back fine", async () => {
|
|
312
|
+
const scope = legacyScope();
|
|
313
|
+
const v1 = new DistributedTable(scope, 'orders', { schema: orderV1, key: { partitionKey: 'orderId' } });
|
|
314
|
+
await v1.put({ orderId: 'o1', total: 10 });
|
|
315
|
+
const r = new DistributedTable(scope, 'orders', {
|
|
316
|
+
schema: orderV1, key: { partitionKey: 'orderId' }, readValidation: 'strict',
|
|
317
|
+
});
|
|
318
|
+
assert.deepEqual(await r.get({ orderId: 'o1' }), { orderId: 'o1', total: 10 });
|
|
319
|
+
});
|
|
320
|
+
test('get() still returns null for a missing item (all modes)', async () => {
|
|
321
|
+
for (const readValidation of ['off', 'coerce', 'strict']) {
|
|
322
|
+
const table = new DistributedTable(testScope(), 'orders', {
|
|
323
|
+
schema: orderV2, key: { partitionKey: 'orderId' }, readValidation,
|
|
324
|
+
});
|
|
325
|
+
assert.equal(await table.get({ orderId: 'nope' }), null);
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
test("default 'coerce': scan() and query() coerce yielded items", async () => {
|
|
329
|
+
const scope = legacyScope();
|
|
330
|
+
const v1 = new DistributedTable(scope, 'orders', { schema: orderV1, key: { partitionKey: 'orderId' } });
|
|
331
|
+
await v1.put({ orderId: 'o1', total: 10 });
|
|
332
|
+
await v1.put({ orderId: 'o2', total: 20 });
|
|
333
|
+
const v2 = new DistributedTable(scope, 'orders', { schema: orderV2, key: { partitionKey: 'orderId' } });
|
|
334
|
+
const scanned = await collect(v2.scan());
|
|
335
|
+
assert.ok(scanned.every(o => o.currency === 'USD'));
|
|
336
|
+
const queried = await collect(v2.query({ where: { orderId: { equals: 'o1' } } }));
|
|
337
|
+
assert.deepEqual(queried, [{ orderId: 'o1', total: 10, currency: 'USD' }]);
|
|
338
|
+
});
|
|
339
|
+
test("default 'coerce': getBatch() coerces each hit and preserves null holes", async () => {
|
|
340
|
+
const scope = legacyScope();
|
|
341
|
+
const v1 = new DistributedTable(scope, 'orders', { schema: orderV1, key: { partitionKey: 'orderId' } });
|
|
342
|
+
await v1.put({ orderId: 'o1', total: 10 });
|
|
343
|
+
const v2 = new DistributedTable(scope, 'orders', { schema: orderV2, key: { partitionKey: 'orderId' } });
|
|
344
|
+
const rows = await v2.getBatch([{ orderId: 'o1' }, { orderId: 'missing' }]);
|
|
345
|
+
assert.deepEqual(rows, [{ orderId: 'o1', total: 10, currency: 'USD' }, null]);
|
|
346
|
+
});
|
|
347
|
+
});
|
|
176
348
|
// ── Query: numeric sort key ─────────────────────────────────────────────
|
|
177
349
|
describe('query (numeric sort key)', () => {
|
|
178
350
|
function numTable() {
|
package/dist/parity.test.js
CHANGED
|
@@ -555,3 +555,52 @@ describe('DistributedTableErrors split the old single Validation bucket into int
|
|
|
555
555
|
assert.strictEqual(DistributedTableErrors.Validation, undefined);
|
|
556
556
|
});
|
|
557
557
|
});
|
|
558
|
+
describe('readValidation behaves identically on the mock and the AWS runtime', () => {
|
|
559
|
+
// V2 schema adds `currency` with a default; a legacy row lacks it.
|
|
560
|
+
const schemaV2 = z.object({ orderId: z.string(), total: z.number(), currency: z.string().default('USD') });
|
|
561
|
+
const legacyRow = { orderId: 'o1', total: 10 };
|
|
562
|
+
const coerced = { orderId: 'o1', total: 10, currency: 'USD' };
|
|
563
|
+
test("mock get() coerces a stored legacy-shaped row under the default 'coerce'", async () => {
|
|
564
|
+
// Write the legacy-shaped row directly into the store (bypassing put's
|
|
565
|
+
// write validation) to simulate a row that predates the V2 schema, then
|
|
566
|
+
// read it back through the V2 schema (readValidation defaults to 'coerce').
|
|
567
|
+
const v2 = new DistributedTable(testScope(), 'orders', {
|
|
568
|
+
schema: schemaV2, key: { partitionKey: 'orderId' },
|
|
569
|
+
});
|
|
570
|
+
v2.data.set(v2.serializeKey({ orderId: 'o1' }), legacyRow);
|
|
571
|
+
assert.deepStrictEqual(await v2.get({ orderId: 'o1' }), coerced);
|
|
572
|
+
});
|
|
573
|
+
test("AWS get() coerces the same legacy row identically under the default 'coerce'", async () => {
|
|
574
|
+
const table = awsTableWithFakeClient('ovr-aws-1', { schema: schemaV2, key: { partitionKey: 'orderId' } }, async () => ({ Item: legacyRow }));
|
|
575
|
+
assert.deepStrictEqual(await table.get({ orderId: 'o1' }), coerced);
|
|
576
|
+
});
|
|
577
|
+
test("AWS get() with readValidation 'off' returns the raw legacy row unchanged", async () => {
|
|
578
|
+
const table = awsTableWithFakeClient('ovr-aws-2', { schema: schemaV2, key: { partitionKey: 'orderId' }, readValidation: 'off' }, async () => ({ Item: legacyRow }));
|
|
579
|
+
assert.deepStrictEqual(await table.get({ orderId: 'o1' }), legacyRow);
|
|
580
|
+
});
|
|
581
|
+
test("AWS get() 'coerce' returns an uncoercible row RAW and never throws", async () => {
|
|
582
|
+
const strict = z.object({ orderId: z.string(), total: z.number() });
|
|
583
|
+
const bad = { orderId: 'x', total: 'not-a-number' };
|
|
584
|
+
const table = awsTableWithFakeClient('ovr-aws-3', { schema: strict, key: { partitionKey: 'orderId' }, readValidation: 'coerce' }, async () => ({ Item: bad }));
|
|
585
|
+
assert.deepStrictEqual(await table.get({ orderId: 'x' }), bad);
|
|
586
|
+
});
|
|
587
|
+
test("AWS get() 'strict' throws ValidationFailed on the same uncoercible row (mock parity)", async () => {
|
|
588
|
+
const strict = z.object({ orderId: z.string(), total: z.number() });
|
|
589
|
+
const bad = { orderId: 'x', total: 'not-a-number' };
|
|
590
|
+
const table = awsTableWithFakeClient('ovr-aws-4', { schema: strict, key: { partitionKey: 'orderId' }, readValidation: 'strict' }, async () => ({ Item: bad }));
|
|
591
|
+
await assert.rejects(() => table.get({ orderId: 'x' }), (err) => err.name === 'ValidationFailedException');
|
|
592
|
+
});
|
|
593
|
+
// coerce preserves unknown stored keys (adds default) — must be identical mock ↔ aws.
|
|
594
|
+
const narrow = z.object({ orderId: z.string(), total: z.number(), currency: z.string().default('USD') });
|
|
595
|
+
const legacyWithExtra = { orderId: 'o1', total: 10, couponCode: 'X' }; // couponCode not in schema
|
|
596
|
+
const preserved = { orderId: 'o1', total: 10, couponCode: 'X', currency: 'USD' };
|
|
597
|
+
test("mock 'coerce' preserves an unknown key while adding the new default", async () => {
|
|
598
|
+
const t = new DistributedTable(testScope(), 'orders', { schema: narrow, key: { partitionKey: 'orderId' } });
|
|
599
|
+
t.data.set(t.serializeKey({ orderId: 'o1' }), legacyWithExtra);
|
|
600
|
+
assert.deepStrictEqual(await t.get({ orderId: 'o1' }), preserved);
|
|
601
|
+
});
|
|
602
|
+
test("AWS 'coerce' preserves the same unknown key identically", async () => {
|
|
603
|
+
const table = awsTableWithFakeClient('ovr-aws-5', { schema: narrow, key: { partitionKey: 'orderId' } }, async () => ({ Item: legacyWithExtra }));
|
|
604
|
+
assert.deepStrictEqual(await table.get({ orderId: 'o1' }), preserved);
|
|
605
|
+
});
|
|
606
|
+
});
|
package/dist/types.d.ts
CHANGED
|
@@ -4,6 +4,16 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import type { StandardSchemaV1 } from '@standard-schema/spec';
|
|
6
6
|
import type { ChildLogger } from '@aws-blocks/bb-logger';
|
|
7
|
+
/**
|
|
8
|
+
* Controls how reads reconcile a stored item with the schema. See
|
|
9
|
+
* {@link DistributedTableOptions.readValidation} for full semantics.
|
|
10
|
+
*
|
|
11
|
+
* - `'coerce'` (default): pass through the schema, return coerced output; on
|
|
12
|
+
* validation failure return the raw value + warn (never throws).
|
|
13
|
+
* - `'strict'`: validate and throw `ValidationFailed` on any non-conforming item.
|
|
14
|
+
* - `'off'`: return the raw stored value with no validation.
|
|
15
|
+
*/
|
|
16
|
+
export type ReadValidationMode = 'off' | 'coerce' | 'strict';
|
|
7
17
|
export interface TableKeyConfig<T> {
|
|
8
18
|
/** Attribute name used as the partition key. Must be a field in the schema. */
|
|
9
19
|
partitionKey: keyof T & string;
|
|
@@ -33,6 +43,47 @@ export interface DistributedTableOptions<T, K extends TableKeyConfig<T> = TableK
|
|
|
33
43
|
* ```
|
|
34
44
|
*/
|
|
35
45
|
ttl?: keyof T & string;
|
|
46
|
+
/**
|
|
47
|
+
* How reads (`get`, `getBatch`, `query`, `scan`) reconcile a stored item with
|
|
48
|
+
* the configured `schema`. Writes (`put`/`putBatch`) always validate; this
|
|
49
|
+
* governs the read side, which matters after a schema change: a row written
|
|
50
|
+
* under an older schema may no longer conform to the current type `T`.
|
|
51
|
+
*
|
|
52
|
+
* - **`'coerce'`** (default) — pass each stored item through the schema and
|
|
53
|
+
* return its output. For transform-bearing schemas (e.g. Zod) this fills
|
|
54
|
+
* `.default()`s and narrows types so the value satisfies `T` and the
|
|
55
|
+
* read-modify-write cycle (`get()` → mutate → `put()`) round-trips. **Never
|
|
56
|
+
* throws:** an item that fails validation is returned **as-is** with a
|
|
57
|
+
* warning, keeping drifted/legacy rows readable for migration.
|
|
58
|
+
* - **`'strict'`** — validate on read and **throw** `ValidationFailed` on any
|
|
59
|
+
* item that doesn't satisfy the schema. For tables where a mismatch should be
|
|
60
|
+
* treated as corruption/tampering and rejected rather than absorbed. Note
|
|
61
|
+
* this makes a single bad row fail the whole `query`/`scan`/`getBatch`.
|
|
62
|
+
* - **`'off'`** — return the raw stored value with no validation (lowest cost).
|
|
63
|
+
* Use for hot paths, data you trust was written through this schema, or to
|
|
64
|
+
* read items you can't yet coerce during a migration.
|
|
65
|
+
*
|
|
66
|
+
* Defaults to `'coerce'`.
|
|
67
|
+
*
|
|
68
|
+
* > **Best-effort coercion (validator-dependent).** Coercion relies on the
|
|
69
|
+
* > schema *transforming* its input. Zod fills defaults and casts; a check-only
|
|
70
|
+
* > Standard Schema validator (some Valibot/ArkType schemas) validates without
|
|
71
|
+
* > transforming, so `'coerce'` returns the value unchanged for those — it never
|
|
72
|
+
* > invents data. A required field with no default is never fabricated: under
|
|
73
|
+
* > `'coerce'` such a row is returned raw + warned; under `'strict'` it throws.
|
|
74
|
+
*
|
|
75
|
+
* @example
|
|
76
|
+
* ```typescript
|
|
77
|
+
* const orders = new DistributedTable(scope, 'orders', {
|
|
78
|
+
* schema: orderSchemaV2, // adds `currency: z.string().default('USD')`
|
|
79
|
+
* key: { partitionKey: 'orderId' },
|
|
80
|
+
* // readValidation: 'coerce' is the default
|
|
81
|
+
* });
|
|
82
|
+
* const order = await orders.get({ orderId: 'o1' }); // legacy row → currency: 'USD'
|
|
83
|
+
* await orders.put({ ...order, total: 20 }); // round-trips cleanly
|
|
84
|
+
* ```
|
|
85
|
+
*/
|
|
86
|
+
readValidation?: ReadValidationMode;
|
|
36
87
|
/** Wrap an existing table instead of creating one. */
|
|
37
88
|
table?: ExternalTableRef;
|
|
38
89
|
/** Optional logger for internal operations. When omitted, a default Logger at error level is created. */
|
package/dist/types.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAGA;;;GAGG;AACH,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAC;AAC9D,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAC;AAIzD;;;;;;;;GAQG;AACH,MAAM,MAAM,kBAAkB,GAAG,KAAK,GAAG,QAAQ,GAAG,QAAQ,CAAC;AAI7D,MAAM,WAAW,cAAc,CAAC,CAAC;IAChC,+EAA+E;IAC/E,YAAY,EAAE,MAAM,CAAC,GAAG,MAAM,CAAC;IAC/B,oFAAoF;IACpF,OAAO,CAAC,EAAE,MAAM,CAAC,GAAG,MAAM,CAAC;CAC3B;AAED,MAAM,WAAW,uBAAuB,CACvC,CAAC,EACD,CAAC,SAAS,cAAc,CAAC,CAAC,CAAC,GAAG,cAAc,CAAC,CAAC,CAAC,EAC/C,OAAO,SAAS,MAAM,CAAC,MAAM,EAAE,cAAc,CAAC,CAAC,CAAC,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,cAAc,CAAC,CAAC,CAAC,CAAC;IAErF,mFAAmF;IACnF,MAAM,EAAE,gBAAgB,CAAC,CAAC,CAAC,CAAC;IAC5B,iCAAiC;IACjC,GAAG,EAAE,CAAC,CAAC;IACP,oDAAoD;IACpD,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB;;;;;;;;;;;;;;OAcG;IACH,GAAG,CAAC,EAAE,MAAM,CAAC,GAAG,MAAM,CAAC;IACvB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OAuCG;IACH,cAAc,CAAC,EAAE,kBAAkB,CAAC;IACpC,sDAAsD;IACtD,KAAK,CAAC,EAAE,gBAAgB,CAAC;IACzB,yGAAyG;IACzG,MAAM,CAAC,EAAE,WAAW,CAAC;CACrB;AAED,MAAM,WAAW,gBAAgB;IAChC,QAAQ,CAAC,OAAO,EAAE,kBAAkB,CAAC;IACrC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;CAC3B;AAID;;;GAGG;AACH,MAAM,MAAM,QAAQ,CAAC,CAAC,EAAE,CAAC,SAAS,cAAc,CAAC,CAAC,CAAC,GAAG,cAAc,CAAC,CAAC,CAAC,IACtE,CAAC,SAAS;IAAE,OAAO,EAAE,MAAM,EAAE,SAAS,MAAM,CAAC,GAAG,MAAM,CAAA;CAAE,GACrD,QAAQ,CAAC,IAAI,CAAC,CAAC,EAAE,CAAC,CAAC,cAAc,CAAC,GAAG,EAAE,CAAC,CAAC,GACzC,QAAQ,CAAC,IAAI,CAAC,CAAC,EAAE,CAAC,CAAC,cAAc,CAAC,CAAC,CAAC,CAAC;AAIzC,gFAAgF;AAChF,MAAM,MAAM,qBAAqB,CAAC,CAAC,IAAI;IAAE,MAAM,EAAE,CAAC,CAAA;CAAE,CAAC;AAErD,8EAA8E;AAC9E,MAAM,MAAM,gBAAgB,CAAC,CAAC,IAAI;IACjC,MAAM,CAAC,EAAE,CAAC,CAAC;IACX,WAAW,CAAC,EAAE,CAAC,CAAC;IAChB,kBAAkB,CAAC,EAAE,CAAC,CAAC;IACvB,QAAQ,CAAC,EAAE,CAAC,CAAC;IACb,eAAe,CAAC,EAAE,CAAC,CAAC;IACpB,OAAO,CAAC,EAAE,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;IACjB,UAAU,CAAC,EAAE,CAAC,SAAS,MAAM,GAAG,MAAM,GAAG,KAAK,CAAC;CAC/C,CAAC;AAEF;;;;;;;;;;GAUG;AACH,MAAM,MAAM,YAAY,CAAC,CAAC,EAAE,CAAC,SAAS,cAAc,CAAC,CAAC,CAAC,IACtD,CAAC,SAAS;IAAE,OAAO,EAAE,MAAM,EAAE,SAAS,MAAM,CAAC,GAAG,MAAM,CAAA;CAAE,GACrD;KAAG,CAAC,IAAI,CAAC,CAAC,cAAc,CAAC,GAAG,qBAAqB,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;CAAE,GACzD;KAAG,CAAC,IAAI,EAAE,CAAC,CAAC,EAAE,gBAAgB,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;CAAE,GACtC;KAAG,CAAC,IAAI,CAAC,CAAC,cAAc,CAAC,GAAG,qBAAqB,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;CAAE,CAAC;AAI9D;;;;;;;;;;;;;;;;;;GAkBG;AACH,MAAM,MAAM,YAAY,CACvB,CAAC,EACD,CAAC,SAAS,cAAc,CAAC,CAAC,CAAC,EAC3B,OAAO,SAAS,MAAM,CAAC,MAAM,EAAE,cAAc,CAAC,CAAC,CAAC,CAAC,IAC9C;KACF,IAAI,IAAI,MAAM,GAAG,MAAM,OAAO,GAAG;QACjC,mDAAmD;QACnD,KAAK,EAAE,IAAI,CAAC;QACZ,oCAAoC;QACpC,KAAK,EAAE,YAAY,CAAC,CAAC,EAAE,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC;QACtC,yCAAyC;QACzC,KAAK,CAAC,EAAE,MAAM,CAAC;QACf,qCAAqC;QACrC,KAAK,CAAC,EAAE,KAAK,GAAG,MAAM,CAAC;KACvB;CACD,CAAC,MAAM,GAAG,MAAM,OAAO,CAAC,GAAG;IAC3B,KAAK,CAAC,EAAE,SAAS,CAAC;IAClB,gDAAgD;IAChD,KAAK,EAAE,YAAY,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;IAC1B,yCAAyC;IACzC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,qCAAqC;IACrC,KAAK,CAAC,EAAE,KAAK,GAAG,MAAM,CAAC;CACvB,CAAC;AAEF,MAAM,WAAW,WAAW;IAC3B,yCAAyC;IACzC,KAAK,CAAC,EAAE,MAAM,CAAC;CACf;AAED,MAAM,MAAM,UAAU,CAAC,CAAC,IACrB;IAAE,WAAW,EAAE,IAAI,CAAC;IAAC,aAAa,CAAC,EAAE,KAAK,CAAA;CAAE,GAC5C;IAAE,WAAW,CAAC,EAAE,KAAK,CAAC;IAAC,aAAa,EAAE,OAAO,CAAC,CAAC,CAAC,CAAA;CAAE,GAClD,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC;AAEzB,MAAM,MAAM,aAAa,CAAC,CAAC,IACxB;IAAE,QAAQ,EAAE,IAAI,CAAC;IAAC,aAAa,CAAC,EAAE,KAAK,CAAA;CAAE,GACzC;IAAE,QAAQ,CAAC,EAAE,KAAK,CAAC;IAAC,aAAa,EAAE,OAAO,CAAC,CAAC,CAAC,CAAA;CAAE,GAC/C,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC"}
|
package/dist/version.d.ts
CHANGED
package/dist/version.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aws-blocks/bb-distributed-table",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
|
+
"repository": {
|
|
5
|
+
"type": "git",
|
|
6
|
+
"url": "git+https://github.com/aws-devtools-labs/aws-blocks.git",
|
|
7
|
+
"directory": "packages/bb-distributed-table"
|
|
8
|
+
},
|
|
9
|
+
"homepage": "https://github.com/aws-devtools-labs/aws-blocks/tree/main/packages/bb-distributed-table#readme",
|
|
10
|
+
"bugs": {
|
|
11
|
+
"url": "https://github.com/aws-devtools-labs/aws-blocks/issues"
|
|
12
|
+
},
|
|
4
13
|
"author": "Amazon Web Services",
|
|
5
14
|
"license": "Apache-2.0",
|
|
6
15
|
"type": "module",
|
|
@@ -30,11 +39,12 @@
|
|
|
30
39
|
"test": "node --test --test-concurrency=1 dist/index.test.js dist/parity.test.js dist/index.cdk.test.js"
|
|
31
40
|
},
|
|
32
41
|
"dependencies": {
|
|
33
|
-
"@aws-blocks/core": "^0.
|
|
34
|
-
"@aws-blocks/bb-logger": "^0.1.
|
|
42
|
+
"@aws-blocks/core": "^0.2.0",
|
|
43
|
+
"@aws-blocks/bb-logger": "^0.1.4",
|
|
35
44
|
"@aws-sdk/client-dynamodb": "^3.0.0",
|
|
36
45
|
"@aws-sdk/lib-dynamodb": "^3.0.0",
|
|
37
|
-
"@standard-schema/spec": "^1.0.0"
|
|
46
|
+
"@standard-schema/spec": "^1.0.0",
|
|
47
|
+
"defu": "^6.1.7"
|
|
38
48
|
},
|
|
39
49
|
"devDependencies": {
|
|
40
50
|
"@types/node": "^20.0.0",
|
package/src/errors.ts
CHANGED
|
@@ -1,6 +1,33 @@
|
|
|
1
1
|
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
|
|
2
2
|
// SPDX-License-Identifier: Apache-2.0
|
|
3
3
|
|
|
4
|
+
import { createDefu } from 'defu';
|
|
5
|
+
import type { StandardSchemaV1 } from '@standard-schema/spec';
|
|
6
|
+
import type { ChildLogger } from '@aws-blocks/bb-logger';
|
|
7
|
+
import type { ReadValidationMode } from './types.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @internal Right-biased deep merge for the `'coerce'` read path: overlay the
|
|
11
|
+
* schema-coerced item onto the raw stored item so schema output (filled defaults,
|
|
12
|
+
* narrowed types) wins, while keys the schema stripped (e.g. attributes from an
|
|
13
|
+
* older schema version or another writer) are preserved rather than silently
|
|
14
|
+
* dropped on a read-modify-write.
|
|
15
|
+
*
|
|
16
|
+
* Arrays are treated as **opaque leaves** — the coerced array replaces the raw
|
|
17
|
+
* array wholesale. defu concatenates arrays by default, which is wrong here: the
|
|
18
|
+
* coerced array is derived from the same raw array, so concatenation would
|
|
19
|
+
* duplicate every element. This customizer overrides that one behavior; plain
|
|
20
|
+
* objects still deep-merge (so nested unknown keys survive), and defu's built-in
|
|
21
|
+
* `__proto__`/`constructor` guard is retained.
|
|
22
|
+
*/
|
|
23
|
+
const mergeCoercedOverRaw = createDefu((obj, key, value) => {
|
|
24
|
+
if (Array.isArray(obj[key]) || Array.isArray(value)) {
|
|
25
|
+
obj[key] = obj[key] ?? value;
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
return false;
|
|
29
|
+
});
|
|
30
|
+
|
|
4
31
|
/**
|
|
5
32
|
* Typed error constants for DistributedTable. Use with `isBlocksError()` in catch blocks.
|
|
6
33
|
*
|
|
@@ -143,3 +170,61 @@ export function remapItemTooLarge(err: unknown): unknown {
|
|
|
143
170
|
}
|
|
144
171
|
return err;
|
|
145
172
|
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* @internal Reconcile a stored item with the schema on read, per the
|
|
176
|
+
* `readValidation` mode. Shared by the mock and AWS runtime so all three modes
|
|
177
|
+
* behave identically in both. `null` (a missing item) always passes through
|
|
178
|
+
* untouched, preserving not-found semantics.
|
|
179
|
+
*
|
|
180
|
+
* - `'off'` — return the raw item, no validation.
|
|
181
|
+
* - `'coerce'` — apply the schema (fill defaults / narrow types for
|
|
182
|
+
* transform-bearing schemas) **without dropping data**: the coerced output is
|
|
183
|
+
* deep-merged over the raw item, so schema output wins per key while attributes
|
|
184
|
+
* the schema doesn't declare (from an older schema version or another writer)
|
|
185
|
+
* are preserved. Without this merge, returning the bare validator output would
|
|
186
|
+
* strip unknown keys (Zod `.strip()` default), and a read-modify-write would
|
|
187
|
+
* then persist the stripped item — silently deleting stored data. On validation
|
|
188
|
+
* failure, return the **raw** item and `warn` — never throws — so drifted/legacy
|
|
189
|
+
* rows stay readable and the "reads return data or `null`" contract holds.
|
|
190
|
+
* - `'strict'` — throw `ValidationFailed` on any item that doesn't satisfy the
|
|
191
|
+
* schema.
|
|
192
|
+
*
|
|
193
|
+
* Schemas may validate synchronously or asynchronously; this awaits either.
|
|
194
|
+
*/
|
|
195
|
+
export async function applyReadValidation<T>(
|
|
196
|
+
mode: ReadValidationMode,
|
|
197
|
+
schema: StandardSchemaV1<T>,
|
|
198
|
+
item: T | null,
|
|
199
|
+
log: Pick<ChildLogger, 'warn'>,
|
|
200
|
+
context?: Record<string, unknown>,
|
|
201
|
+
): Promise<T | null> {
|
|
202
|
+
if (item == null || mode === 'off') return item;
|
|
203
|
+
const result = schema['~standard'].validate(item);
|
|
204
|
+
const resolved = result instanceof Promise ? await result : result;
|
|
205
|
+
if (resolved.issues) {
|
|
206
|
+
if (mode === 'strict') {
|
|
207
|
+
throw blocksError(DistributedTableErrors.ValidationFailed, resolved.issues[0]?.message ?? 'stored item failed schema validation on read');
|
|
208
|
+
}
|
|
209
|
+
log.warn(
|
|
210
|
+
`readValidation: stored item failed schema validation, returning the raw value. ${resolved.issues[0]?.message ?? ''}`.trim(),
|
|
211
|
+
context,
|
|
212
|
+
);
|
|
213
|
+
return item;
|
|
214
|
+
}
|
|
215
|
+
// Merge coerced output over the raw item: schema wins per key, but keys the
|
|
216
|
+
// schema stripped are preserved (see mergeCoercedOverRaw). Only merge when both
|
|
217
|
+
// sides are plain objects — a schema whose output is a primitive/array (rare
|
|
218
|
+
// for a table item) is returned as-is.
|
|
219
|
+
if (isPlainObject(item) && isPlainObject(resolved.value)) {
|
|
220
|
+
return mergeCoercedOverRaw(resolved.value as Record<string, unknown>, item as Record<string, unknown>) as T;
|
|
221
|
+
}
|
|
222
|
+
return resolved.value as T;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** True for a plain `{}` object (not null, array, Date, or class instance). */
|
|
226
|
+
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
|
227
|
+
if (v === null || typeof v !== 'object' || Array.isArray(v)) return false;
|
|
228
|
+
const proto = Object.getPrototypeOf(v);
|
|
229
|
+
return proto === Object.prototype || proto === null;
|
|
230
|
+
}
|
package/src/index.aws.ts
CHANGED
|
@@ -21,6 +21,7 @@ export { DistributedTableErrors } from './errors.js';
|
|
|
21
21
|
export type {
|
|
22
22
|
TableKeyConfig,
|
|
23
23
|
DistributedTableOptions,
|
|
24
|
+
ReadValidationMode,
|
|
24
25
|
ExternalTableRef,
|
|
25
26
|
TableKey,
|
|
26
27
|
PartitionKeyCondition,
|
|
@@ -42,8 +43,9 @@ import type {
|
|
|
42
43
|
PartitionKeyCondition,
|
|
43
44
|
SortKeyCondition,
|
|
44
45
|
TableKey,
|
|
46
|
+
ReadValidationMode,
|
|
45
47
|
} from './types.js';
|
|
46
|
-
import { DistributedTableErrors, DistributedTableMessages, blocksError, normalizeSortKeyCondition, remapItemTooLarge } from './errors.js';
|
|
48
|
+
import { DistributedTableErrors, DistributedTableMessages, blocksError, normalizeSortKeyCondition, remapItemTooLarge, applyReadValidation } from './errors.js';
|
|
47
49
|
import type { KeyCondition, QueryOptions } from './types.js';
|
|
48
50
|
import { Logger } from '@aws-blocks/bb-logger';
|
|
49
51
|
import type { ChildLogger } from '@aws-blocks/bb-logger';
|
|
@@ -79,18 +81,23 @@ export class DistributedTable<
|
|
|
79
81
|
private schema: StandardSchemaV1<T>;
|
|
80
82
|
private keyConfig: K;
|
|
81
83
|
private indexes: Indexes;
|
|
84
|
+
private readValidation: ReadValidationMode;
|
|
82
85
|
private docClient: DynamoDBDocumentClient;
|
|
83
86
|
|
|
84
|
-
/** @internal Logger for internal operations. Defaults to
|
|
87
|
+
/** @internal Logger for internal operations. Defaults to warn-level when not provided. */
|
|
85
88
|
protected log: ChildLogger;
|
|
86
89
|
|
|
87
90
|
constructor(scope: ScopeParent, id: string, public options: DistributedTableOptions<T, K, Indexes>) {
|
|
88
91
|
super(id, { parent: scope, bbName: BB_NAME, bbVersion: BB_VERSION });
|
|
89
|
-
|
|
92
|
+
// Default level is 'warn' (not 'error') so the readValidation='coerce'
|
|
93
|
+
// raw-fallback warning actually surfaces — it's the only log the block
|
|
94
|
+
// emits, so this doesn't add noise. Callers can pass their own logger.
|
|
95
|
+
this.log = options?.logger ?? new Logger(this, 'logger', { level: 'warn' });
|
|
90
96
|
const tableName = options.table?.tableName ?? this.fullId.substring(0, 255);
|
|
91
97
|
this.schema = options.schema;
|
|
92
98
|
this.keyConfig = options.key;
|
|
93
99
|
this.indexes = (options.indexes ?? {}) as Indexes;
|
|
100
|
+
this.readValidation = options.readValidation ?? 'coerce';
|
|
94
101
|
const client = new DynamoDBClient({
|
|
95
102
|
customUserAgent: this.buildUserAgentChain(),
|
|
96
103
|
});
|
|
@@ -103,7 +110,7 @@ export class DistributedTable<
|
|
|
103
110
|
TableName: getSdkIdentifiers(this).tableName,
|
|
104
111
|
Key: this.buildKey(key),
|
|
105
112
|
}));
|
|
106
|
-
return (result.Item as T) ?? null;
|
|
113
|
+
return this.reconcileRead((result.Item as T) ?? null);
|
|
107
114
|
}
|
|
108
115
|
|
|
109
116
|
async put(item: T, options?: PutOptions<T>): Promise<void> {
|
|
@@ -178,7 +185,7 @@ export class DistributedTable<
|
|
|
178
185
|
const result = await this.docClient.send(command);
|
|
179
186
|
|
|
180
187
|
for (const item of result.Items ?? []) {
|
|
181
|
-
yield item as T;
|
|
188
|
+
yield (await this.reconcileRead(item as T)) as T;
|
|
182
189
|
if (options.limit && ++count >= options.limit) return;
|
|
183
190
|
}
|
|
184
191
|
|
|
@@ -198,7 +205,7 @@ export class DistributedTable<
|
|
|
198
205
|
}));
|
|
199
206
|
|
|
200
207
|
for (const item of result.Items ?? []) {
|
|
201
|
-
yield item as T;
|
|
208
|
+
yield (await this.reconcileRead(item as T)) as T;
|
|
202
209
|
if (options?.limit && ++count >= options.limit) return;
|
|
203
210
|
}
|
|
204
211
|
|
|
@@ -225,7 +232,9 @@ export class DistributedTable<
|
|
|
225
232
|
},
|
|
226
233
|
);
|
|
227
234
|
}
|
|
228
|
-
return
|
|
235
|
+
return Promise.all(
|
|
236
|
+
keys.map(key => this.reconcileRead(results.get(JSON.stringify(this.buildKey(key))) ?? null)),
|
|
237
|
+
);
|
|
229
238
|
}
|
|
230
239
|
|
|
231
240
|
async putBatch(items: T[]): Promise<void> {
|
|
@@ -281,6 +290,16 @@ export class DistributedTable<
|
|
|
281
290
|
}
|
|
282
291
|
}
|
|
283
292
|
|
|
293
|
+
/**
|
|
294
|
+
* Reconcile a stored value with the schema per this table's `readValidation`
|
|
295
|
+
* mode (`off` → raw, `coerce` → coerced output / raw+warn on failure, `strict`
|
|
296
|
+
* → throw on mismatch). `null` (a missing item) passes straight through. See
|
|
297
|
+
* {@link applyReadValidation}.
|
|
298
|
+
*/
|
|
299
|
+
private reconcileRead(item: T | null): Promise<T | null> {
|
|
300
|
+
return applyReadValidation(this.readValidation, this.schema, item, this.log, { table: this.fullId });
|
|
301
|
+
}
|
|
302
|
+
|
|
284
303
|
private buildKey(key: TableKey<T, K>): Record<string, any> {
|
|
285
304
|
const result: Record<string, any> = { [this.keyConfig.partitionKey]: (key as any)[this.keyConfig.partitionKey] };
|
|
286
305
|
if (this.keyConfig.sortKey) result[this.keyConfig.sortKey] = (key as any)[this.keyConfig.sortKey];
|
|
@@ -293,7 +312,7 @@ export class DistributedTable<
|
|
|
293
312
|
// and lets concurrent callers re-collide; equal jitter preserves a minimum
|
|
294
313
|
// spacing while still de-synchronising retries under shared throttling.
|
|
295
314
|
// See: https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Programming.Errors.html#Programming.Errors.BatchOperations
|
|
296
|
-
const capped = Math.min(BASE_BACKOFF_MS *
|
|
315
|
+
const capped = Math.min(BASE_BACKOFF_MS * 2 ** attempt, MAX_BACKOFF_MS);
|
|
297
316
|
const ms = capped / 2 + Math.random() * (capped / 2);
|
|
298
317
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
299
318
|
}
|
package/src/index.cdk.test.ts
CHANGED
|
@@ -15,7 +15,7 @@ import assert from 'node:assert';
|
|
|
15
15
|
import * as cdk from 'aws-cdk-lib';
|
|
16
16
|
import type { Construct } from 'constructs';
|
|
17
17
|
import { Template } from 'aws-cdk-lib/assertions';
|
|
18
|
-
import { Scope, DEFAULT_NODE_RUNTIME } from '@aws-blocks/core/cdk';
|
|
18
|
+
import { Scope, DEFAULT_NODE_RUNTIME, BlocksPresets, type BlocksDefaults } from '@aws-blocks/core/cdk';
|
|
19
19
|
import { z } from 'zod';
|
|
20
20
|
import { DistributedTable } from './index.cdk.js';
|
|
21
21
|
|
|
@@ -28,6 +28,7 @@ const userSchema = z.object({
|
|
|
28
28
|
class StubBlocksStack extends cdk.Stack {
|
|
29
29
|
public readonly handler: cdk.aws_lambda.Function;
|
|
30
30
|
public readonly id: string;
|
|
31
|
+
public defaults: BlocksDefaults = BlocksPresets.production;
|
|
31
32
|
constructor(scope: Construct, id: string) {
|
|
32
33
|
super(scope, id);
|
|
33
34
|
this.id = id;
|
|
@@ -40,9 +41,10 @@ class StubBlocksStack extends cdk.Stack {
|
|
|
40
41
|
}
|
|
41
42
|
}
|
|
42
43
|
|
|
43
|
-
function setup(): { stack: StubBlocksStack; parent: Scope } {
|
|
44
|
+
function setup(defaults: BlocksDefaults = BlocksPresets.production): { stack: StubBlocksStack; parent: Scope } {
|
|
44
45
|
const app = new cdk.App();
|
|
45
46
|
const stack = new StubBlocksStack(app, 'TestStack');
|
|
47
|
+
stack.defaults = defaults;
|
|
46
48
|
const parent = new Scope('app');
|
|
47
49
|
return { stack, parent };
|
|
48
50
|
}
|
|
@@ -57,6 +59,28 @@ test('CDK: default DistributedTable provisions a DynamoDB table', () => {
|
|
|
57
59
|
template.resourceCountIs('AWS::DynamoDB::Table', 1);
|
|
58
60
|
});
|
|
59
61
|
|
|
62
|
+
test('CDK: table adopts sandbox defaults (DESTROY, deletion protection off)', () => {
|
|
63
|
+
const { stack, parent } = setup(BlocksPresets.sandbox);
|
|
64
|
+
new DistributedTable(parent, 'users', {
|
|
65
|
+
schema: userSchema,
|
|
66
|
+
key: { partitionKey: 'userId', sortKey: 'createdAt' },
|
|
67
|
+
});
|
|
68
|
+
const template = Template.fromStack(stack);
|
|
69
|
+
template.hasResource('AWS::DynamoDB::Table', { DeletionPolicy: 'Delete' });
|
|
70
|
+
template.hasResourceProperties('AWS::DynamoDB::Table', { DeletionProtectionEnabled: false });
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('CDK: table adopts production defaults (RETAIN, deletion protection on)', () => {
|
|
74
|
+
const { stack, parent } = setup(BlocksPresets.production);
|
|
75
|
+
new DistributedTable(parent, 'users', {
|
|
76
|
+
schema: userSchema,
|
|
77
|
+
key: { partitionKey: 'userId', sortKey: 'createdAt' },
|
|
78
|
+
});
|
|
79
|
+
const template = Template.fromStack(stack);
|
|
80
|
+
template.hasResource('AWS::DynamoDB::Table', { DeletionPolicy: 'Retain' });
|
|
81
|
+
template.hasResourceProperties('AWS::DynamoDB::Table', { DeletionProtectionEnabled: true });
|
|
82
|
+
});
|
|
83
|
+
|
|
60
84
|
test('CDK: DistributedTable.fromExisting does NOT provision a table (regression)', () => {
|
|
61
85
|
const { stack, parent } = setup();
|
|
62
86
|
new DistributedTable(parent, 'users', {
|
package/src/index.cdk.ts
CHANGED
|
@@ -15,7 +15,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
15
15
|
import { dirname, join } from 'node:path';
|
|
16
16
|
|
|
17
17
|
export { DistributedTableErrors } from './errors.js';
|
|
18
|
-
export type { DistributedTableOptions, TableKeyConfig, TableKey, PutOptions, DeleteOptions, QueryOptions, ScanOptions, ExternalTableRef } from './types.js';
|
|
18
|
+
export type { DistributedTableOptions, ReadValidationMode, TableKeyConfig, TableKey, PutOptions, DeleteOptions, QueryOptions, ScanOptions, ExternalTableRef } from './types.js';
|
|
19
19
|
|
|
20
20
|
export class DistributedTable<T = any> extends Scope {
|
|
21
21
|
private table: ITable;
|
|
@@ -83,6 +83,11 @@ export class DistributedTable<T = any> extends Scope {
|
|
|
83
83
|
} : undefined,
|
|
84
84
|
billingMode: BillingMode.PAY_PER_REQUEST,
|
|
85
85
|
timeToLiveAttribute: config.ttl || undefined,
|
|
86
|
+
// Durability follows the stack-wide `defaults` (sandbox → DESTROY +
|
|
87
|
+
// no protection so a teardown is clean; production → RETAIN + protected).
|
|
88
|
+
// A richer per-block override lands with the `protection` option in #282.
|
|
89
|
+
removalPolicy: this.defaults.removalPolicy,
|
|
90
|
+
deletionProtection: this.defaults.deletionProtection,
|
|
86
91
|
});
|
|
87
92
|
|
|
88
93
|
this.table.grantReadWriteData(this.handler);
|