@aws-blocks/bb-distributed-table 0.1.3 → 0.1.4

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.
@@ -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() {
@@ -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. */
@@ -1 +1 @@
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,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,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"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
package/dist/version.d.ts CHANGED
@@ -1,3 +1,3 @@
1
1
  export declare const BB_NAME = "DistributedTable";
2
- export declare const BB_VERSION = "0.1.3";
2
+ export declare const BB_VERSION = "0.1.4";
3
3
  //# sourceMappingURL=version.d.ts.map
package/dist/version.js CHANGED
@@ -1,3 +1,3 @@
1
1
  // Auto-generated by scripts/generate-version.mjs — do not edit manually
2
2
  export const BB_NAME = 'DistributedTable';
3
- export const BB_VERSION = '0.1.3';
3
+ export const BB_VERSION = '0.1.4';
package/package.json CHANGED
@@ -1,6 +1,15 @@
1
1
  {
2
2
  "name": "@aws-blocks/bb-distributed-table",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
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.1.2",
34
- "@aws-blocks/bb-logger": "^0.1.2",
42
+ "@aws-blocks/core": "^0.1.17",
43
+ "@aws-blocks/bb-logger": "^0.1.3",
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 error-level when not provided. */
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
- this.log = options?.logger ?? new Logger(this, 'logger', { level: 'error' });
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 keys.map(key => results.get(JSON.stringify(this.buildKey(key))) ?? null);
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 * Math.pow(2, attempt), MAX_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.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;