@aws-blocks/bb-distributed-table 0.1.5 → 0.1.7

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.mock.ts CHANGED
@@ -15,6 +15,7 @@ export type {
15
15
  DistributedTableOptions,
16
16
  ReadValidationMode,
17
17
  ExternalTableRef,
18
+ ExternalKmsKeyRef,
18
19
  TableKey,
19
20
  PartitionKeyCondition,
20
21
  SortKeyCondition,
@@ -29,6 +30,7 @@ import type {
29
30
  TableKeyConfig,
30
31
  DistributedTableOptions,
31
32
  ExternalTableRef,
33
+ ExternalKmsKeyRef,
32
34
  SortKeyCondition,
33
35
  ScanOptions,
34
36
  PutOptions,
@@ -264,7 +266,14 @@ export class DistributedTable<
264
266
  const dir = options.order === 'desc' ? -1 : 1;
265
267
  items.sort((a, b) => {
266
268
  const av = (a as any)[skField], bv = (b as any)[skField];
267
- return (av < bv ? -1 : av > bv ? 1 : 0) * dir;
269
+ const primary = av < bv ? -1 : av > bv ? 1 : 0;
270
+ // Tie-break on the base-table primary key. On a GSI the sort key need
271
+ // not be unique, so equal sort-key values must NOT fall back to Map
272
+ // insertion order (which varies by write order / disk reload) — that's
273
+ // the mock-vs-DynamoDB divergence. DynamoDB orders index ties by the
274
+ // base-table key, and the whole index (ties included) reverses under
275
+ // `order: 'desc'`.
276
+ return (primary !== 0 ? primary : this.compareByBaseKey(a, b)) * dir;
268
277
  });
269
278
  }
270
279
 
@@ -334,6 +343,10 @@ export class DistributedTable<
334
343
  return { __brand: 'ExternalTableRef' as const, tableName };
335
344
  }
336
345
 
346
+ static fromKmsKey(keyArn: string): ExternalKmsKeyRef {
347
+ return { __brand: 'ExternalKmsKeyRef' as const, keyArn };
348
+ }
349
+
337
350
  // ── Internal ────────────────────────────────────────────────────────────
338
351
 
339
352
  private checkFieldEquals(keyStr: string, fields: Partial<T>): void {
@@ -353,6 +366,27 @@ export class DistributedTable<
353
366
  }
354
367
  }
355
368
 
369
+ /**
370
+ * Deterministic tie-break for `query` ordering: compare two items by the
371
+ * base-table primary key (partition key, then sort key). Used when an index
372
+ * sort-key value is shared by multiple items, so results don't depend on Map
373
+ * insertion order. Returns a stable -1/0/1.
374
+ */
375
+ private compareByBaseKey(a: T, b: T): number {
376
+ const pk = this.keyConfig.partitionKey;
377
+ const ap = (a as any)[pk], bp = (b as any)[pk];
378
+ if (ap !== bp) return ap < bp ? -1 : 1;
379
+ const sk = this.keyConfig.sortKey;
380
+ if (sk) {
381
+ const as = (a as any)[sk], bs = (b as any)[sk];
382
+ if (as !== bs) return as < bs ? -1 : 1;
383
+ }
384
+ // Unreachable for distinct rows: every item is keyed by its serialized base
385
+ // primary key, so two different entries always differ in base PK or SK.
386
+ // (String comparisons above use JS UTF-16 order — see DESIGN.md D-DT-6.)
387
+ return 0;
388
+ }
389
+
356
390
  private serializeKey(key: TableKey<T, K>): string {
357
391
  const parts = [(key as any)[this.keyConfig.partitionKey]];
358
392
  if (this.keyConfig.sortKey) parts.push((key as any)[this.keyConfig.sortKey]);
package/src/index.test.ts CHANGED
@@ -859,4 +859,49 @@ describe('DistributedTable', () => {
859
859
  );
860
860
  });
861
861
  });
