@githolon/dsl 0.102.3 → 0.102.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@githolon/dsl",
3
- "version": "0.102.3",
3
+ "version": "0.102.5",
4
4
  "type": "module",
5
5
  "description": "Nomos 2 domain-authoring DSL: aggregates + directives in TS, executed and encoded to the Rust kernel's wire shapes.",
6
6
  "license": "SEE LICENSE IN LICENSE.md",
@@ -26,6 +26,7 @@ import {
26
26
  type EvolveLaw,
27
27
  type StableIdLockfile,
28
28
  } from "./evolve_preview.js";
29
+ import { parseStableIdLockfile } from "./stable_id_lockfile.js";
29
30
 
30
31
  interface DomainConfig {
31
32
  key: string;
@@ -125,7 +126,7 @@ function loadPriorEvolveLaw(arg: string): { law: EvolveLaw; source: string; note
125
126
  const p = parsed as Record<string, unknown>;
126
127
  if (p.domains !== undefined && p.interface === undefined && p.packageUsda === undefined) {
127
128
  return {
128
- law: evolveLawFromLockfile(parsed as StableIdLockfile),
129
+ law: evolveLawFromLockfile(parseStableIdLockfile(parsed)),
129
130
  source: file,
130
131
  note: `prior is a lockfile — aggregate/field identity is diffed, but directive payload changes cannot be (deploy.json carries the interface).`,
131
132
  };
@@ -149,7 +150,7 @@ function loadCwdLockfile(configArg: string): StableIdLockfile | undefined {
149
150
  const lockPath = path.join(cfgDir, "nomos.stable-ids.json");
150
151
  if (!existsSync(lockPath)) return undefined;
151
152
  try {
152
- return JSON.parse(readFileSync(lockPath, "utf8")) as StableIdLockfile;
153
+ return parseStableIdLockfile(JSON.parse(readFileSync(lockPath, "utf8")));
153
154
  } catch {
154
155
  return undefined;
155
156
  }
@@ -1707,7 +1707,7 @@ function dartTypeOfVoField(f: ValueObjectFieldDescriptor, voName: string, fieldN
1707
1707
  switch (f.type) {
1708
1708
  case "string":
1709
1709
  return f.idOf !== undefined
1710
- ? `NomosRef<${concreteClassName(f.idOf)}>`
1710
+ ? `NomosRef<${f.idOfContract === true ? contractTypeName(f.idOf) : concreteClassName(f.idOf)}>`
1711
1711
  : f.brand ?? "String"; // a brandedId(...) → the generated extension-type wrapper
1712
1712
  case "number":
1713
1713
  // a UNIT-tagged number is the generated zero-cost extension type; `.int()` is Dart int.
@@ -1742,7 +1742,7 @@ function voFromExpr(
1742
1742
  switch (f.type) {
1743
1743
  case "string":
1744
1744
  if (f.idOf !== undefined) {
1745
- const target = concreteClassName(f.idOf);
1745
+ const target = f.idOfContract === true ? contractTypeName(f.idOf) : concreteClassName(f.idOf);
1746
1746
  return `NmsInternalNomosRefs.create<${target}>(workspace: '', type: '${f.idOf}', id: ${expr} as String)`;
1747
1747
  }
1748
1748
  return f.brand !== undefined ? `${f.brand}(${expr} as String)` : `${expr} as String`;
@@ -721,6 +721,11 @@ export function generateDartApplication(mod: DomainModule, opts: DartApplication
721
721
  const targetFile = owner?.name ?? mod.name;
722
722
  const targetApplication = dartApplicationClassName(targetDomain);
723
723
  const opener = `nomosInternalOpen${targetApplication}`;
724
+ const rootAggregate = aggregates.get(birth.aggregateId);
725
+ const rootType = concreteClassName(birth.aggregateId);
726
+ const rootCollection = member(plural(rootAggregate === undefined
727
+ ? birth.aggregateId
728
+ : concreteClassName(rootAggregate.id)));
724
729
  if (targetFile !== mod.name) {
725
730
  addSibling(`${targetFile}.application.dart`, targetApplication);
726
731
  addSibling(`${targetFile}.application.dart`, opener);
@@ -735,7 +740,9 @@ export function generateDartApplication(mod: DomainModule, opts: DartApplication
735
740
  ` /// or call a lower-level client.`,
736
741
  ` Future<${targetApplication}> ${method}(${dartActionInputClassName(directive)} facts) async {`,
737
742
  ` final reference = await _client.${method}(facts);`,
738
- ` return ${opener}(_client.bridge, reference, _client.actor);`,
743
+ opts.supportImport === "package"
744
+ ? ` return ${rootCollection}._nmsOpenReference(reference);`
745
+ : ` return ${opener}(_client.bridge, reference, _client.actor);`,
739
746
  ` }`,
740
747
  ``,
741
748
  );
@@ -744,31 +751,47 @@ export function generateDartApplication(mod: DomainModule, opts: DartApplication
744
751
  ` /// Give one ${birth.aggregateId} its own durable life.`,
745
752
  ` Future<${targetApplication}> birth(Create${cap(camel(birth.aggregateId))}Input facts) async {`,
746
753
  ` final reference = await _client.birth(facts);`,
747
- ` return ${opener}(_client.bridge, reference, _client.actor);`,
754
+ opts.supportImport === "package"
755
+ ? ` return _nmsOpenReference(reference);`
756
+ : ` return ${opener}(_client.bridge, reference, _client.actor);`,
748
757
  ` }`,
749
758
  ``,
750
759
  ]);
751
760
  }
752
761
  if (opts.supportImport === "package") {
753
762
  usesWorkspaceName = true;
754
- const rootAggregate = aggregates.get(birth.aggregateId);
755
763
  const rootModel = rootAggregate === undefined
756
764
  ? `${concreteClassName(birth.aggregateId)}ReadModel`
757
765
  : readModelClassName(rootAggregate.id);
758
- const rootType = concreteClassName(birth.aggregateId);
759
- const rootCollection = member(plural(rootAggregate === undefined
760
- ? birth.aggregateId
761
- : concreteClassName(rootAggregate.id)));
762
766
  addCollectionMember(birth.aggregateId, "open", [
767
+ ` final Map<String, Future<${targetApplication}>> _nmsApplications = {};`,
768
+ ``,
769
+ ` Future<${targetApplication}> _nmsOpenReference(NomosRef<${rootType}> reference) {`,
770
+ ` final workspace = NmsInternalNomosRefs.workspace(reference);`,
771
+ ` final existing = _nmsApplications[workspace];`,
772
+ ` if (existing != null) return existing;`,
773
+ ` late final Future<${targetApplication}> opened;`,
774
+ ` opened = (() async {`,
775
+ ` try {`,
776
+ ` return await ${opener}(_client.bridge, reference, _client.actor);`,
777
+ ` } catch (_) {`,
778
+ ` if (identical(_nmsApplications[workspace], opened)) {`,
779
+ ` _nmsApplications.remove(workspace);`,
780
+ ` }`,
781
+ ` rethrow;`,
782
+ ` }`,
783
+ ` })();`,
784
+ ` _nmsApplications[workspace] = opened;`,
785
+ ` return opened;`,
786
+ ` }`,
787
+ ``,
763
788
  ` /// Open the ${birth.aggregateId} whose ${birth.keyField} is [key].`,
764
- ` Future<${targetApplication}> open(String key) => ${opener}(`,
765
- ` _client.bridge,`,
789
+ ` Future<${targetApplication}> open(String key) => _nmsOpenReference(`,
766
790
  ` NmsInternalNomosRefs.create<${rootType}>(`,
767
791
  ` workspace: deriveWorkspaceName(${JSON.stringify(birth.namespace)}, key),`,
768
792
  ` type: ${JSON.stringify(birth.aggregateId)},`,
769
793
  ` id: ${JSON.stringify(birth.rootId)},`,
770
794
  ` ),`,
771
- ` _client.actor,`,
772
795
  ` );`,
773
796
  ``,
774
797
  ]);
@@ -790,14 +813,9 @@ export function generateDartApplication(mod: DomainModule, opts: DartApplication
790
813
  ` if (raw is! Map) continue;`,
791
814
  ` final child = (raw['child'] ?? '').toString();`,
792
815
  ` if (!child.startsWith(${JSON.stringify(`${birth.namespace}-`)})) continue;`,
793
- ` final application = await ${opener}(`,
794
- ` _client.bridge,`,
795
- ` NmsInternalNomosRefs.create<${rootType}>(workspace: child, type: ${JSON.stringify(birth.aggregateId)}, id: ${JSON.stringify(birth.rootId)}),`,
796
- ` _client.actor,`,
797
- ` );`,
798
- ` final record = await application.${rootCollection}.find(`,
799
- ` NmsInternalNomosRefs.create<${rootType}>(workspace: child, type: ${JSON.stringify(birth.aggregateId)}, id: ${JSON.stringify(birth.rootId)}),`,
800
- ` );`,
816
+ ` final reference = NmsInternalNomosRefs.create<${rootType}>(workspace: child, type: ${JSON.stringify(birth.aggregateId)}, id: ${JSON.stringify(birth.rootId)});`,
817
+ ` final application = await _nmsOpenReference(reference);`,
818
+ ` final record = await application.${rootCollection}.find(reference);`,
801
819
  ` if (record != null) records.add(record);`,
802
820
  ` }`,
803
821
  ` return records;`,
@@ -833,14 +851,9 @@ export function generateDartApplication(mod: DomainModule, opts: DartApplication
833
851
  ` object != 'workspace:self' ||`,
834
852
  ` prunedAt.isNotEmpty ||`,
835
853
  ` !seen.add(workspace)) continue;`,
836
- ` final application = await ${opener}(`,
837
- ` _client.bridge,`,
838
- ` NmsInternalNomosRefs.create<${rootType}>(workspace: workspace, type: ${JSON.stringify(birth.aggregateId)}, id: ${JSON.stringify(birth.rootId)}),`,
839
- ` _client.actor,`,
840
- ` );`,
841
- ` final record = await application.${rootCollection}.find(`,
842
- ` NmsInternalNomosRefs.create<${rootType}>(workspace: workspace, type: ${JSON.stringify(birth.aggregateId)}, id: ${JSON.stringify(birth.rootId)}),`,
843
- ` );`,
854
+ ` final reference = NmsInternalNomosRefs.create<${rootType}>(workspace: workspace, type: ${JSON.stringify(birth.aggregateId)}, id: ${JSON.stringify(birth.rootId)});`,
855
+ ` final application = await _nmsOpenReference(reference);`,
856
+ ` final record = await application.${rootCollection}.find(reference);`,
844
857
  ` if (record != null) records.add(record);`,
845
858
  ` }`,
846
859
  ` return records;`,
@@ -855,10 +868,8 @@ export function generateDartApplication(mod: DomainModule, opts: DartApplication
855
868
  ` /// Continue working in the business application that produced [record].`,
856
869
  ` Future<${targetApplication}> applicationOf(${rootModel} record) {`,
857
870
  ` final origin = NmsInternalRecordOrigins.require(record);`,
858
- ` return ${opener}(`,
859
- ` _client.bridge,`,
871
+ ` return _nmsOpenReference(`,
860
872
  ` NmsInternalNomosRefs.create<${rootType}>(workspace: origin.workspace, type: ${JSON.stringify(birth.aggregateId)}, id: origin.id),`,
861
- ` _client.actor,`,
862
873
  ` );`,
863
874
  ` }`,
864
875
  ``,
@@ -1028,6 +1039,55 @@ export function generateDartApplication(mod: DomainModule, opts: DartApplication
1028
1039
  (candidate as { businessBirthInternal?: unknown }).businessBirthInternal !== true,
1029
1040
  )) {
1030
1041
  const sourceModel = readModelClassName(aggregate.id);
1042
+ for (const contractId of aggregate.implementsContracts ?? []) {
1043
+ const contract = contractTypeName(contractId);
1044
+ const method = member(`as${contract}`);
1045
+ addCollectionMember(aggregate.id, method, [
1046
+ ` /// Use [record] wherever another domain declares [${contract}].`,
1047
+ ` ///`,
1048
+ ` /// Nomos preserves the record's workspace-qualified identity while exposing only`,
1049
+ ` /// the contract that this aggregate explicitly implements. This does not grant access.`,
1050
+ ` NomosRef<${contract}> ${method}(${sourceModel} record) {`,
1051
+ ` final reference = record.ref;`,
1052
+ ` _nmsRequireReference(reference, ${JSON.stringify(aggregate.id)}, '');`,
1053
+ ` return NmsInternalNomosRefs.create<${contract}>(`,
1054
+ ` workspace: reference.workspace,`,
1055
+ ` type: ${JSON.stringify(contractId)},`,
1056
+ ` id: reference.id,`,
1057
+ ` );`,
1058
+ ` }`,
1059
+ ``,
1060
+ ]);
1061
+ const resolveMethod = member(`resolve${contract}`);
1062
+ addCollectionMember(aggregate.id, resolveMethod, [
1063
+ ` /// Resolve [reference] as the concrete ${sourceModel} that implements [${contract}].`,
1064
+ ` ///`,
1065
+ ` /// Use this after following a generated cross-application reference and opening`,
1066
+ ` /// its business application. Nomos checks the declared contract and access on read.`,
1067
+ ` Future<${sourceModel}?> ${resolveMethod}(ForeignRef<${contract}> reference) {`,
1068
+ ` _nmsRequireReference(reference.ref, ${JSON.stringify(contractId)}, '');`,
1069
+ ` return find(NmsInternalNomosRefs.create<${concreteClassName(aggregate.id)}>(`,
1070
+ ` workspace: reference.workspace,`,
1071
+ ` type: ${JSON.stringify(aggregate.id)},`,
1072
+ ` id: reference.id,`,
1073
+ ` ));`,
1074
+ ` }`,
1075
+ ``,
1076
+ ]);
1077
+ const watchMethod = member(`watch${contract}`);
1078
+ addCollectionMember(aggregate.id, watchMethod, [
1079
+ ` /// React when the concrete record behind [reference] changes.`,
1080
+ ` Stream<${sourceModel}?> ${watchMethod}(ForeignRef<${contract}> reference) {`,
1081
+ ` _nmsRequireReference(reference.ref, ${JSON.stringify(contractId)}, '');`,
1082
+ ` return watchOne(NmsInternalNomosRefs.create<${concreteClassName(aggregate.id)}>(`,
1083
+ ` workspace: reference.workspace,`,
1084
+ ` type: ${JSON.stringify(aggregate.id)},`,
1085
+ ` id: reference.id,`,
1086
+ ` ));`,
1087
+ ` }`,
1088
+ ``,
1089
+ ]);
1090
+ }
1031
1091
  const sameDomainRefs = Object.entries(aggregate.fields).filter(([, field]) =>
1032
1092
  field.kind === "ref" &&
1033
1093
  field.foreignRef === undefined &&
@@ -1301,7 +1361,7 @@ export function generateDartApplication(mod: DomainModule, opts: DartApplication
1301
1361
  `/// a repository or parallel application state.`,
1302
1362
  `final class ${collection} {`,
1303
1363
  ` final ${client} _client;`,
1304
- ` const ${collection}._(this._client);`,
1364
+ ` ${collection}._(this._client);`,
1305
1365
  ``,
1306
1366
  ...members,
1307
1367
  `}`,
package/src/codegen_ts.ts CHANGED
@@ -598,7 +598,7 @@ function tsTypeOfVoField(f: ValueObjectFieldDescriptor): string {
598
598
  switch (f.type) {
599
599
  case "string":
600
600
  return f.idOf !== undefined
601
- ? `NomosRef<${f.idOf}>`
601
+ ? `NomosRef<${f.idOfContract === true ? contractTypeName(f.idOf) : f.idOf}>`
602
602
  : f.brand ?? "string"; // a brandedId(...) → the emitted branded alias
603
603
  case "number":
604
604
  return "number"; // `.int()`/units are Dart-side nominal products; TS keeps one number
@@ -152,6 +152,11 @@ import {
152
152
  type InferredRename,
153
153
  type StableIds,
154
154
  } from "./stable_ids.js";
155
+ import {
156
+ parseStableIdLockfile,
157
+ serializeStableIdLockfile,
158
+ type StableIdLockfile,
159
+ } from "./stable_id_lockfile.js";
155
160
  import { emitUsd, type UsdLayer } from "./usd.js";
156
161
  import { emitLayeredRelationUsda, type RelationLayerInput } from "./relation_composition.js";
157
162
  import { assertNoDartBarrelCollisions, type BarrelExportedFile } from "./dart_barrel_guard.js";
@@ -3053,20 +3058,16 @@ async function main(): Promise<void> {
3053
3058
  // at first appearance (a leftover disappearance is THE EVOLVE GATE's typed question
3054
3059
  // at deploy — answered in the upgrade intent's disposition). The resolved continuity
3055
3060
  // is attached to the module so the canonical manifest + USD-IR carry it.
3056
- interface StableIdLock {
3057
- v: 1;
3058
- domains: Record<string, { stableIds: StableIds; schemas: Record<string, WireSchema> }>;
3059
- }
3060
3061
  const lockPath = path.join(cfgDir, "nomos.stable-ids.json");
3061
- const priorLock: StableIdLock | undefined = existsSync(lockPath)
3062
- ? (JSON.parse(readFileSync(lockPath, "utf8")) as StableIdLock)
3062
+ const priorLock: StableIdLockfile | undefined = existsSync(lockPath)
3063
+ ? parseStableIdLockfile(JSON.parse(readFileSync(lockPath, "utf8")))
3063
3064
  : undefined;
3064
3065
  const inferredRenames: { domain: string; renames: InferredRename[] }[] = [];
3065
3066
  // The lock is project-wide, not package-local. Several package configs commonly live beside one
3066
3067
  // application stage; compiling one must not erase the stable identity of domains owned by its siblings.
3067
3068
  // Current package domains are replaced below, while absent domains remain only as continuity metadata
3068
3069
  // and never leak into this package's interface, law, clients or deployment.
3069
- const nextLock: StableIdLock = { v: 1, domains: { ...(priorLock?.domains ?? {}) } };
3070
+ const nextLock: StableIdLockfile = { v: 1, domains: { ...(priorLock?.domains ?? {}) } };
3070
3071
  for (const mod of domainModules) {
3071
3072
  const identityName = mod.name;
3072
3073
  // The CURRENT canonical aggregate schemas (a pre-continuity manifest pass —
@@ -3165,7 +3166,7 @@ async function main(): Promise<void> {
3165
3166
  // identity across clean clones). Existing project-domain order is retained, so once sibling
3166
3167
  // packages are registered, recompiling them in any order rewrites the same bytes without a
3167
3168
  // giant formatting-only diff in an established lock.
3168
- writeFileSync(lockPath, JSON.stringify(nextLock, null, 2) + "\n", "utf8");
3169
+ writeFileSync(lockPath, serializeStableIdLockfile(nextLock), "utf8");
3169
3170
 
3170
3171
  // ── THE STRUCTURAL INTERFACE (#72) — directives (payload shapes) + aggregates (read
3171
3172
  // shapes, sid-keyed), keyed by DOMAIN. The SAME object is (a) baked into the generated
@@ -34,6 +34,8 @@ import type { Finding } from "./law_check.js";
34
34
  import type { DirectivePayloadShape, DomainInterface } from "./shape_compat.js";
35
35
  import { domainInterface } from "./shape_compat.js";
36
36
  import type { StableIds } from "./stable_ids_types.js";
37
+ import type { StableIdLockfile } from "./stable_id_lockfile.js";
38
+ export type { StableIdLockfile } from "./stable_id_lockfile.js";
37
39
  import type { DomainModule } from "./codegen_dart.js";
38
40
  import type { WireSchema } from "./wire.js";
39
41
  import { parseUsdaDocument } from "./usd_layers.js";
@@ -92,7 +94,7 @@ function aggregatesFromStableIds(
92
94
  for (const [aggLabel, a] of Object.entries(stableIds.aggregates)) {
93
95
  const fields: EvolveAggregate["fields"] = {};
94
96
  for (const [fLabel, f] of Object.entries(a.fields ?? {})) {
95
- const driver = schemas?.[aggLabel]?.[fLabel];
97
+ const driver = f.driver ?? schemas?.[aggLabel]?.[fLabel];
96
98
  fields[f.sid] = {
97
99
  sid: f.sid,
98
100
  label: fLabel,
@@ -130,12 +132,6 @@ function evolveDomainFromInterface(iface: DomainInterface): EvolveDomain {
130
132
  };
131
133
  }
132
134
 
133
- /** The committed `nomos.stable-ids.json` lockfile shape (compile_package_main writes it). */
134
- export interface StableIdLockfile {
135
- v: 1;
136
- domains: Record<string, { stableIds: StableIds; schemas?: Record<string, WireSchema> }>;
137
- }
138
-
139
135
  /** A deploy.json body (the subset this preview reads): the per-domain interface + the package
140
136
  * bytes (for the embedded stable-ids/driver schemas when no lockfile rides alongside). */
141
137
  export interface DeployJson {
@@ -254,13 +250,19 @@ export function currentEvolveLaw(
254
250
  const stableIds = locked?.stableIds;
255
251
  const iface = domainInterface(mod, stableIds);
256
252
  const dom = evolveDomainFromInterface(iface);
257
- // Fold drivers from the lockfile schemas (interface has none).
258
- if (locked?.schemas !== undefined) {
253
+ // Fold drivers from the stable fields. Verbose v1 locks may also carry the
254
+ // older aggregate-schema copy; compact v2 locks deliberately omit it.
255
+ if (stableIds !== undefined) {
259
256
  for (const agg of Object.values(dom.aggregates)) {
260
- const schema = locked.schemas[agg.label];
261
- if (schema === undefined) continue;
257
+ const stableAggregate = Object.values(stableIds.aggregates).find(
258
+ (candidate) => candidate.sid === agg.sid,
259
+ );
260
+ const schema = locked?.schemas?.[agg.label];
262
261
  for (const f of Object.values(agg.fields)) {
263
- const driver = schema[f.label];
262
+ const stableField = Object.values(stableAggregate?.fields ?? {}).find(
263
+ (candidate) => candidate.sid === f.sid,
264
+ );
265
+ const driver = stableField?.driver ?? schema?.[f.label];
264
266
  if (driver !== undefined) (f as { driver?: string }).driver = JSON.stringify(driver);
265
267
  }
266
268
  }
package/src/fields.ts CHANGED
@@ -171,7 +171,14 @@ export type ValueObjectFieldDescriptor =
171
171
  /** A plain string, OR a BRANDED nominal string when `brand` is present (a `brandedId(...)`
172
172
  * — the generated TS branded alias / Dart extension-type wrapper's name). Wire-identical
173
173
  * to a plain string; the brand is a compile-time nominal tag only. */
174
- | { readonly type: "string"; readonly brand?: string; readonly idOf?: string; readonly optional?: true }
174
+ | {
175
+ readonly type: "string";
176
+ readonly brand?: string;
177
+ readonly idOf?: string;
178
+ /** Present only for an `idOf(recordContract(...))` target. Aggregate is the default. */
179
+ readonly idOfContract?: true;
180
+ readonly optional?: true;
181
+ }
175
182
  /** `int: true` when the zod carried `.int()` (Dart `int`, TS `number`); `unit` when the field
176
183
  * was declared through `unit(...)` — the generated Dart extension-type wrapper's name. */
177
184
  | { readonly type: "number"; readonly int?: true; readonly unit?: string; readonly optional?: true }
package/src/lsp_server.ts CHANGED
@@ -49,6 +49,7 @@ import {
49
49
  type EvolveLaw,
50
50
  type StableIdLockfile,
51
51
  } from "./evolve_preview.js";
52
+ import { parseStableIdLockfile } from "./stable_id_lockfile.js";
52
53
 
53
54
  interface DomainConfig {
54
55
  key: string;
@@ -192,7 +193,7 @@ function loadPriorEvolveLaw(arg: string, cfgDir: string): EvolveLaw | undefined
192
193
  }
193
194
  const p = parsed as Record<string, unknown>;
194
195
  if (p.domains !== undefined && p.interface === undefined && p.packageUsda === undefined) {
195
- return evolveLawFromLockfile(parsed as StableIdLockfile);
196
+ return evolveLawFromLockfile(parseStableIdLockfile(parsed));
196
197
  }
197
198
  return evolveLawFromDeployJson(parsed as never);
198
199
  }
@@ -202,7 +203,7 @@ function loadCwdLockfile(cfgDir: string): StableIdLockfile | undefined {
202
203
  const lockPath = path.join(cfgDir, "nomos.stable-ids.json");
203
204
  if (!existsSync(lockPath)) return undefined;
204
205
  try {
205
- return JSON.parse(readFileSync(lockPath, "utf8")) as StableIdLockfile;
206
+ return parseStableIdLockfile(JSON.parse(readFileSync(lockPath, "utf8")));
206
207
  } catch {
207
208
  return undefined;
208
209
  }
@@ -0,0 +1,144 @@
1
+ import type { StableAggregateId, StableFieldId, StableIds } from "./stable_ids_types.js";
2
+ import type { WireSchema } from "./wire.js";
3
+
4
+ export interface StableIdLockDomain {
5
+ stableIds: StableIds;
6
+ schemas?: Record<string, WireSchema>;
7
+ }
8
+
9
+ /** The materialized lock shape consumed inside the compiler and authoring tools. */
10
+ export interface StableIdLockfile {
11
+ v: 1;
12
+ domains: Record<string, StableIdLockDomain>;
13
+ }
14
+
15
+ type CompactStableFieldId = Omit<StableFieldId, "contract" | "shape"> & {
16
+ contract?: string | number;
17
+ };
18
+
19
+ interface CompactStableAggregateId extends Omit<StableAggregateId, "fields"> {
20
+ fields: Record<string, CompactStableFieldId>;
21
+ }
22
+
23
+ interface CompactStableIds extends Omit<StableIds, "aggregates"> {
24
+ aggregates: Record<string, CompactStableAggregateId>;
25
+ }
26
+
27
+ interface CompactStableIdLockfile {
28
+ v: 2;
29
+ /** [contract hash, canonical contract descriptor], addressed by field.contract. */
30
+ contracts: readonly (readonly [string, unknown])[];
31
+ domains: Record<string, { stableIds: CompactStableIds }>;
32
+ }
33
+
34
+ function isRecord(value: unknown): value is Record<string, unknown> {
35
+ return typeof value === "object" && value !== null && !Array.isArray(value);
36
+ }
37
+
38
+ function sortedJson(value: unknown): unknown {
39
+ if (Array.isArray(value)) return value.map(sortedJson);
40
+ if (!isRecord(value)) return value;
41
+ return Object.fromEntries(
42
+ Object.keys(value)
43
+ .sort()
44
+ .map((key) => [key, sortedJson(value[key])]),
45
+ );
46
+ }
47
+
48
+ /**
49
+ * Read both the original verbose lock and the compact v2 wire shape. Callers always
50
+ * receive the same fully materialized v1 object, so continuity and evolution logic
51
+ * never needs to know how the durable file was encoded.
52
+ */
53
+ export function parseStableIdLockfile(value: unknown): StableIdLockfile {
54
+ if (!isRecord(value)) throw new Error("stable-id lock must be a JSON object");
55
+ if (value.v === 1) return value as unknown as StableIdLockfile;
56
+ if (value.v !== 2 || !Array.isArray(value.contracts) || !isRecord(value.domains)) {
57
+ throw new Error("unsupported stable-id lock encoding");
58
+ }
59
+
60
+ const compact = value as unknown as CompactStableIdLockfile;
61
+ const contracts = compact.contracts.map((entry, index) => {
62
+ if (!Array.isArray(entry) || entry.length !== 2 || typeof entry[0] !== "string") {
63
+ throw new Error(`stable-id lock contract ${index} is malformed`);
64
+ }
65
+ return entry;
66
+ });
67
+ const domains: StableIdLockfile["domains"] = {};
68
+
69
+ for (const [domainName, domain] of Object.entries(compact.domains)) {
70
+ const aggregates: StableIds["aggregates"] = {};
71
+ for (const [aggregateName, aggregate] of Object.entries(domain.stableIds.aggregates ?? {})) {
72
+ const fields: StableAggregateId["fields"] = {};
73
+ for (const [fieldName, field] of Object.entries(aggregate.fields ?? {})) {
74
+ const contractRef = field.contract;
75
+ const { contract: _contractRef, ...fieldWithoutContract } = field;
76
+ if (typeof contractRef === "number") {
77
+ const contract = contracts[contractRef];
78
+ if (contract === undefined) {
79
+ throw new Error(
80
+ `stable-id lock field ${domainName}.${aggregateName}.${fieldName} references missing contract ${contractRef}`,
81
+ );
82
+ }
83
+ fields[fieldName] = {
84
+ ...fieldWithoutContract,
85
+ contract: contract[0],
86
+ shape: contract[1],
87
+ };
88
+ } else if (typeof contractRef === "string") {
89
+ fields[fieldName] = { ...fieldWithoutContract, contract: contractRef };
90
+ } else {
91
+ fields[fieldName] = fieldWithoutContract;
92
+ }
93
+ }
94
+ aggregates[aggregateName] = { ...aggregate, fields };
95
+ }
96
+ domains[domainName] = {
97
+ stableIds: { ...domain.stableIds, aggregates },
98
+ };
99
+ }
100
+
101
+ return { v: 1, domains };
102
+ }
103
+
104
+ /**
105
+ * Encode the committed lock without repeating identical contract descriptors or
106
+ * aggregate driver schemas. Fields already retain their canonical merge driver;
107
+ * the contract table retains the full descriptor needed to prove safe widening.
108
+ */
109
+ export function serializeStableIdLockfile(lock: StableIdLockfile): string {
110
+ const contracts: [string, unknown][] = [];
111
+ const contractIndexes = new Map<string, number>();
112
+ const domains: CompactStableIdLockfile["domains"] = {};
113
+
114
+ for (const [domainName, domain] of Object.entries(lock.domains)) {
115
+ const aggregates: CompactStableIds["aggregates"] = {};
116
+ for (const [aggregateName, aggregate] of Object.entries(domain.stableIds.aggregates)) {
117
+ const fields: CompactStableAggregateId["fields"] = {};
118
+ for (const [fieldName, field] of Object.entries(aggregate.fields)) {
119
+ const { shape, ...withoutShape } = field;
120
+ if (field.contract !== undefined && shape !== undefined) {
121
+ let index = contractIndexes.get(field.contract);
122
+ if (index === undefined) {
123
+ index = contracts.length;
124
+ contracts.push([field.contract, shape]);
125
+ contractIndexes.set(field.contract, index);
126
+ }
127
+ fields[fieldName] = { ...withoutShape, contract: index };
128
+ } else {
129
+ fields[fieldName] = withoutShape;
130
+ }
131
+ }
132
+ aggregates[aggregateName] = { ...aggregate, fields };
133
+ }
134
+ domains[domainName] = {
135
+ stableIds: { ...domain.stableIds, aggregates },
136
+ };
137
+ }
138
+
139
+ // This is a machine-owned lockfile. Compact JSON is intentional: unlike generated
140
+ // law and clients, it must be committed so identity survives a clean clone.
141
+ return JSON.stringify(
142
+ sortedJson({ v: 2, contracts, domains } satisfies CompactStableIdLockfile),
143
+ ) + "\n";
144
+ }
package/src/stable_ids.ts CHANGED
@@ -233,9 +233,14 @@ export function deriveStableIdContinuity(
233
233
  * drivers). Returns `undefined` (pair allowed on name-cardinality alone) when no
234
234
  * snapshot is available. */
235
235
  function priorFieldDriver(
236
- resolved: { priorSchema?: WireSchema } | undefined,
236
+ resolved: {
237
+ priorFields?: Record<string, StableFieldId>;
238
+ priorSchema?: WireSchema;
239
+ } | undefined,
237
240
  oldField: string,
238
241
  ): string | undefined {
242
+ const retained = resolved?.priorFields?.[oldField]?.driver;
243
+ if (retained !== undefined) return JSON.stringify(retained);
239
244
  const schema = resolved?.priorSchema;
240
245
  if (schema === undefined) return undefined;
241
246
  const d = schema[oldField];
@@ -396,12 +396,17 @@ function describeZod(
396
396
  switch (zodKind(schema)) {
397
397
  case "string": {
398
398
  const brandedBrand = (schema as unknown as Record<symbol, unknown>)[NOMOS_BRANDED_ID_BRAND];
399
- const idOfAgg = (schema as unknown as Record<symbol, unknown>)[NOMOS_IDOF_BRAND];
399
+ const idOfTarget = idOfMetaOf(schema);
400
400
  // idOf(Agg) REFERENCES the aggregate's canonical `NomosRef<Agg>`. Carry `idOf` in the stable
401
401
  // descriptor so every emitter can recover the aggregate type while the wire remains a string.
402
402
  // A standalone `brandedId("X")` remains an unrelated nominal string and declares its own wrapper.
403
- if (typeof idOfAgg === "string") {
404
- return { type: "string", brand: `${idOfAgg}Id`, idOf: idOfAgg };
403
+ if (idOfTarget !== undefined) {
404
+ return {
405
+ type: "string",
406
+ brand: `${idOfTarget}Id`,
407
+ idOf: idOfTarget,
408
+ ...(idOfTargetKindOf(schema) === "contract" ? { idOfContract: true as const } : {}),
409
+ };
405
410
  }
406
411
  return typeof brandedBrand === "string" ? { type: "string", brand: brandedBrand } : { type: "string" };
407
412
  }