@aws-blocks/bb-distributed-table 0.1.2 → 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.
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;
package/src/index.mock.ts CHANGED
@@ -13,6 +13,7 @@ export { DistributedTableErrors } from './errors.js';
13
13
  export type {
14
14
  TableKeyConfig,
15
15
  DistributedTableOptions,
16
+ ReadValidationMode,
16
17
  ExternalTableRef,
17
18
  TableKey,
18
19
  PartitionKeyCondition,
@@ -33,8 +34,9 @@ import type {
33
34
  PutOptions,
34
35
  DeleteOptions,
35
36
  TableKey,
37
+ ReadValidationMode,
36
38
  } from './types.js';
37
- import { DistributedTableErrors, DistributedTableMessages, blocksError, normalizeSortKeyCondition } from './errors.js';
39
+ import { DistributedTableErrors, DistributedTableMessages, blocksError, normalizeSortKeyCondition, applyReadValidation } from './errors.js';
38
40
 
39
41
  // ── Helpers ─────────────────────────────────────────────────────────────────
40
42
 
@@ -125,23 +127,38 @@ export class DistributedTable<
125
127
  private schema: StandardSchemaV1<T>;
126
128
  private keyConfig: K;
127
129
  private indexes: Indexes;
130
+ private readValidation: ReadValidationMode;
128
131
 
129
- /** @internal Logger for internal operations. Defaults to error-level when not provided. */
132
+ /** @internal Logger for internal operations. Defaults to warn-level when not provided. */
130
133
  protected log: ChildLogger;
131
134
 
132
135
  constructor(scope: ScopeParent, id: string, public options: DistributedTableOptions<T, K, Indexes>) {
133
136
  super(id, { parent: scope, bbName: BB_NAME, bbVersion: BB_VERSION });
134
- this.log = options?.logger ?? new Logger(this, 'logger', { level: 'error' });
137
+ // Default level is 'warn' (not 'error') so the readValidation='coerce'
138
+ // raw-fallback warning actually surfaces — it's the only log the block
139
+ // emits, so this doesn't add noise. Callers can pass their own logger.
140
+ this.log = options?.logger ?? new Logger(this, 'logger', { level: 'warn' });
135
141
  this.filePath = join(getMockDataDir(this), 'data.json');
136
142
  this.data = this.loadFromDisk();
137
143
  this.schema = options.schema;
138
144
  this.keyConfig = options.key;
139
145
  this.indexes = (options.indexes ?? {}) as Indexes;
146
+ this.readValidation = options.readValidation ?? 'coerce';
140
147
  registerSdkIdentifiers(this.fullId, { tableName: `mock-${this.fullId}`.substring(0, 255) });
141
148
  }
142
149
 
143
150
  async get(key: TableKey<T, K>): Promise<T | null> {
144
- return this.data.get(this.serializeKey(key)) ?? null;
151
+ return this.reconcileRead(this.data.get(this.serializeKey(key)) ?? null);
152
+ }
153
+
154
+ /**
155
+ * Reconcile a stored value with the schema per this table's `readValidation`
156
+ * mode (`off` → raw, `coerce` → coerced output / raw+warn on failure, `strict`
157
+ * → throw on mismatch). `null` (a missing item) passes straight through. See
158
+ * {@link applyReadValidation}.
159
+ */
160
+ private reconcileRead(item: T | null): Promise<T | null> {
161
+ return applyReadValidation(this.readValidation, this.schema, item, this.log, { table: this.fullId });
145
162
  }
146
163
 
147
164
  async put(item: T, options?: PutOptions<T>): Promise<void> {
@@ -253,7 +270,7 @@ export class DistributedTable<
253
270
 
254
271
  let count = 0;
255
272
  for (const item of items) {
256
- yield item;
273
+ yield (await this.reconcileRead(item)) as T;
257
274
  if (options.limit && ++count >= options.limit) return;
258
275
  }
259
276
  }
@@ -261,7 +278,7 @@ export class DistributedTable<
261
278
  async *scan(options?: ScanOptions): AsyncIterable<T> {
262
279
  let count = 0;
263
280
  for (const item of this.data.values()) {
264
- yield item;
281
+ yield (await this.reconcileRead(item)) as T;
265
282
  if (options?.limit && ++count >= options.limit) return;
266
283
  }
267
284
  }
@@ -275,7 +292,9 @@ export class DistributedTable<
275
292
  * sustained throttling. The local mock never throttles, so it does not throw this.
276
293
  */
277
294
  async getBatch(keys: TableKey<T, K>[]): Promise<(T | null)[]> {
278
- return keys.map(key => this.data.get(this.serializeKey(key)) ?? null);
295
+ return Promise.all(
296
+ keys.map(key => this.reconcileRead(this.data.get(this.serializeKey(key)) ?? null)),
297
+ );
279
298
  }
280
299
 
281
300
  /**
package/src/index.test.ts CHANGED
@@ -219,6 +219,211 @@ describe('DistributedTable', () => {
219
219
  });
220
220
  });
221
221
 
222
+ // ── readValidation (off | coerce | strict) ────────────────────────────────
223
+ // Regression for the bug bash finding #1007: get() returned raw stored values
224
+ // without reconciling them against the schema, so after a schema change a legacy
225
+ // row no longer conformed to T — an added field was absent (a schema .default()
226
+ // was neither applied nor persisted on write-back) and a required-no-default
227
+ // field made the put() half of the read-modify-write cycle throw ValidationFailed.
228
+ // `readValidation` defaults to 'coerce', which closes the coercible gap.
229
+
230
+ describe('readValidation', () => {
231
+ // V1: no `currency`. V2: adds `currency` with a default.
232
+ const orderV1 = z.object({ orderId: z.string(), total: z.number() });
233
+ const orderV2 = z.object({
234
+ orderId: z.string(),
235
+ total: z.number(),
236
+ currency: z.string().default('USD'),
237
+ });
238
+
239
+ // Fixed scope so a V1 writer and a V2 reader share the same on-disk table,
240
+ // simulating a schema augmentation over pre-existing (legacy) rows.
241
+ function legacyScope() {
242
+ return new Scope(`dt-legacy-${++scopeCounter}-${Date.now()}`);
243
+ }
244
+
245
+ test("default is 'coerce': get() coerces the legacy row through the current schema (fills default)", async () => {
246
+ const scope = legacyScope();
247
+ const v1 = new DistributedTable(scope, 'orders', { schema: orderV1, key: { partitionKey: 'orderId' } });
248
+ await v1.put({ orderId: 'o1', total: 10 });
249
+
250
+ // No readValidation option → defaults to 'coerce'.
251
+ const v2 = new DistributedTable(scope, 'orders', { schema: orderV2, key: { partitionKey: 'orderId' } });
252
+ const row = await v2.get({ orderId: 'o1' });
253
+ assert.deepEqual(row, { orderId: 'o1', total: 10, currency: 'USD' });
254
+ });
255
+
256
+ test("default 'coerce': coerced read can be written back (fixes the read-modify-write cycle)", async () => {
257
+ const scope = legacyScope();
258
+ const v1 = new DistributedTable(scope, 'orders', { schema: orderV1, key: { partitionKey: 'orderId' } });
259
+ await v1.put({ orderId: 'o1', total: 10 });
260
+
261
+ const v2 = new DistributedTable(scope, 'orders', { schema: orderV2, key: { partitionKey: 'orderId' } });
262
+ const row = await v2.get({ orderId: 'o1' });
263
+ assert.ok(row);
264
+ await v2.put({ ...row, total: 20 }); // must NOT throw ValidationFailed
265
+ assert.deepEqual(await v2.get({ orderId: 'o1' }), { orderId: 'o1', total: 20, currency: 'USD' });
266
+ });
267
+
268
+ test("'off': get() returns the raw legacy row unchanged (opt-out of coercion)", async () => {
269
+ const scope = legacyScope();
270
+ const v1 = new DistributedTable(scope, 'orders', { schema: orderV1, key: { partitionKey: 'orderId' } });
271
+ await v1.put({ orderId: 'o1', total: 10 });
272
+
273
+ const v2 = new DistributedTable(scope, 'orders', {
274
+ schema: orderV2, key: { partitionKey: 'orderId' }, readValidation: 'off',
275
+ });
276
+ const row = await v2.get({ orderId: 'o1' });
277
+ assert.deepEqual(row, { orderId: 'o1', total: 10 }); // no currency injected
278
+ });
279
+
280
+ test("'coerce': an unrecoverable row is returned RAW (never throws) so it stays readable", async () => {
281
+ const scope = legacyScope();
282
+ // Write a row that violates the strict schema (total is a string, no coercion path).
283
+ const loose = z.object({ orderId: z.string(), total: z.any() });
284
+ const strict = z.object({ orderId: z.string(), total: z.number() });
285
+ const w = new DistributedTable(scope, 'orders', { schema: loose, key: { partitionKey: 'orderId' } });
286
+ await w.put({ orderId: 'bad', total: 'not-a-number' });
287
+
288
+ const r = new DistributedTable(scope, 'orders', {
289
+ schema: strict, key: { partitionKey: 'orderId' }, readValidation: 'coerce',
290
+ });
291
+ const row = await r.get({ orderId: 'bad' }); // must NOT throw
292
+ assert.deepEqual(row, { orderId: 'bad', total: 'not-a-number' }); // raw fallback
293
+ });
294
+
295
+ test("'coerce' PRESERVES stored keys not in the schema (no silent data loss)", async () => {
296
+ const scope = legacyScope();
297
+ // Row stored under a schema that had an extra `legacyNote` field.
298
+ const wide = z.object({ orderId: z.string(), total: z.number(), legacyNote: z.string() });
299
+ const narrow = z.object({ orderId: z.string(), total: z.number() }); // current schema no longer declares legacyNote
300
+ const w = new DistributedTable(scope, 'orders', { schema: wide, key: { partitionKey: 'orderId' } });
301
+ await w.put({ orderId: 'o1', total: 10, legacyNote: 'keep me' });
302
+
303
+ // coerce (default): unknown key is preserved (coerced output merged over the raw item).
304
+ const coerceReader = new DistributedTable(scope, 'orders', { schema: narrow, key: { partitionKey: 'orderId' } });
305
+ assert.deepEqual(await coerceReader.get({ orderId: 'o1' }), { orderId: 'o1', total: 10, legacyNote: 'keep me' });
306
+
307
+ // off also preserves (raw passthrough) — same observable result here.
308
+ const offReader = new DistributedTable(scope, 'orders', {
309
+ schema: narrow, key: { partitionKey: 'orderId' }, readValidation: 'off',
310
+ });
311
+ assert.deepEqual(await offReader.get({ orderId: 'o1' }), { orderId: 'o1', total: 10, legacyNote: 'keep me' });
312
+ });
313
+
314
+ test("'coerce' read-modify-write does NOT drop an unknown stored key (the reviewer's scenario)", async () => {
315
+ const scope = legacyScope();
316
+ const wide = z.object({ orderId: z.string(), total: z.number(), couponCode: z.string() });
317
+ const narrow = z.object({ orderId: z.string(), total: z.number() }); // couponCode no longer in schema
318
+ const seed = new DistributedTable(scope, 'orders', { schema: wide, key: { partitionKey: 'orderId' } });
319
+ await seed.put({ orderId: 'A1', total: 10, couponCode: 'SAVE10' });
320
+
321
+ // Read, change an unrelated field, write back — couponCode must survive.
322
+ const t = new DistributedTable(scope, 'orders', { schema: narrow, key: { partitionKey: 'orderId' } });
323
+ const row = await t.get({ orderId: 'A1' });
324
+ assert.ok(row);
325
+ await t.put({ ...row, total: 50 });
326
+ const after = await t.get({ orderId: 'A1' });
327
+ assert.deepEqual(after, { orderId: 'A1', total: 50, couponCode: 'SAVE10' });
328
+ });
329
+
330
+ test("'coerce' adds a new default AND preserves an unknown key at the same time", async () => {
331
+ const scope = legacyScope();
332
+ const v1 = new DistributedTable(scope, 'orders', {
333
+ schema: z.object({ orderId: z.string(), total: z.number(), couponCode: z.string() }),
334
+ key: { partitionKey: 'orderId' },
335
+ });
336
+ await v1.put({ orderId: 'o1', total: 10, couponCode: 'X' });
337
+
338
+ // V2 adds currency (default) and no longer declares couponCode.
339
+ const v2 = new DistributedTable(scope, 'orders', {
340
+ schema: z.object({ orderId: z.string(), total: z.number(), currency: z.string().default('USD') }),
341
+ key: { partitionKey: 'orderId' },
342
+ });
343
+ assert.deepEqual(await v2.get({ orderId: 'o1' }), { orderId: 'o1', total: 10, currency: 'USD', couponCode: 'X' });
344
+ });
345
+
346
+ test("'coerce' preserves unknown keys nested inside a known object (deep)", async () => {
347
+ const scope = legacyScope();
348
+ const wide = z.object({ orderId: z.string(), meta: z.object({ a: z.number(), legacy: z.boolean() }) });
349
+ const narrow = z.object({ orderId: z.string(), meta: z.object({ a: z.number() }) }); // dropped meta.legacy
350
+ const w = new DistributedTable(scope, 'orders', { schema: wide, key: { partitionKey: 'orderId' } });
351
+ await w.put({ orderId: 'o1', meta: { a: 1, legacy: true } });
352
+
353
+ const t = new DistributedTable(scope, 'orders', { schema: narrow, key: { partitionKey: 'orderId' } });
354
+ assert.deepEqual(await t.get({ orderId: 'o1' }), { orderId: 'o1', meta: { a: 1, legacy: true } });
355
+ });
356
+
357
+ test("'coerce' replaces arrays wholesale — never duplicates elements", async () => {
358
+ const scope = legacyScope();
359
+ const schema = z.object({ orderId: z.string(), tags: z.array(z.string()) });
360
+ const w = new DistributedTable(scope, 'orders', { schema, key: { partitionKey: 'orderId' } });
361
+ await w.put({ orderId: 'o1', tags: ['a', 'b'] });
362
+
363
+ // Same schema on read: coerced tags === raw tags; merge must NOT concat them.
364
+ const t = new DistributedTable(scope, 'orders', { schema, key: { partitionKey: 'orderId' } });
365
+ assert.deepEqual(await t.get({ orderId: 'o1' }), { orderId: 'o1', tags: ['a', 'b'] });
366
+ });
367
+
368
+ test("'strict': get() throws ValidationFailed on a non-conforming stored row", async () => {
369
+ const scope = legacyScope();
370
+ const loose = z.object({ orderId: z.string(), total: z.any() });
371
+ const strict = z.object({ orderId: z.string(), total: z.number() });
372
+ const w = new DistributedTable(scope, 'orders', { schema: loose, key: { partitionKey: 'orderId' } });
373
+ await w.put({ orderId: 'bad', total: 'not-a-number' });
374
+
375
+ const r = new DistributedTable(scope, 'orders', {
376
+ schema: strict, key: { partitionKey: 'orderId' }, readValidation: 'strict',
377
+ });
378
+ await assert.rejects(
379
+ () => r.get({ orderId: 'bad' }),
380
+ (err: any) => err.name === 'ValidationFailedException',
381
+ );
382
+ });
383
+
384
+ test("'strict': a conforming row reads back fine", async () => {
385
+ const scope = legacyScope();
386
+ const v1 = new DistributedTable(scope, 'orders', { schema: orderV1, key: { partitionKey: 'orderId' } });
387
+ await v1.put({ orderId: 'o1', total: 10 });
388
+ const r = new DistributedTable(scope, 'orders', {
389
+ schema: orderV1, key: { partitionKey: 'orderId' }, readValidation: 'strict',
390
+ });
391
+ assert.deepEqual(await r.get({ orderId: 'o1' }), { orderId: 'o1', total: 10 });
392
+ });
393
+
394
+ test('get() still returns null for a missing item (all modes)', async () => {
395
+ for (const readValidation of ['off', 'coerce', 'strict'] as const) {
396
+ const table = new DistributedTable(testScope(), 'orders', {
397
+ schema: orderV2, key: { partitionKey: 'orderId' }, readValidation,
398
+ });
399
+ assert.equal(await table.get({ orderId: 'nope' }), null);
400
+ }
401
+ });
402
+
403
+ test("default 'coerce': scan() and query() coerce yielded items", async () => {
404
+ const scope = legacyScope();
405
+ const v1 = new DistributedTable(scope, 'orders', { schema: orderV1, key: { partitionKey: 'orderId' } });
406
+ await v1.put({ orderId: 'o1', total: 10 });
407
+ await v1.put({ orderId: 'o2', total: 20 });
408
+
409
+ const v2 = new DistributedTable(scope, 'orders', { schema: orderV2, key: { partitionKey: 'orderId' } });
410
+ const scanned = await collect(v2.scan());
411
+ assert.ok(scanned.every(o => o.currency === 'USD'));
412
+ const queried = await collect(v2.query({ where: { orderId: { equals: 'o1' } } }));
413
+ assert.deepEqual(queried, [{ orderId: 'o1', total: 10, currency: 'USD' }]);
414
+ });
415
+
416
+ test("default 'coerce': getBatch() coerces each hit and preserves null holes", async () => {
417
+ const scope = legacyScope();
418
+ const v1 = new DistributedTable(scope, 'orders', { schema: orderV1, key: { partitionKey: 'orderId' } });
419
+ await v1.put({ orderId: 'o1', total: 10 });
420
+
421
+ const v2 = new DistributedTable(scope, 'orders', { schema: orderV2, key: { partitionKey: 'orderId' } });
422
+ const rows = await v2.getBatch([{ orderId: 'o1' }, { orderId: 'missing' }]);
423
+ assert.deepEqual(rows, [{ orderId: 'o1', total: 10, currency: 'USD' }, null]);
424
+ });
425
+ });
426
+
222
427
  // ── Query: numeric sort key ─────────────────────────────────────────────
223
428
 
224
429
  describe('query (numeric sort key)', () => {
@@ -761,3 +761,79 @@ describe('DistributedTableErrors split the old single Validation bucket into int
761
761
  assert.strictEqual((DistributedTableErrors as any).Validation, undefined);
762
762
  });
763
763
  });
764
+
765
+ describe('readValidation behaves identically on the mock and the AWS runtime', () => {
766
+ // V2 schema adds `currency` with a default; a legacy row lacks it.
767
+ const schemaV2 = z.object({ orderId: z.string(), total: z.number(), currency: z.string().default('USD') });
768
+ const legacyRow = { orderId: 'o1', total: 10 };
769
+ const coerced = { orderId: 'o1', total: 10, currency: 'USD' };
770
+
771
+ test("mock get() coerces a stored legacy-shaped row under the default 'coerce'", async () => {
772
+ // Write the legacy-shaped row directly into the store (bypassing put's
773
+ // write validation) to simulate a row that predates the V2 schema, then
774
+ // read it back through the V2 schema (readValidation defaults to 'coerce').
775
+ const v2 = new DistributedTable(testScope(), 'orders', {
776
+ schema: schemaV2, key: { partitionKey: 'orderId' },
777
+ });
778
+ (v2 as any).data.set((v2 as any).serializeKey({ orderId: 'o1' }), legacyRow);
779
+ assert.deepStrictEqual(await v2.get({ orderId: 'o1' }), coerced);
780
+ });
781
+
782
+ test("AWS get() coerces the same legacy row identically under the default 'coerce'", async () => {
783
+ const table = awsTableWithFakeClient('ovr-aws-1',
784
+ { schema: schemaV2, key: { partitionKey: 'orderId' } },
785
+ async () => ({ Item: legacyRow }),
786
+ );
787
+ assert.deepStrictEqual(await table.get({ orderId: 'o1' }), coerced);
788
+ });
789
+
790
+ test("AWS get() with readValidation 'off' returns the raw legacy row unchanged", async () => {
791
+ const table = awsTableWithFakeClient('ovr-aws-2',
792
+ { schema: schemaV2, key: { partitionKey: 'orderId' }, readValidation: 'off' },
793
+ async () => ({ Item: legacyRow }),
794
+ );
795
+ assert.deepStrictEqual(await table.get({ orderId: 'o1' }), legacyRow);
796
+ });
797
+
798
+ test("AWS get() 'coerce' returns an uncoercible row RAW and never throws", async () => {
799
+ const strict = z.object({ orderId: z.string(), total: z.number() });
800
+ const bad = { orderId: 'x', total: 'not-a-number' };
801
+ const table = awsTableWithFakeClient('ovr-aws-3',
802
+ { schema: strict, key: { partitionKey: 'orderId' }, readValidation: 'coerce' },
803
+ async () => ({ Item: bad }),
804
+ );
805
+ assert.deepStrictEqual(await table.get({ orderId: 'x' }), bad);
806
+ });
807
+
808
+ test("AWS get() 'strict' throws ValidationFailed on the same uncoercible row (mock parity)", async () => {
809
+ const strict = z.object({ orderId: z.string(), total: z.number() });
810
+ const bad = { orderId: 'x', total: 'not-a-number' };
811
+ const table = awsTableWithFakeClient('ovr-aws-4',
812
+ { schema: strict, key: { partitionKey: 'orderId' }, readValidation: 'strict' },
813
+ async () => ({ Item: bad }),
814
+ );
815
+ await assert.rejects(
816
+ () => table.get({ orderId: 'x' }),
817
+ (err: any) => err.name === 'ValidationFailedException',
818
+ );
819
+ });
820
+
821
+ // coerce preserves unknown stored keys (adds default) — must be identical mock ↔ aws.
822
+ const narrow = z.object({ orderId: z.string(), total: z.number(), currency: z.string().default('USD') });
823
+ const legacyWithExtra = { orderId: 'o1', total: 10, couponCode: 'X' }; // couponCode not in schema
824
+ const preserved = { orderId: 'o1', total: 10, couponCode: 'X', currency: 'USD' };
825
+
826
+ test("mock 'coerce' preserves an unknown key while adding the new default", async () => {
827
+ const t = new DistributedTable(testScope(), 'orders', { schema: narrow, key: { partitionKey: 'orderId' } });
828
+ (t as any).data.set((t as any).serializeKey({ orderId: 'o1' }), legacyWithExtra);
829
+ assert.deepStrictEqual(await t.get({ orderId: 'o1' }), preserved);
830
+ });
831
+
832
+ test("AWS 'coerce' preserves the same unknown key identically", async () => {
833
+ const table = awsTableWithFakeClient('ovr-aws-5',
834
+ { schema: narrow, key: { partitionKey: 'orderId' } },
835
+ async () => ({ Item: legacyWithExtra }),
836
+ );
837
+ assert.deepStrictEqual(await table.get({ orderId: 'o1' }), preserved);
838
+ });
839
+ });
package/src/types.ts CHANGED
@@ -8,6 +8,19 @@
8
8
  import type { StandardSchemaV1 } from '@standard-schema/spec';
9
9
  import type { ChildLogger } from '@aws-blocks/bb-logger';
10
10
 
11
+ // ── Read validation ─────────────────────────────────────────────────────────
12
+
13
+ /**
14
+ * Controls how reads reconcile a stored item with the schema. See
15
+ * {@link DistributedTableOptions.readValidation} for full semantics.
16
+ *
17
+ * - `'coerce'` (default): pass through the schema, return coerced output; on
18
+ * validation failure return the raw value + warn (never throws).
19
+ * - `'strict'`: validate and throw `ValidationFailed` on any non-conforming item.
20
+ * - `'off'`: return the raw stored value with no validation.
21
+ */
22
+ export type ReadValidationMode = 'off' | 'coerce' | 'strict';
23
+
11
24
  // ── Key configuration ───────────────────────────────────────────────────────
12
25
 
13
26
  export interface TableKeyConfig<T> {
@@ -44,6 +57,47 @@ export interface DistributedTableOptions<
44
57
  * ```
45
58
  */
46
59
  ttl?: keyof T & string;
60
+ /**
61
+ * How reads (`get`, `getBatch`, `query`, `scan`) reconcile a stored item with
62
+ * the configured `schema`. Writes (`put`/`putBatch`) always validate; this
63
+ * governs the read side, which matters after a schema change: a row written
64
+ * under an older schema may no longer conform to the current type `T`.
65
+ *
66
+ * - **`'coerce'`** (default) — pass each stored item through the schema and
67
+ * return its output. For transform-bearing schemas (e.g. Zod) this fills
68
+ * `.default()`s and narrows types so the value satisfies `T` and the
69
+ * read-modify-write cycle (`get()` → mutate → `put()`) round-trips. **Never
70
+ * throws:** an item that fails validation is returned **as-is** with a
71
+ * warning, keeping drifted/legacy rows readable for migration.
72
+ * - **`'strict'`** — validate on read and **throw** `ValidationFailed` on any
73
+ * item that doesn't satisfy the schema. For tables where a mismatch should be
74
+ * treated as corruption/tampering and rejected rather than absorbed. Note
75
+ * this makes a single bad row fail the whole `query`/`scan`/`getBatch`.
76
+ * - **`'off'`** — return the raw stored value with no validation (lowest cost).
77
+ * Use for hot paths, data you trust was written through this schema, or to
78
+ * read items you can't yet coerce during a migration.
79
+ *
80
+ * Defaults to `'coerce'`.
81
+ *
82
+ * > **Best-effort coercion (validator-dependent).** Coercion relies on the
83
+ * > schema *transforming* its input. Zod fills defaults and casts; a check-only
84
+ * > Standard Schema validator (some Valibot/ArkType schemas) validates without
85
+ * > transforming, so `'coerce'` returns the value unchanged for those — it never
86
+ * > invents data. A required field with no default is never fabricated: under
87
+ * > `'coerce'` such a row is returned raw + warned; under `'strict'` it throws.
88
+ *
89
+ * @example
90
+ * ```typescript
91
+ * const orders = new DistributedTable(scope, 'orders', {
92
+ * schema: orderSchemaV2, // adds `currency: z.string().default('USD')`
93
+ * key: { partitionKey: 'orderId' },
94
+ * // readValidation: 'coerce' is the default
95
+ * });
96
+ * const order = await orders.get({ orderId: 'o1' }); // legacy row → currency: 'USD'
97
+ * await orders.put({ ...order, total: 20 }); // round-trips cleanly
98
+ * ```
99
+ */
100
+ readValidation?: ReadValidationMode;
47
101
  /** Wrap an existing table instead of creating one. */
48
102
  table?: ExternalTableRef;
49
103
  /** Optional logger for internal operations. When omitted, a default Logger at error level is created. */
package/src/version.ts 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.2';
3
+ export const BB_VERSION = '0.1.4';