862
+
863
+ // ── Query: index sort-key ties are ordered deterministically ────────────
864
+ describe('query (index sort-key ties)', () => {
865
+ const cardSchema = z.object({ boardId: z.string(), cardId: z.string(), position: z.number() });
866
+ // Base key is (boardId, cardId); the GSI sorts by `position`, which is NOT
867
+ // unique — several cards can share a position. DynamoDB tie-breaks such
868
+ // index rows by the base-table key, so the mock must too (not by Map
869
+ // insertion order, which varies by write order / disk reload).
870
+ function cardTable() {
871
+ return new DistributedTable(testScope(), 'cards', {
872
+ schema: cardSchema,
873
+ key: { partitionKey: 'boardId', sortKey: 'cardId' },
874
+ indexes: { byPosition: { partitionKey: 'boardId', sortKey: 'position' } },
875
+ });
876
+ }
877
+ const q = (t: ReturnType<typeof cardTable>, order?: 'asc' | 'desc') =>
878
+ collect(t.query({ index: 'byPosition', where: { boardId: { equals: 'b1' } }, ...(order ? { order } : {}) }));
879
+
880
+ test('ties on the index sort key order by the base-table key, regardless of write order', async () => {
881
+ const t1 = cardTable();
882
+ // All position=1; insert cardIds out of order.
883
+ for (const cardId of ['c3', 'c1', 'c2']) await t1.put({ boardId: 'b1', cardId, position: 1 });
884
+ assert.deepEqual((await q(t1)).map((c) => c.cardId), ['c1', 'c2', 'c3']);
885
+
886
+ // A different write order must yield the SAME result (deterministic).
887
+ const t2 = cardTable();
888
+ for (const cardId of ['c2', 'c3', 'c1']) await t2.put({ boardId: 'b1', cardId, position: 1 });
889
+ assert.deepEqual((await q(t2)).map((c) => c.cardId), ['c1', 'c2', 'c3']);
890
+ });
891
+
892
+ test('desc reverses ties too (whole index order flips)', async () => {
893
+ const t = cardTable();
894
+ for (const cardId of ['c1', 'c3', 'c2']) await t.put({ boardId: 'b1', cardId, position: 1 });
895
+ assert.deepEqual((await q(t, 'desc')).map((c) => c.cardId), ['c3', 'c2', 'c1']);
896
+ });
897
+
898
+ test('primary order stays by index sort key; base key only breaks ties', async () => {
899
+ const t = cardTable();
900
+ await t.put({ boardId: 'b1', cardId: 'zzz', position: 1 });
901
+ await t.put({ boardId: 'b1', cardId: 'aaa', position: 2 });
902
+ await t.put({ boardId: 'b1', cardId: 'mmm', position: 1 });
903
+ // position asc first (1,1,2); within position=1, base key (cardId) breaks the tie.
904
+ assert.deepEqual((await q(t)).map((c) => [c.position, c.cardId]), [[1, 'mmm'], [1, 'zzz'], [2, 'aaa']]);
905
+ });
906
+ });
862
907
  });
