@aws-blocks/bb-distributed-table 0.1.6 → 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/DESIGN.md CHANGED
@@ -81,6 +81,10 @@ orders.query({
81
81
 
82
82
  The `order` field maps to DynamoDB's `ScanIndexForward` parameter (`'desc'` → `ScanIndexForward: false`). It defaults to `'asc'`.
83
83
 
84
+ **Ordering ties (GSI):** a GSI sort key need not be unique, so multiple items can share one sort-key value. The mock tie-breaks on the base-table primary key (partition key, then sort key) rather than on Map insertion order — otherwise results would depend on write order / disk-reload order. This **matches DynamoDB's observed ordering** for index rows with equal sort keys; AWS does not document a contractual guarantee for the tie order, so the mock is intentionally **deterministic even where DynamoDB's contract is unspecified** (a plus for reproducible tests — just don't rely on a specific tie order in production logic). Under `order: 'desc'` the whole index order, ties included, reverses.
85
+
86
+ **String-ordering boundary:** the mock compares sort/base keys with JavaScript `<`/`>` (UTF-16 code-unit order), which can differ from DynamoDB's UTF-8 byte order for non-ASCII string keys. This applies to both the index sort-key comparison and the base-key tie-break, and is a pre-existing property of the mock — surfaced here so the parity discussion is complete.
87
+
84
88
  ### D-DT-7: TTL via options field
85
89
 
86
90
  **Decision:** TTL is configured via `ttl: 'fieldName'` in the constructor options, not as a separate method or decorator.
@@ -1 +1 @@
1
- {"version":3,"file":"index.cdk.d.ts","sourceRoot":"","sources":["../src/index.cdk.ts"],"names":[],"mappings":"AAWA,OAAO,EAAE,KAAK,EAAoC,MAAM,sBAAsB,CAAC;AAC/E,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AACpD,OAAO,KAAK,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAItE,OAAO,EAAE,sBAAsB,EAAE,MAAM,aAAa,CAAC;AACrD,YAAY,EAAE,uBAAuB,EAAE,kBAAkB,EAAE,cAAc,EAAE,QAAQ,EAAE,UAAU,EAAE,aAAa,EAAE,YAAY,EAAE,WAAW,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAEnM,qBAAa,gBAAgB,CAAC,CAAC,GAAG,GAAG,CAAE,SAAQ,KAAK;IA2BA,OAAO,EAAE,GAAG;IA1B/D,OAAO,CAAC,KAAK,CAAS;IAEtB;;;;;;OAMG;IACH,MAAM,CAAC,YAAY,CAAC,SAAS,EAAE,MAAM,GAAG,gBAAgB;IAIxD;;;;;;;;OAQG;IACH,MAAM,CAAC,UAAU,CAAC,MAAM,EAAE,MAAM,GAAG,iBAAiB;gBAIxC,KAAK,EAAE,WAAW,EAAE,EAAE,EAAE,MAAM,EAAS,OAAO,EAAE,GAAG;IAqO/D,GAAG,CAAC,GAAG,KAAK,EAAE,OAAO,EAAE,GAAG,KAAK;IAC/B,GAAG,CAAC,GAAG,KAAK,EAAE,OAAO,EAAE,GAAG,KAAK;IAC/B,MAAM,CAAC,GAAG,KAAK,EAAE,OAAO,EAAE,GAAG,KAAK;IAClC,KAAK,CAAC,GAAG,KAAK,EAAE,OAAO,EAAE,GAAG,KAAK;IACjC,IAAI,CAAC,GAAG,KAAK,EAAE,OAAO,EAAE,GAAG,KAAK;IAChC,QAAQ,CAAC,GAAG,KAAK,EAAE,OAAO,EAAE,GAAG,KAAK;IACpC,QAAQ,CAAC,GAAG,KAAK,EAAE,OAAO,EAAE,GAAG,KAAK;IACpC,WAAW,CAAC,GAAG,KAAK,EAAE,OAAO,EAAE,GAAG,KAAK;CACvC"}
1
+ {"version":3,"file":"index.cdk.d.ts","sourceRoot":"","sources":["../src/index.cdk.ts"],"names":[],"mappings":"AAYA,OAAO,EAAE,KAAK,EAAoC,MAAM,sBAAsB,CAAC;AAC/E,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AACpD,OAAO,KAAK,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAItE,OAAO,EAAE,sBAAsB,EAAE,MAAM,aAAa,CAAC;AACrD,YAAY,EAAE,uBAAuB,EAAE,kBAAkB,EAAE,cAAc,EAAE,QAAQ,EAAE,UAAU,EAAE,aAAa,EAAE,YAAY,EAAE,WAAW,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAEnM,qBAAa,gBAAgB,CAAC,CAAC,GAAG,GAAG,CAAE,SAAQ,KAAK;IA2BA,OAAO,EAAE,GAAG;IA1B/D,OAAO,CAAC,KAAK,CAAS;IAEtB;;;;;;OAMG;IACH,MAAM,CAAC,YAAY,CAAC,SAAS,EAAE,MAAM,GAAG,gBAAgB;IAIxD;;;;;;;;OAQG;IACH,MAAM,CAAC,UAAU,CAAC,MAAM,EAAE,MAAM,GAAG,iBAAiB;gBAIxC,KAAK,EAAE,WAAW,EAAE,EAAE,EAAE,MAAM,EAAS,OAAO,EAAE,GAAG;IAqO/D,GAAG,CAAC,GAAG,KAAK,EAAE,OAAO,EAAE,GAAG,KAAK;IAC/B,GAAG,CAAC,GAAG,KAAK,EAAE,OAAO,EAAE,GAAG,KAAK;IAC/B,MAAM,CAAC,GAAG,KAAK,EAAE,OAAO,EAAE,GAAG,KAAK;IAClC,KAAK,CAAC,GAAG,KAAK,EAAE,OAAO,EAAE,GAAG,KAAK;IACjC,IAAI,CAAC,GAAG,KAAK,EAAE,OAAO,EAAE,GAAG,KAAK;IAChC,QAAQ,CAAC,GAAG,KAAK,EAAE,OAAO,EAAE,GAAG,KAAK;IACpC,QAAQ,CAAC,GAAG,KAAK,EAAE,OAAO,EAAE,GAAG,KAAK;IACpC,WAAW,CAAC,GAAG,KAAK,EAAE,OAAO,EAAE,GAAG,KAAK;CACvC"}
package/dist/index.cdk.js CHANGED
@@ -4,6 +4,7 @@ import { Table, AttributeType, BillingMode, TableEncryption } from 'aws-cdk-lib/
4
4
  import * as cdk from 'aws-cdk-lib';
5
5
  import { Annotations, CustomResource, Duration, RemovalPolicy } from 'aws-cdk-lib';
6
6
  import { Code, Function as LambdaFunction } from 'aws-cdk-lib/aws-lambda';
7
+ import { LogGroup } from 'aws-cdk-lib/aws-logs';
7
8
  import { Provider } from 'aws-cdk-lib/custom-resources';
8
9
  import { PolicyStatement } from 'aws-cdk-lib/aws-iam';
9
10
  import { Key } from 'aws-cdk-lib/aws-kms';
@@ -207,7 +208,7 @@ export class DistributedTable extends Scope {
207
208
  }));
208
209
  // Add GSI manager if indexes are defined
209
210
  if (config.indexes && Object.keys(config.indexes).length > 0) {
210
- const gsiProvider = getOrCreateGsiProvider(cdk.Stack.of(this));
211
+ const gsiProvider = getOrCreateGsiProvider(cdk.Stack.of(this), this.defaults.logRetention);
211
212
  gsiProvider.addTableArn(this.table.tableArn, isSandbox);
212
213
  const indexesWithTypes = {};
213
214
  for (const [indexName, indexConfig] of Object.entries(config.indexes)) {
@@ -249,24 +250,35 @@ export class DistributedTable extends Scope {
249
250
  }
250
251
  // ── Shared GSI Manager Provider (one per stack) ─────────────────────────────
251
252
  const GSI_PROVIDER_KEY = Symbol.for('BLOCKS_GSI_MANAGER_PROVIDER');
252
- function getOrCreateGsiProvider(stack) {
253
+ function getOrCreateGsiProvider(stack, logRetention) {
253
254
  const existing = stack[GSI_PROVIDER_KEY];
254
255
  if (existing)
255
256
  return existing;
256
257
  const __dirname = dirname(fileURLToPath(import.meta.url));
257
258
  const tableArns = [];
258
259
  const sandboxTableArns = [];
260
+ // Own the GSI-manager Lambdas' log groups so their retention follows the
261
+ // stack-wide default instead of AWS's infinite retention. Torn down with the
262
+ // stack (logs are not durable state).
259
263
  const gsiManagerLambda = new LambdaFunction(stack, 'BlocksGsiManager', {
260
264
  runtime: DEFAULT_NODE_RUNTIME,
261
265
  handler: 'index.handler',
262
266
  code: Code.fromAsset(join(__dirname, 'gsi-manager-lambda')),
263
267
  timeout: Duration.minutes(15),
268
+ logGroup: new LogGroup(stack, 'BlocksGsiManagerLogs', {
269
+ retention: logRetention,
270
+ removalPolicy: cdk.RemovalPolicy.DESTROY,
271
+ }),
264
272
  });
265
273
  const gsiIsCompleteLambda = new LambdaFunction(stack, 'BlocksGsiIsComplete', {
266
274
  runtime: DEFAULT_NODE_RUNTIME,
267
275
  handler: 'index.isCompleteHandler',
268
276
  code: Code.fromAsset(join(__dirname, 'gsi-manager-lambda')),
269
277
  timeout: Duration.minutes(1),
278
+ logGroup: new LogGroup(stack, 'BlocksGsiIsCompleteLogs', {
279
+ retention: logRetention,
280
+ removalPolicy: cdk.RemovalPolicy.DESTROY,
281
+ }),
270
282
  });
271
283
  // Production permissions — lazily resolved so ARNs accumulate as tables register
272
284
  gsiManagerLambda.addToRolePolicy(new PolicyStatement({
@@ -332,6 +332,19 @@ test('CDK: a per-block protection option overrides the stack defaults', () => {
332
332
  template.hasResource('AWS::DynamoDB::Table', { DeletionPolicy: 'Retain' });
333
333
  template.hasResourceProperties('AWS::DynamoDB::Table', { DeletionProtectionEnabled: true });
334
334
  });
335
+ test('CDK: GSI manager Lambda log groups adopt defaults.logRetention', () => {
336
+ const { stack, parent } = setup(BlocksPresets.sandbox);
337
+ new DistributedTable(parent, 'users', {
338
+ schema: userSchema,
339
+ key: { partitionKey: 'userId', sortKey: 'createdAt' },
340
+ indexes: { byEmail: { partitionKey: 'email' } },
341
+ });
342
+ const template = Template.fromStack(stack);
343
+ // The GSI manager + isComplete Lambdas now own explicit log groups whose
344
+ // retention follows the stack-wide default (sandbox → one week), instead of
345
+ // AWS's infinite default.
346
+ template.hasResourceProperties('AWS::Logs::LogGroup', { RetentionInDays: 7 });
347
+ });
335
348
  test('CDK: DistributedTable.fromExisting does NOT provision a table (regression)', () => {
336
349
  const { stack, parent } = setup();
337
350
  new DistributedTable(parent, 'users', {
@@ -101,6 +101,13 @@ export declare class DistributedTable<T, K extends TableKeyConfig<T> = TableKeyC
101
101
  static fromExisting(tableName: string): ExternalTableRef;
102
102
  static fromKmsKey(keyArn: string): ExternalKmsKeyRef;
103
103
  private checkFieldEquals;
104
+ /**
105
+ * Deterministic tie-break for `query` ordering: compare two items by the
106
+ * base-table primary key (partition key, then sort key). Used when an index
107
+ * sort-key value is shared by multiple items, so results don't depend on Map
108
+ * insertion order. Returns a stable -1/0/1.
109
+ */
110
+ private compareByBaseKey;
104
111
  private serializeKey;
105
112
  private loadFromDisk;
106
113
  private flushToDisk;
@@ -1 +1 @@
1
- {"version":3,"file":"index.mock.d.ts","sourceRoot":"","sources":["../src/index.mock.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"index.mock.d.ts","sourceRoot":"","sources":["../src/index.mock.ts"],"names":[],"mappings":"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"}
@@ -212,7 +212,14 @@ export class DistributedTable extends Scope {
212
212
  const dir = options.order === 'desc' ? -1 : 1;
213
213
  items.sort((a, b) => {
214
214
  const av = a[skField], bv = b[skField];
215
- return (av < bv ? -1 : av > bv ? 1 : 0) * dir;
215
+ const primary = av < bv ? -1 : av > bv ? 1 : 0;
216
+ // Tie-break on the base-table primary key. On a GSI the sort key need
217
+ // not be unique, so equal sort-key values must NOT fall back to Map
218
+ // insertion order (which varies by write order / disk reload) — that's
219
+ // the mock-vs-DynamoDB divergence. DynamoDB orders index ties by the
220
+ // base-table key, and the whole index (ties included) reverses under
221
+ // `order: 'desc'`.
222
+ return (primary !== 0 ? primary : this.compareByBaseKey(a, b)) * dir;
216
223
  });
217
224
  }
218
225
  let count = 0;
@@ -295,6 +302,28 @@ export class DistributedTable extends Scope {
295
302
  }
296
303
  }
297
304
  }
305
+ /**
306
+ * Deterministic tie-break for `query` ordering: compare two items by the
307
+ * base-table primary key (partition key, then sort key). Used when an index
308
+ * sort-key value is shared by multiple items, so results don't depend on Map
309
+ * insertion order. Returns a stable -1/0/1.
310
+ */
311
+ compareByBaseKey(a, b) {
312
+ const pk = this.keyConfig.partitionKey;
313
+ const ap = a[pk], bp = b[pk];
314
+ if (ap !== bp)
315
+ return ap < bp ? -1 : 1;
316
+ const sk = this.keyConfig.sortKey;
317
+ if (sk) {
318
+ const as = a[sk], bs = b[sk];
319
+ if (as !== bs)
320
+ return as < bs ? -1 : 1;
321
+ }
322
+ // Unreachable for distinct rows: every item is keyed by its serialized base
323
+ // primary key, so two different entries always differ in base PK or SK.
324
+ // (String comparisons above use JS UTF-16 order — see DESIGN.md D-DT-6.)
325
+ return 0;
326
+ }
298
327
  serializeKey(key) {
299
328
  const parts = [key[this.keyConfig.partitionKey]];
300
329
  if (this.keyConfig.sortKey)
@@ -724,4 +724,46 @@ describe('DistributedTable', () => {
724
724
  async () => { for await (const _ of table.query({ index: 'doesNotExist', where: { userId: { equals: 'u1' } } })) { } }, /Index 'doesNotExist' not found/);
725
725
  });
726
726
  });
727
+ // ── Query: index sort-key ties are ordered deterministically ────────────
728
+ describe('query (index sort-key ties)', () => {
729
+ const cardSchema = z.object({ boardId: z.string(), cardId: z.string(), position: z.number() });
730
+ // Base key is (boardId, cardId); the GSI sorts by `position`, which is NOT
731
+ // unique — several cards can share a position. DynamoDB tie-breaks such
732
+ // index rows by the base-table key, so the mock must too (not by Map
733
+ // insertion order, which varies by write order / disk reload).
734
+ function cardTable() {
735
+ return new DistributedTable(testScope(), 'cards', {
736
+ schema: cardSchema,
737
+ key: { partitionKey: 'boardId', sortKey: 'cardId' },
738
+ indexes: { byPosition: { partitionKey: 'boardId', sortKey: 'position' } },
739
+ });
740
+ }
741
+ const q = (t, order) => collect(t.query({ index: 'byPosition', where: { boardId: { equals: 'b1' } }, ...(order ? { order } : {}) }));
742
+ test('ties on the index sort key order by the base-table key, regardless of write order', async () => {
743
+ const t1 = cardTable();
744
+ // All position=1; insert cardIds out of order.
745
+ for (const cardId of ['c3', 'c1', 'c2'])
746
+ await t1.put({ boardId: 'b1', cardId, position: 1 });
747
+ assert.deepEqual((await q(t1)).map((c) => c.cardId), ['c1', 'c2', 'c3']);
748
+ // A different write order must yield the SAME result (deterministic).
749
+ const t2 = cardTable();
750
+ for (const cardId of ['c2', 'c3', 'c1'])
751
+ await t2.put({ boardId: 'b1', cardId, position: 1 });
752
+ assert.deepEqual((await q(t2)).map((c) => c.cardId), ['c1', 'c2', 'c3']);
753
+ });
754
+ test('desc reverses ties too (whole index order flips)', async () => {
755
+ const t = cardTable();
756
+ for (const cardId of ['c1', 'c3', 'c2'])
757
+ await t.put({ boardId: 'b1', cardId, position: 1 });
758
+ assert.deepEqual((await q(t, 'desc')).map((c) => c.cardId), ['c3', 'c2', 'c1']);
759
+ });
760
+ test('primary order stays by index sort key; base key only breaks ties', async () => {
761
+ const t = cardTable();
762
+ await t.put({ boardId: 'b1', cardId: 'zzz', position: 1 });
763
+ await t.put({ boardId: 'b1', cardId: 'aaa', position: 2 });
764
+ await t.put({ boardId: 'b1', cardId: 'mmm', position: 1 });
765
+ // position asc first (1,1,2); within position=1, base key (cardId) breaks the tie.
766
+ assert.deepEqual((await q(t)).map((c) => [c.position, c.cardId]), [[1, 'mmm'], [1, 'zzz'], [2, 'aaa']]);
767
+ });
768
+ });
727
769
  });
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.6";
2
+ export declare const BB_VERSION = "0.1.7";
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.6';
3
+ export const BB_VERSION = '0.1.7';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aws-blocks/bb-distributed-table",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/aws-devtools-labs/aws-blocks.git",
@@ -39,8 +39,8 @@
39
39
  "test": "node --test --test-concurrency=1 dist/index.test.js dist/parity.test.js dist/index.cdk.test.js"
40
40
  },
41
41
  "dependencies": {
42
- "@aws-blocks/core": "^0.3.0",
43
- "@aws-blocks/bb-logger": "^0.1.5",
42
+ "@aws-blocks/core": "^0.4.0",
43
+ "@aws-blocks/bb-logger": "^0.1.6",
44
44
  "@aws-sdk/client-dynamodb": "^3.0.0",
45
45
  "@aws-sdk/lib-dynamodb": "^3.0.0",
46
46
  "@standard-schema/spec": "^1.0.0",
@@ -363,6 +363,20 @@ test('CDK: a per-block protection option overrides the stack defaults', () => {
363
363
  template.hasResourceProperties('AWS::DynamoDB::Table', { DeletionProtectionEnabled: true });
364
364
  });
365
365
 
366
+ test('CDK: GSI manager Lambda log groups adopt defaults.logRetention', () => {
367
+ const { stack, parent } = setup(BlocksPresets.sandbox);
368
+ new DistributedTable(parent, 'users', {
369
+ schema: userSchema,
370
+ key: { partitionKey: 'userId', sortKey: 'createdAt' },
371
+ indexes: { byEmail: { partitionKey: 'email' } },
372
+ });
373
+ const template = Template.fromStack(stack);
374
+ // The GSI manager + isComplete Lambdas now own explicit log groups whose
375
+ // retention follows the stack-wide default (sandbox → one week), instead of
376
+ // AWS's infinite default.
377
+ template.hasResourceProperties('AWS::Logs::LogGroup', { RetentionInDays: 7 });
378
+ });
379
+
366
380
  test('CDK: DistributedTable.fromExisting does NOT provision a table (regression)', () => {
367
381
  const { stack, parent } = setup();
368
382
  new DistributedTable(parent, 'users', {
package/src/index.cdk.ts CHANGED
@@ -6,6 +6,7 @@ import { Table, type ITable, AttributeType, BillingMode, TableEncryption } from
6
6
  import * as cdk from 'aws-cdk-lib';
7
7
  import { Annotations, CustomResource, Duration, RemovalPolicy } from 'aws-cdk-lib';
8
8
  import { Code, Function as LambdaFunction } from 'aws-cdk-lib/aws-lambda';
9
+ import { LogGroup, type RetentionDays } from 'aws-cdk-lib/aws-logs';
9
10
  import { Provider } from 'aws-cdk-lib/custom-resources';
10
11
  import { PolicyStatement } from 'aws-cdk-lib/aws-iam';
11
12
  import { Key, type IKey } from 'aws-cdk-lib/aws-kms';
@@ -239,7 +240,7 @@ export class DistributedTable<T = any> extends Scope {
239
240
 
240
241
  // Add GSI manager if indexes are defined
241
242
  if (config.indexes && Object.keys(config.indexes).length > 0) {
242
- const gsiProvider = getOrCreateGsiProvider(cdk.Stack.of(this));
243
+ const gsiProvider = getOrCreateGsiProvider(cdk.Stack.of(this), this.defaults.logRetention);
243
244
  gsiProvider.addTableArn(this.table.tableArn, isSandbox);
244
245
 
245
246
  const indexesWithTypes: Record<string, any> = {};
@@ -293,7 +294,7 @@ interface SharedGsiProvider {
293
294
  addTableArn: (tableArn: string, isSandbox: boolean) => void;
294
295
  }
295
296
 
296
- function getOrCreateGsiProvider(stack: cdk.Stack): SharedGsiProvider {
297
+ function getOrCreateGsiProvider(stack: cdk.Stack, logRetention: RetentionDays): SharedGsiProvider {
297
298
  const existing = (stack as any)[GSI_PROVIDER_KEY] as SharedGsiProvider | undefined;
298
299
  if (existing) return existing;
299
300
 
@@ -302,11 +303,18 @@ function getOrCreateGsiProvider(stack: cdk.Stack): SharedGsiProvider {
302
303
  const tableArns: string[] = [];
303
304
  const sandboxTableArns: string[] = [];
304
305
 
306
+ // Own the GSI-manager Lambdas' log groups so their retention follows the
307
+ // stack-wide default instead of AWS's infinite retention. Torn down with the
308
+ // stack (logs are not durable state).
305
309
  const gsiManagerLambda = new LambdaFunction(stack, 'BlocksGsiManager', {
306
310
  runtime: DEFAULT_NODE_RUNTIME,
307
311
  handler: 'index.handler',
308
312
  code: Code.fromAsset(join(__dirname, 'gsi-manager-lambda')),
309
313
  timeout: Duration.minutes(15),
314
+ logGroup: new LogGroup(stack, 'BlocksGsiManagerLogs', {
315
+ retention: logRetention,
316
+ removalPolicy: cdk.RemovalPolicy.DESTROY,
317
+ }),
310
318
  });
311
319
 
312
320
  const gsiIsCompleteLambda = new LambdaFunction(stack, 'BlocksGsiIsComplete', {
@@ -314,6 +322,10 @@ function getOrCreateGsiProvider(stack: cdk.Stack): SharedGsiProvider {
314
322
  handler: 'index.isCompleteHandler',
315
323
  code: Code.fromAsset(join(__dirname, 'gsi-manager-lambda')),
316
324
  timeout: Duration.minutes(1),
325
+ logGroup: new LogGroup(stack, 'BlocksGsiIsCompleteLogs', {
326
+ retention: logRetention,
327
+ removalPolicy: cdk.RemovalPolicy.DESTROY,
328
+ }),
317
329
  });
318
330
 
319
331
  // Production permissions — lazily resolved so ARNs accumulate as tables register
package/src/index.mock.ts CHANGED
@@ -266,7 +266,14 @@ export class DistributedTable<
266
266
  const dir = options.order === 'desc' ? -1 : 1;
267
267
  items.sort((a, b) => {
268
268
  const av = (a as any)[skField], bv = (b as any)[skField];
269
- 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;
270
277
  });
271
278
  }
272
279
 
@@ -359,6 +366,27 @@ export class DistributedTable<
359
366
  }
360
367
  }
361
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
+
362
390
  private serializeKey(key: TableKey<T, K>): string {
363
391
  const parts = [(key as any)[this.keyConfig.partitionKey]];
364
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/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.6';
3
+ export const BB_VERSION = '0.1.7';