graphddb 0.4.2 → 0.5.0

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.
@@ -7,7 +7,7 @@ import {
7
7
  buildMaintenanceGraph,
8
8
  buildPutInput,
9
9
  resolveModelClass
10
- } from "./chunk-CPTV3H2U.js";
10
+ } from "./chunk-FMJIWFIS.js";
11
11
 
12
12
  // src/cdc/prng.ts
13
13
  var SeededRandom = class {
@@ -412,6 +412,21 @@ var relationDepthRule = {
412
412
  }
413
413
  };
414
414
 
415
+ // src/client/TableMapping.ts
416
+ var TableMapping = class {
417
+ static mapping = {};
418
+ static set(mapping) {
419
+ this.mapping = { ...mapping };
420
+ }
421
+ static resolve(declaredName) {
422
+ return this.mapping[declaredName] ?? declaredName;
423
+ }
424
+ /** @internal — for testing only */
425
+ static reset() {
426
+ this.mapping = {};
427
+ }
428
+ };
429
+
415
430
  // src/linter/rules/same-partition-preset.ts
416
431
  var samePartitionPresetRule = {
417
432
  id: "same-partition-preset",
@@ -843,6 +858,112 @@ function destinationRowKey(owner, relation) {
843
858
  return `${owner}#${parts.join("&")}`;
844
859
  }
845
860
 
861
+ // src/linter/rules/cfn-schema-consistency.ts
862
+ var MAX_GSIS_PER_TABLE = 20;
863
+ function gsiShape(gsi2) {
864
+ const hasSk = gsi2.segmented.skSegments.length > 0;
865
+ return { indexName: gsi2.indexName, hasSk };
866
+ }
867
+ function err3(entity, message) {
868
+ return {
869
+ ruleId: "cfn-schema-consistency",
870
+ severity: "error",
871
+ message,
872
+ entity
873
+ };
874
+ }
875
+ var cfnSchemaConsistencyRule = {
876
+ id: "cfn-schema-consistency",
877
+ severity: "error",
878
+ check(metadata, registry) {
879
+ return checkAllTables(metadata, registry);
880
+ }
881
+ };
882
+ function checkAllTables(current, registry) {
883
+ const seenMeta = /* @__PURE__ */ new Set();
884
+ const byPhysical = /* @__PURE__ */ new Map();
885
+ const add = (meta) => {
886
+ if (seenMeta.has(meta)) return;
887
+ seenMeta.add(meta);
888
+ const physical = TableMapping.resolve(meta.tableName);
889
+ const list = byPhysical.get(physical) ?? [];
890
+ list.push(meta);
891
+ byPhysical.set(physical, list);
892
+ };
893
+ add(current);
894
+ for (const [, meta] of registry.getFinalized()) add(meta);
895
+ const currentPhysical = TableMapping.resolve(current.tableName);
896
+ const entities = byPhysical.get(currentPhysical);
897
+ if (!entities) return [];
898
+ return checkTable(currentPhysical, entities);
899
+ }
900
+ function checkTable(physical, entities) {
901
+ const ordered = [...entities].sort((a, b) => a.prefix.localeCompare(b.prefix));
902
+ const results = [];
903
+ results.push(...checkBaseKeySchema(physical, ordered));
904
+ results.push(...checkGsiUnion(physical, ordered));
905
+ return results;
906
+ }
907
+ function checkBaseKeySchema(physical, entities) {
908
+ const withSk = [];
909
+ const withoutSk = [];
910
+ for (const meta of entities) {
911
+ if (!meta.primaryKey) continue;
912
+ const hasSk = meta.primaryKey.segmented.skSegments.length > 0;
913
+ (hasSk ? withSk : withoutSk).push(meta.prefix);
914
+ }
915
+ if (withSk.length > 0 && withoutSk.length > 0) {
916
+ const offender = [...withSk, ...withoutSk].sort()[0];
917
+ return [
918
+ err3(
919
+ offender,
920
+ `CloudFormation schema consistency: physical table '${physical}' has an ambiguous base KeySchema \u2014 some entities declare a sort key and some do not (with SK: [${withSk.join(", ")}]; without SK: [${withoutSk.join(", ")}]). A single DynamoDB table has exactly one KeySchema; every entity sharing a table must agree on whether the base key has an SK.`
921
+ )
922
+ ];
923
+ }
924
+ return [];
925
+ }
926
+ function checkGsiUnion(physical, entities) {
927
+ const results = [];
928
+ const seen = /* @__PURE__ */ new Map();
929
+ for (const meta of entities) {
930
+ for (const gsi2 of meta.gsiDefinitions) {
931
+ const shape = gsiShape(gsi2);
932
+ const existing = seen.get(shape.indexName);
933
+ if (!existing) {
934
+ seen.set(shape.indexName, { shape, entity: meta.prefix });
935
+ continue;
936
+ }
937
+ const conflict = describeConflict(existing.shape, shape);
938
+ if (conflict) {
939
+ results.push(
940
+ err3(
941
+ meta.prefix,
942
+ `CloudFormation schema consistency: GSI '${shape.indexName}' on physical table '${physical}' is declared inconsistently across entities that share it \u2014 ${conflict} (first declared by '${existing.entity}', conflicting declaration on '${meta.prefix}'). A single DynamoDB table has exactly one GSI per index name; entities may share an index name (projecting different logical fields into the shared physical key), but only when its physical key structure (sort-key presence) is identical.`
943
+ )
944
+ );
945
+ }
946
+ }
947
+ }
948
+ if (seen.size > MAX_GSIS_PER_TABLE) {
949
+ const names = [...seen.keys()].sort();
950
+ const offender = [...entities].map((e) => e.prefix).sort()[0];
951
+ results.push(
952
+ err3(
953
+ offender,
954
+ `CloudFormation schema consistency: physical table '${physical}' would have ${seen.size} global secondary indexes after unioning across entities, exceeding the DynamoDB limit of ${MAX_GSIS_PER_TABLE}. Distinct index names: [${names.join(", ")}]. Reduce the number of distinct GSIs on this table.`
955
+ )
956
+ );
957
+ }
958
+ return results;
959
+ }
960
+ function describeConflict(a, b) {
961
+ if (a.hasSk !== b.hasSk) {
962
+ return `one declaration has a sort key and the other does not (${a.hasSk ? "with" : "without"} SK vs. ${b.hasSk ? "with" : "without"} SK)`;
963
+ }
964
+ return null;
965
+ }
966
+
846
967
  // src/linter/default-linter.ts
847
968
  function createDefaultLinter() {
848
969
  const linter = new Linter();
@@ -857,6 +978,7 @@ function createDefaultLinter() {
857
978
  linter.addRule(hotPartitionRule);
858
979
  linter.addRule(fanOutRule);
859
980
  linter.addRule(multiMaintainerSameRowRule);
981
+ linter.addRule(cfnSchemaConsistencyRule);
860
982
  return linter;
861
983
  }
862
984
 
@@ -909,6 +1031,25 @@ var MetadataRegistry = class {
909
1031
  }
910
1032
  return new Map(this.store);
911
1033
  }
1034
+ /**
1035
+ * Snapshot of the entities that are **already finalized**, WITHOUT triggering
1036
+ * finalize on any pending entity (unlike {@link getAll}). This is the safe view
1037
+ * for a cross-entity lint rule that runs *inside* `finalize`: eagerly
1038
+ * finalizing the rest of the registry from within a finalize pass would
1039
+ * re-enter (and duplicate) the in-progress entity's finalize. A whole-registry
1040
+ * rule instead reasons over the already-finalized peers plus the entity
1041
+ * currently being checked; any conflicting pair is caught when the later of the
1042
+ * two finalizes (both are present by then).
1043
+ *
1044
+ * @internal — for cross-entity linter rules.
1045
+ */
1046
+ static getFinalized() {
1047
+ const out = /* @__PURE__ */ new Map();
1048
+ for (const [target, meta] of this.store) {
1049
+ if (meta._finalized) out.set(target, meta);
1050
+ }
1051
+ return out;
1052
+ }
912
1053
  static get linter() {
913
1054
  return this._linter;
914
1055
  }
@@ -968,21 +1109,6 @@ ${messages}`);
968
1109
  }
969
1110
  };
970
1111
 
971
- // src/client/TableMapping.ts
972
- var TableMapping = class {
973
- static mapping = {};
974
- static set(mapping) {
975
- this.mapping = { ...mapping };
976
- }
977
- static resolve(declaredName) {
978
- return this.mapping[declaredName] ?? declaredName;
979
- }
980
- /** @internal — for testing only */
981
- static reset() {
982
- this.mapping = {};
983
- }
984
- };
985
-
986
1112
  // src/define/param.ts
987
1113
  function isParamOptions(value) {
988
1114
  return typeof value === "object" && value !== null;
@@ -2334,14 +2460,14 @@ var MiddlewareRuntime = class {
2334
2460
  * is the host's responsibility — a hook recovering a `query` should return an
2335
2461
  * item / `null`, one recovering a `list` a `{ items, cursor }`.
2336
2462
  */
2337
- async runRequestError(ctx, err3) {
2463
+ async runRequestError(ctx, err4) {
2338
2464
  for (let i = this.chain.length - 1; i >= 0; i--) {
2339
2465
  const onError = this.chain[i].read?.onError;
2340
2466
  if (!onError) continue;
2341
- const recovered = await onError(ctx, err3);
2467
+ const recovered = await onError(ctx, err4);
2342
2468
  if (recovered !== void 0) return recovered;
2343
2469
  }
2344
- throw err3;
2470
+ throw err4;
2345
2471
  }
2346
2472
  /**
2347
2473
  * Drive ONE physical op (root read or any fan-out fetch) through R2 → send →
@@ -2381,14 +2507,14 @@ var MiddlewareRuntime = class {
2381
2507
  if (afterFetch) items = await afterFetch(ctx, items);
2382
2508
  }
2383
2509
  return items;
2384
- } catch (err3) {
2510
+ } catch (err4) {
2385
2511
  for (let i = this.chain.length - 1; i >= 0; i--) {
2386
2512
  const onError = this.chain[i].read?.op?.onError;
2387
2513
  if (!onError) continue;
2388
- const recovered = await onError(ctx, err3);
2514
+ const recovered = await onError(ctx, err4);
2389
2515
  if (recovered !== void 0) return recovered;
2390
2516
  }
2391
- throw err3;
2517
+ throw err4;
2392
2518
  }
2393
2519
  }
2394
2520
  };
@@ -2452,14 +2578,14 @@ var WriteRuntime = class {
2452
2578
  * short-circuits the chain); the value becomes the write's resolved value.
2453
2579
  * Otherwise the original error rethrows (symmetric with the read `onError`).
2454
2580
  */
2455
- async runWriteError(ctx, err3) {
2581
+ async runWriteError(ctx, err4) {
2456
2582
  for (let i = this.chain.length - 1; i >= 0; i--) {
2457
2583
  const onError = this.chain[i].write?.onError;
2458
2584
  if (!onError) continue;
2459
- const recovered = await onError(ctx, err3);
2585
+ const recovered = await onError(ctx, err4);
2460
2586
  if (recovered !== void 0) return recovered;
2461
2587
  }
2462
- throw err3;
2588
+ throw err4;
2463
2589
  }
2464
2590
  /** Build a W3/W4/W5 persist context (pure — no hooks run). */
2465
2591
  persistCtx(items, origins, transaction) {
@@ -2488,20 +2614,20 @@ var WriteRuntime = class {
2488
2614
  if (before) await before(ctx);
2489
2615
  }
2490
2616
  results = await send(ctx.items);
2491
- } catch (err3) {
2617
+ } catch (err4) {
2492
2618
  let recovered;
2493
2619
  let didRecover = false;
2494
2620
  for (let i = this.chain.length - 1; i >= 0; i--) {
2495
2621
  const onError = this.chain[i].write?.persist?.onError;
2496
2622
  if (!onError) continue;
2497
- const r = await onError(ctx, err3);
2623
+ const r = await onError(ctx, err4);
2498
2624
  if (r !== void 0) {
2499
2625
  recovered = r;
2500
2626
  didRecover = true;
2501
2627
  break;
2502
2628
  }
2503
2629
  }
2504
- if (!didRecover) throw err3;
2630
+ if (!didRecover) throw err4;
2505
2631
  results = recovered;
2506
2632
  }
2507
2633
  for (let i = this.chain.length - 1; i >= 0; i--) {
@@ -2722,8 +2848,8 @@ async function executeSingleWriteWithHooks(req, mw) {
2722
2848
  await mw.runWriteBefore(ctx);
2723
2849
  const change2 = await persistRewrittenWrite(req.modelClass, ctx, mw);
2724
2850
  await mw.runWriteAfter(ctx, change2);
2725
- } catch (err3) {
2726
- await mw.runWriteError(ctx, err3);
2851
+ } catch (err4) {
2852
+ await mw.runWriteError(ctx, err4);
2727
2853
  }
2728
2854
  }
2729
2855
  async function persistRewrittenWrite(modelClass, ctx, mw) {
@@ -4287,9 +4413,9 @@ export {
4287
4413
  resolveKey,
4288
4414
  missingGsiRule,
4289
4415
  relationDepthRule,
4416
+ TableMapping,
4290
4417
  createDefaultLinter,
4291
4418
  MetadataRegistry,
4292
- TableMapping,
4293
4419
  pkTemplate,
4294
4420
  skTemplate,
4295
4421
  resolveSegmentedKey,
@@ -42,7 +42,7 @@ import {
42
42
  serializeFieldValue,
43
43
  serializeRawCondition,
44
44
  skTemplate
45
- } from "./chunk-CPTV3H2U.js";
45
+ } from "./chunk-FMJIWFIS.js";
46
46
 
47
47
  // src/spec/types.ts
48
48
  var SPEC_VERSION = "1.0";
@@ -129,7 +129,11 @@ function buildEntity(metadata) {
129
129
  key: buildKey(metadata.primaryKey),
130
130
  gsis: buildGsis(metadata),
131
131
  relations: buildRelations(metadata),
132
- ...metadata.description !== void 0 ? { description: metadata.description } : {}
132
+ ...metadata.description !== void 0 ? { description: metadata.description } : {},
133
+ // TTL (issue #172, Epic #167 — C4): a physical-schema fact carried on the
134
+ // manifest so the CFn emitter can render `TimeToLiveSpecification`. Absent
135
+ // when no `@ttl` field is declared (byte-identical to the pre-#172 manifest).
136
+ ...metadata.ttlAttribute !== void 0 ? { ttlAttribute: metadata.ttlAttribute } : {}
133
137
  };
134
138
  }
135
139
  function buildManifest(registry = MetadataRegistry) {
@@ -140,12 +144,28 @@ function buildManifest(registry = MetadataRegistry) {
140
144
  }
141
145
  const entities = {};
142
146
  const tables = {};
147
+ const ttlByPhysicalTable = /* @__PURE__ */ new Map();
143
148
  for (const name of [...entitiesByName.keys()].sort()) {
144
149
  const metadata = entitiesByName.get(name);
145
150
  entities[name] = buildEntity(metadata);
151
+ const physicalName = TableMapping.resolve(metadata.tableName);
146
152
  tables[metadata.tableName] = {
147
- physicalName: TableMapping.resolve(metadata.tableName)
153
+ physicalName
148
154
  };
155
+ if (metadata.ttlAttribute !== void 0) {
156
+ const existing = ttlByPhysicalTable.get(physicalName);
157
+ if (existing && existing.attr !== metadata.ttlAttribute) {
158
+ throw new Error(
159
+ `Manifest build: physical table '${physicalName}' has more than one TTL attribute \u2014 entity '${existing.entity}' declares \`@ttl\` on '${existing.attr}' and entity '${name}' on '${metadata.ttlAttribute}'. DynamoDB allows exactly one TTL attribute per table; align the \`@ttl\` attribute across all entities sharing this table (or drop one).`
160
+ );
161
+ }
162
+ if (!existing) {
163
+ ttlByPhysicalTable.set(physicalName, {
164
+ entity: name,
165
+ attr: metadata.ttlAttribute
166
+ });
167
+ }
168
+ }
149
169
  }
150
170
  return {
151
171
  version: SPEC_VERSION,