package/src/types.ts CHANGED
@@ -57,6 +57,80 @@ export interface DistributedTableOptions<
57
57
  * ```
58
58
  */
59
59
  ttl?: keyof T & string;
60
+ /**
61
+ * DynamoDB Point-in-Time Recovery (continuous backups) — restore the table
62
+ * to any second within a retention window, protecting against accidental
63
+ * writes/deletes and logical corruption.
64
+ *
65
+ * A single knob, since the recovery window only means anything when PITR is
66
+ * on:
67
+ * - `true` — enable PITR with the default 35-day window.
68
+ * - `false` — disable PITR.
69
+ * - `{ retentionDays: n }` — enable PITR and keep `n` days of continuous
70
+ * backups (**1–35**). A shorter window reduces backup-storage cost at the
71
+ * expense of how far back you can restore.
72
+ *
73
+ * When omitted, the stack-wide default applies (`defaults.pointInTimeRecovery`
74
+ * from `BlocksPresets` — on under `production`, off under `sandbox`). A
75
+ * per-block value always wins.
76
+ *
77
+ * Note: PITR bills for continuous-backup storage (per GB-month of table
78
+ * size), so it is not free on large tables.
79
+ */
80
+ pointInTimeRecovery?: boolean | { retentionDays: number };
81
+ /**
82
+ * How hard the table is to destroy — a single knob spanning DynamoDB
83
+ * deletion protection and the CloudFormation removal policy, which together
84
+ * answer one question: "can this table be destroyed?"
85
+ *
86
+ * - `'disposable'`: `RemovalPolicy.DESTROY`, deletion protection **off**.
87
+ * Deleting the stack deletes the table. The **sandbox default** — keeps
88
+ * `sandbox:destroy` a one-command teardown.
89
+ * - `'retained'`: `RemovalPolicy.RETAIN`, deletion protection **off**.
90
+ * Deleting the stack orphans (keeps) the table, but a direct
91
+ * `DeleteTable`/console delete still works. Use when you want the data to
92
+ * survive stack teardown without blocking intentional deletes.
93
+ * - `'locked'`: `RemovalPolicy.RETAIN` **and** deletion protection **on**.
94
+ * The table survives stack deletion and DynamoDB refuses a direct delete
95
+ * until protection is turned off. The **production default**.
96
+ *
97
+ * When omitted, removal policy and deletion protection follow the stack-wide
98
+ * `defaults` (`BlocksPresets.production` ≈ `'locked'`, `BlocksPresets.sandbox`
99
+ * ≈ `'disposable'`). A per-block value always wins.
100
+ *
101
+ * Replaces the separate `deletionProtection` + `removalPolicy` booleans:
102
+ * those two knobs could encode the contradictory `deletionProtection: true`
103
+ * + `removalPolicy: 'destroy'` state, which wedges stack deletion (CFN
104
+ * issues `DeleteTable`, DynamoDB refuses it, the stack lands in
105
+ * `DELETE_FAILED`). A single enum makes that state unrepresentable.
106
+ */
107
+ protection?: 'disposable' | 'retained' | 'locked';
108
+ /**
109
+ * Server-side encryption at rest.
110
+ *
111
+ * - `'aws-managed'` (default): SSE with the AWS-managed `aws/dynamodb` KMS
112
+ * key. Auditable via CloudTrail with no per-key monthly charge.
113
+ * - `'customer-managed'`: provisions a **dedicated** customer-managed KMS
114
+ * key (CMK) for this table, giving you full control over rotation and key
115
+ * policy. Incurs standard KMS key + request charges — and note this mints
116
+ * a **separate key per table**, so a dozen tables means a dozen keys.
117
+ * - a {@link ExternalKmsKeyRef} from {@link DistributedTable.fromKmsKey}:
118
+ * uses an **existing** CMK you already own, so several tables can share one
119
+ * key (and one monthly charge) instead of each provisioning its own.
120
+ *
121
+ * DynamoDB is always encrypted at rest; this only selects the key.
122
+ *
123
+ * @example
124
+ * ```ts
125
+ * // Share one key across several tables
126
+ * const key = DistributedTable.fromKmsKey(
127
+ * 'arn:aws:kms:us-east-1:111122223333:key/abcd-1234',
128
+ * );
129
+ * new DistributedTable(scope, 'orders', { schema, key: { partitionKey: 'id' }, encryption: key });
130
+ * new DistributedTable(scope, 'events', { schema, key: { partitionKey: 'id' }, encryption: key });
131
+ * ```
132
+ */
133
+ encryption?: 'aws-managed' | 'customer-managed' | ExternalKmsKeyRef;
60
134
  /**
61
135
  * How reads (`get`, `getBatch`, `query`, `scan`) reconcile a stored item with
62
136
  * the configured `schema`. Writes (`put`/`putBatch`) always validate; this
@@ -109,6 +183,17 @@ export interface ExternalTableRef {
109
183
  readonly tableName: string;
110
184
  }
111
185
 
186
+ /**
187
+ * A reference to an existing customer-managed KMS key, produced by
188
+ * {@link DistributedTable.fromKmsKey}. Pass it as the `encryption` option to
189
+ * encrypt the table with a CMK you already own — letting several tables share
190
+ * one key instead of each provisioning its own dedicated key.
191
+ */
192
+ export interface ExternalKmsKeyRef {
193
+ readonly __brand: 'ExternalKmsKeyRef';
194
+ readonly keyArn: string;
195
+ }
196
+
112
197
  // ── Key type for get/delete ─────────────────────────────────────────────────
113
198
 
114
199
  /**
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.5';
3
+ export const BB_VERSION = '0.1.7';