@githolon/dsl 0.70.2 → 0.72.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.
@@ -491,6 +491,14 @@ interface AggFieldSpec {
491
491
  nullAdmits?: boolean;
492
492
  /** The law-documentation `reason` for a null-admitting field (surfaces in the Dart doc comment). */
493
493
  nullReason?: string;
494
+ /**
495
+ * SURFACED MERGE CONFLICTS (architecture/surfaced_merge_conflict.md): the field is declared
496
+ * `.merge(Surfaced(...))`, so its read value is the base scalar OR the pending-conflict
497
+ * envelope — typed `SurfacedValue<T>?` (T = the base {@link dartType}) and decoded via
498
+ * `SurfacedValue.decode`. The FIRST driver-conditional generated typing; absent for every
499
+ * other field (byte-identical to a pre-Surfaced compile).
500
+ */
501
+ surfaced?: true;
494
502
  }
495
503
 
496
504
  export interface DartImport {
@@ -546,7 +554,7 @@ function readModelNullableSuffix(s: AggFieldSpec): string {
546
554
  return s.optional ? "?" : "";
547
555
  }
548
556
 
549
- function dartMemberName(raw: string): string {
557
+ export function dartMemberName(raw: string): string {
550
558
  const parts = raw.split(/[^A-Za-z0-9]+/).filter((p) => p !== "");
551
559
  const camel =
552
560
  parts.length === 0
@@ -748,7 +756,7 @@ function aggFieldSpec(
748
756
  // `contracts.dart`); the aggregate class is `<ContractId>` and must not collide, hence the suffix.
749
757
  const target = field.foreignRef.targetIsContract === true
750
758
  ? `${field.foreignRef.targetAggregateId}Contract`
751
- : field.foreignRef.targetAggregateId;
759
+ : concreteClassName(field.foreignRef.targetAggregateId);
752
760
  return {
753
761
  name,
754
762
  wireName,
@@ -868,11 +876,19 @@ function aggregateFieldSpecs(
868
876
  : f.isFromBirth === true
869
877
  ? { ...base, optional: false }
870
878
  : { ...base, optional: true };
879
+ // SURFACED MERGE CONFLICTS (architecture/surfaced_merge_conflict.md): a `.merge(Surfaced(...))`
880
+ // field types `SurfacedValue<T>?` and decodes via `SurfacedValue.decode`. ALWAYS nullable —
881
+ // even `.fromBirth()` (the wrapper's pending arm has no non-null plain-scalar reading).
882
+ // aggregate() already v1-scoped the driver to Lww-class scalar kinds; excludes nullAdmits.
883
+ const surfacedSpec: AggFieldSpec =
884
+ (f.driver as { kind?: string } | undefined)?.kind === "Surfaced"
885
+ ? { ...baseSpec, optional: true, surfaced: true }
886
+ : baseSpec;
871
887
  // NULL SEMANTICS (null_semantics.md): a null-admitting scalar carries the tri-state slot.
872
888
  const nmeta = nullMetaOf(f);
873
889
  const spec: AggFieldSpec = admitsNull(f)
874
- ? { ...baseSpec, nullAdmits: true, ...(nmeta.nullReason !== undefined ? { nullReason: nmeta.nullReason } : {}) }
875
- : baseSpec;
890
+ ? { ...surfacedSpec, nullAdmits: true, ...(nmeta.nullReason !== undefined ? { nullReason: nmeta.nullReason } : {}) }
891
+ : surfacedSpec;
876
892
  if (used.has(spec.name)) {
877
893
  throw new Error(
878
894
  `aggregate '${agg.id}' field '${spec.wireName}' maps to duplicate Dart field '${spec.name}'`,
@@ -901,11 +917,65 @@ export function cap(s: string): string {
901
917
  // `TrackableThing → TrackableThingId`, `BuildingAggregate → BuildingId`. The
902
918
  // `RootAggregate` strip BEFORE `Aggregate` keeps the proven `SiteId` byte-identical.
903
919
 
920
+ // ---- Dart product-name registry (`.dartName`, architecture/dart_naming.md) ----
921
+ // A wire id → declared Dart base name map. When an aggregate declared `.dartName("Site")`,
922
+ // EVERY Dart naming derivation (concrete class, `<stem>ReadModel`, `<stem>Id`,
923
+ // `read/watch<Name>ById`, `ForeignRef<Target>`) consults this instead of stemming the wire id.
924
+ // Populated per-domain by `registerDartNames` at each Dart-emit entry point. NON-law, Dart-only
925
+ // (TS codegen never reads it); empty ⇒ every derivation is byte-identical to before this existed.
926
+ const dartNameByWireId = new Map<string, string>();
927
+
928
+ /** Register (or reset) the `.dartName` overrides for a set of aggregates before Dart emit. An
929
+ * aggregate WITHOUT a `.dartName` deletes any stale entry for its wire id (test hygiene across
930
+ * compiles in one process), so a name only ever reflects the aggregate's OWN current declaration. */
931
+ export function registerDartNames(aggregates: readonly AggregateHandle[]): void {
932
+ for (const a of aggregates) {
933
+ if (a.dartClassName !== undefined) dartNameByWireId.set(a.id, a.dartClassName);
934
+ else dartNameByWireId.delete(a.id);
935
+ }
936
+ // COLLISION GUARD (dart_naming.md): a `.dartName` must not collide with another aggregate's declared
937
+ // dartName OR its default id-derived concrete class — that would emit two Dart classes of the same
938
+ // name (a fail-closed compile error, not a silent last-wins). Checked over the concrete-class tier
939
+ // (the aggregate-state name); the ReadModel/Id tiers derive from it, so one check covers all tiers.
940
+ const byClass = new Map<string, string>(); // concrete class → wire id that produced it
941
+ for (const a of aggregates) {
942
+ const cls = dartNameByWireId.get(a.id) ?? a.id;
943
+ const prior = byClass.get(cls);
944
+ if (prior !== undefined && prior !== a.id) {
945
+ const how = (id: string) => (dartNameByWireId.get(id) !== undefined ? `.dartName("${cls}")` : `its wire id`);
946
+ throw new Error(
947
+ `dart naming collision: aggregate '${a.id}' (${how(a.id)}) and aggregate '${prior}' (${how(prior)}) ` +
948
+ `both generate the Dart class name '${cls}' — pick a distinct .dartName() for one of them`,
949
+ );
950
+ }
951
+ byClass.set(cls, a.id);
952
+ }
953
+ }
954
+
955
+ /** The concrete aggregate class name (aggregate STATE tier): the declared `.dartName` or the wire id
956
+ * (already PascalCase by convention). */
957
+ export function concreteClassName(aggregateType: string): string {
958
+ return dartNameByWireId.get(aggregateType) ?? aggregateType;
959
+ }
960
+
961
+ /** The Dart naming STEM for an aggregate wire type: the declared `.dartName` (used verbatim), else the
962
+ * wire id with a trailing `RootAggregate`/`Aggregate` stripped (the historical id-derived stem). */
963
+ function dartNameStem(aggregateType: string): string {
964
+ const declared = dartNameByWireId.get(aggregateType);
965
+ if (declared !== undefined) return declared;
966
+ let stem = aggregateType;
967
+ if (stem.endsWith("RootAggregate")) stem = stem.slice(0, -"RootAggregate".length);
968
+ else if (stem.endsWith("Aggregate")) stem = stem.slice(0, -"Aggregate".length);
969
+ return cap(stem === "" ? aggregateType : stem);
970
+ }
971
+
904
972
  /** The typed-id class name for an aggregate WIRE TYPE (e.g. `SiteRootAggregate` →
905
973
  * `SiteId`). Pure + deterministic: the SAME wire type ALWAYS yields the SAME name,
906
974
  * so the id-class decl, the `createX` factory return, and every typed ref field that
907
- * points at this type all agree. */
908
- function typedIdClassForAggregateType(aggregateType: string): string {
975
+ * points at this type all agree. Honours `.dartName` (`Site` → `SiteId`). */
976
+ export function typedIdClassForAggregateType(aggregateType: string): string {
977
+ const declared = dartNameByWireId.get(aggregateType);
978
+ if (declared !== undefined) return `${declared}Id`;
909
979
  let stem = aggregateType;
910
980
  if (stem.endsWith("RootAggregate")) {
911
981
  stem = stem.slice(0, -"RootAggregate".length);
@@ -1171,7 +1241,7 @@ function emitAggregate(
1171
1241
  refIdClassForAggregate: (aggregateType: string) => string = typedIdClassForAggregateType,
1172
1242
  ownIdFields: ReadonlySet<string> = new Set(),
1173
1243
  ): string {
1174
- const className = agg.id; // wire id is already PascalCase by convention.
1244
+ const className = concreteClassName(agg.id); // wire id (already PascalCase) unless `.dartName` overrides.
1175
1245
  const idType = aggregateIdDartType(agg.id);
1176
1246
  const specs = aggregateFieldSpecs(
1177
1247
  agg,
@@ -1203,6 +1273,14 @@ function emitAggregate(
1203
1273
  ` final NomosRef ref;`,
1204
1274
  ...specs.flatMap((s) => {
1205
1275
  const doc = s.nullReason !== undefined ? [` /// null semantic: ${s.nullReason}`] : [];
1276
+ // SURFACED MERGE CONFLICTS: the field reads as the plain scalar, or [SurfacedPending]
1277
+ // while genuinely concurrent writes await a human pick (resolve = an ordinary next write).
1278
+ if (s.surfaced === true) {
1279
+ return [
1280
+ ` /// Surfaced merge conflicts: [SurfacedResolved] steady state, [SurfacedPending] under conflict.`,
1281
+ ` final SurfacedValue<${s.dartType}>? ${s.name};`,
1282
+ ];
1283
+ }
1206
1284
  // NULL SEMANTICS: a null-admitting field is the sealed tri-state slot (Omitted / ExplicitNull /
1207
1285
  // Present) — omission and explicit-null never collapse. Others keep the plain `T?`.
1208
1286
  return s.nullAdmits === true
@@ -1236,9 +1314,11 @@ function emitAggregate(
1236
1314
  ` aggregateId: ${aggregateIdFromRowExpr(agg.id, "id")},`,
1237
1315
  ` ref: NomosRef(workspace: workspace, type: type.isEmpty ? '${agg.id}' : type, id: id),`,
1238
1316
  ...specs.map((s) =>
1239
- s.nullAdmits === true
1240
- ? ` ${s.name}: NomosSlot.decode<${stripNullable(s.dartType)}>(data, '${s.wireName}', ${slotDecodeCallback(s, "aggregate")}),`
1241
- : ` ${s.name}: ${parseField(s, className)},`,
1317
+ s.surfaced === true
1318
+ ? ` ${s.name}: SurfacedValue.decode<${s.dartType}>(data, '${s.wireName}', ${slotDecodeCallback(s, "aggregate")}),`
1319
+ : s.nullAdmits === true
1320
+ ? ` ${s.name}: NomosSlot.decode<${stripNullable(s.dartType)}>(data, '${s.wireName}', ${slotDecodeCallback(s, "aggregate")}),`
1321
+ : ` ${s.name}: ${parseField(s, className)},`,
1242
1322
  ),
1243
1323
  ].join("\n");
1244
1324
 
@@ -1797,7 +1877,7 @@ function emitAggregateMetaRegistry(
1797
1877
  ` fields: {`,
1798
1878
  ...fieldLines,
1799
1879
  ` },`,
1800
- ` fromRow: ${agg.id}.fromJson,`,
1880
+ ` fromRow: ${concreteClassName(agg.id)}.fromJson,`,
1801
1881
  ` ),`,
1802
1882
  ].join("\n");
1803
1883
  });
@@ -2367,13 +2447,7 @@ function stripNullable(t: string): string {
2367
2447
  * `BuildingReadModel`, `ThingReadModel`, `TrackableThingReadModel`). Pure + deterministic
2368
2448
  * — the same wire type always yields the same name. */
2369
2449
  export function readModelClassName(aggregateType: string): string {
2370
- let stem = aggregateType;
2371
- if (stem.endsWith("RootAggregate")) {
2372
- stem = stem.slice(0, -"RootAggregate".length);
2373
- } else if (stem.endsWith("Aggregate")) {
2374
- stem = stem.slice(0, -"Aggregate".length);
2375
- }
2376
- return `${cap(stem === "" ? aggregateType : stem)}ReadModel`;
2450
+ return `${dartNameStem(aggregateType)}ReadModel`;
2377
2451
  }
2378
2452
 
2379
2453
  /** The Dart type for one generated read-model field. */
@@ -2560,6 +2634,13 @@ function emitReadModel(
2560
2634
  ` final NomosRef ref;`,
2561
2635
  ...specs.flatMap((s) => {
2562
2636
  const doc = s.nullReason !== undefined ? [` /// null semantic: ${s.nullReason}`] : [];
2637
+ // SURFACED MERGE CONFLICTS: pending observations surface typed; resolve = ordinary next write.
2638
+ if (s.surfaced === true) {
2639
+ return [
2640
+ ` /// Surfaced merge conflicts: [SurfacedResolved] steady state, [SurfacedPending] under conflict.`,
2641
+ ` final SurfacedValue<${readModelDartType(s)}>? ${s.name};`,
2642
+ ];
2643
+ }
2563
2644
  return s.nullAdmits === true
2564
2645
  ? [...doc, ` final NomosSlot<${stripNullable(readModelDartType(s))}> ${s.name};`]
2565
2646
  : [` final ${readModelDartType(s)}${readModelNullableSuffix(s)} ${s.name};`];
@@ -2591,13 +2672,39 @@ function emitReadModel(
2591
2672
  ...combineds.map((c) => ` this.${c.id},`),
2592
2673
  ].join("\n");
2593
2674
 
2675
+ // SURFACED MERGE CONFLICTS: per-Surfaced-field convenience accessors (the pending seam an app
2676
+ // branches on without hand-parsing) — an EXTENSION on the read model, mirroring the ref-traversal
2677
+ // accessor pattern (codegen_dart_client.ts P2). They live HERE (the pure types file), not on the
2678
+ // client: they are pure field reads needing no bridge, and in layered-packages mode the types
2679
+ // package must not depend on the client. A Surfaced-free aggregate emits none (byte-identical).
2680
+ const surfacedSpecs = specs.filter((s) => s.surfaced === true);
2681
+ const surfacedExtension =
2682
+ surfacedSpecs.length === 0
2683
+ ? []
2684
+ : [
2685
+ ``,
2686
+ `/// Pending-conflict accessors for [${className}] (surfaced_merge_conflict.md) — branch on`,
2687
+ `/// pending-ness without hand-parsing; the UI decides what to do (resolution is an ordinary`,
2688
+ `/// next write via the domain's own directive).`,
2689
+ `extension ${className}Surfaced on ${className} {`,
2690
+ ...surfacedSpecs.flatMap((s) => [
2691
+ ` /// True while \`${s.wireName}\` awaits a human resolution (competing observations retained).`,
2692
+ ` bool get has${cap(s.name)}PendingConflict => ${s.name}?.isPending ?? false;`,
2693
+ ` /// The competing observations on \`${s.wireName}\` while pending; empty otherwise.`,
2694
+ ` List<SurfacedObservation<${readModelDartType(s)}>> get ${s.name}PendingObservations => ${s.name}?.pending ?? const [];`,
2695
+ ]),
2696
+ `}`,
2697
+ ];
2698
+
2594
2699
  const parseLines = [
2595
2700
  ` aggregateId: ${aggregateIdFromRowExpr(agg.id, "id")},`,
2596
2701
  ` ref: NomosRef(workspace: workspace, type: type.isEmpty ? '${agg.id}' : type, id: id),`,
2597
2702
  ...specs.map((s) =>
2598
- s.nullAdmits === true
2599
- ? ` ${s.name}: NomosSlot.decode<${stripNullable(readModelDartType(s))}>(data, '${s.wireName}', ${slotDecodeCallback(s, "readModel")}),`
2600
- : ` ${s.name}: ${parseReadModelField(s, wireType)},`,
2703
+ s.surfaced === true
2704
+ ? ` ${s.name}: SurfacedValue.decode<${readModelDartType(s)}>(data, '${s.wireName}', ${slotDecodeCallback(s, "readModel")}),`
2705
+ : s.nullAdmits === true
2706
+ ? ` ${s.name}: NomosSlot.decode<${stripNullable(readModelDartType(s))}>(data, '${s.wireName}', ${slotDecodeCallback(s, "readModel")}),`
2707
+ : ` ${s.name}: ${parseReadModelField(s, wireType)},`,
2601
2708
  ),
2602
2709
  // Derived/combined fields decode from the engine-produced bare JSON leaf.
2603
2710
  ...deriveds.map((d) => ` ${d.id}: ${parseProjectionReturnField(d.id, d.returns)},`),
@@ -2636,6 +2743,7 @@ function emitReadModel(
2636
2743
  ` );`,
2637
2744
  ` }`,
2638
2745
  `}`,
2746
+ ...surfacedExtension,
2639
2747
  ].join("\n");
2640
2748
  }
2641
2749
 
@@ -2968,7 +3076,8 @@ function emitQueryReads(
2968
3076
  refIdClassForAggregate: (aggregateType: string) => string = typedIdClassForAggregateType,
2969
3077
  ownIdFieldsByAggregate: ReadonlyMap<string, ReadonlySet<string>> = new Map(),
2970
3078
  ): string {
2971
- const agg = q.returns; // the aggregate TYPE id (== the generated class name).
3079
+ const agg = q.returns; // the aggregate TYPE id (== the wire id, used for field/index lookups).
3080
+ const aggClass = concreteClassName(agg); // the generated Dart class name (honours `.dartName`).
2972
3081
  const method = readMethodName(q.id);
2973
3082
  const Method = cap(method);
2974
3083
  // The query registry keys every query on EXACTLY ONE string field; take it as
@@ -2995,17 +3104,17 @@ function emitQueryReads(
2995
3104
  const keyParam = `${keySpec.dartType} ${paramName}`;
2996
3105
  const words = q.id.replace(/([a-z0-9])([A-Z])/g, "$1 $2").toLowerCase();
2997
3106
  return [
2998
- `/// Reactive typed read: every \`${agg}\` matching the indexed \`${q.id}\` query`,
3107
+ `/// Reactive typed read: every \`${aggClass}\` matching the indexed \`${q.id}\` query`,
2999
3108
  `/// (${words}), keyed on \`${keyField}\`. Emits the current matches, then re-emits`,
3000
3109
  `/// on each projection tick. O(matches) via the projection index (#140) — never a`,
3001
3110
  `/// workspace scan.`,
3002
- `Stream<List<${agg}>> watch${Method}(NomosReads reads, ${keyParam}) =>`,
3003
- ` reads.watchQuery('${q.id}', {${params}}, ${agg}.fromJson);`,
3111
+ `Stream<List<${aggClass}>> watch${Method}(NomosReads reads, ${keyParam}) =>`,
3112
+ ` reads.watchQuery('${q.id}', {${params}}, ${aggClass}.fromJson);`,
3004
3113
  ``,
3005
3114
  `/// One-shot typed read of the \`${q.id}\` query (${words}): the CURRENT matching`,
3006
- `/// \`${agg}\` list, keyed on \`${keyField}\`. O(matches).`,
3007
- `List<${agg}> read${Method}(NomosReads reads, ${keyParam}) =>`,
3008
- ` reads.readQuery('${q.id}', {${params}}, ${agg}.fromJson);`,
3115
+ `/// \`${aggClass}\` list, keyed on \`${keyField}\`. O(matches).`,
3116
+ `List<${aggClass}> read${Method}(NomosReads reads, ${keyParam}) =>`,
3117
+ ` reads.readQuery('${q.id}', {${params}}, ${aggClass}.fromJson);`,
3009
3118
  ].join("\n");
3010
3119
  }
3011
3120
 
@@ -3014,7 +3123,7 @@ function emitQueryReads(
3014
3123
  * off the PK `query_by_id` seam (resolves the unbound singleton by its type-key AND
3015
3124
  * an instance-bound aggregate by its id). */
3016
3125
  function emitAggregateByIdReads(agg: AggregateHandle): string {
3017
- const cls = agg.id;
3126
+ const cls = concreteClassName(agg.id);
3018
3127
  const idType = aggregateIdDartType(agg.id);
3019
3128
  return [
3020
3129
  `/// Reactive typed by-id read: the \`${cls}\` with kernel aggregate id [id]`,
@@ -3318,7 +3427,7 @@ function emitExistsReads(
3318
3427
  * Returns projected typed aggregates (via `project_by_ids`, `lib.rs:1686`) — not raw ids.
3319
3428
  * Archived aggregates are auto-excluded (the `type_index` row is dropped on archive). */
3320
3429
  function emitOrderedReads(d: OrderedReadDecl): string {
3321
- const agg = d.of;
3430
+ const agg = concreteClassName(d.of); // the generated Dart class name (honours `.dartName`).
3322
3431
  const method = readMethodName(d.id);
3323
3432
  const Method = cap(method);
3324
3433
  const words = d.id.replace(/([a-z0-9])([A-Z])/g, "$1 $2").toLowerCase();
@@ -3708,6 +3817,14 @@ export interface DomainModule {
3708
3817
  * (TS + Dart). NOT law, NOT hashed (like {@link autoDeclaredEnsures}). Absent when no role is declared.
3709
3818
  */
3710
3819
  roles?: import("./role.js").RoleMeta[];
3820
+ /**
3821
+ * THE DESIGNATED SURFACED RESOLVERS (surfaced_lint.ts) — one binding per `.merge(Surfaced(...))`
3822
+ * field, produced (and fail-closed enforced: exactly one `.resolves()` per Surfaced field, every
3823
+ * Surfaced field covered) by `composeDomainModule`. Drives the generated typed `resolve…` helpers
3824
+ * (TS + Dart) and the `pendingConflicts` accessor. NOT law, NOT hashed (like {@link roles}).
3825
+ * Absent when the domain declares no Surfaced field (byte-identical — the era rule).
3826
+ */
3827
+ surfacedResolvers?: import("./surfaced_lint.js").SurfacedResolverBinding[];
3711
3828
  /**
3712
3829
  * STABLE-ID CONTINUITY (#58 — names are labels, identity is minted). When present
3713
3830
  * (attached by `nomos-compile` from the PRIOR build's identity manifests), the
@@ -3977,6 +4094,7 @@ export interface GenerateDartDomainOptions {
3977
4094
 
3978
4095
  /** Generate the full Dart source for one domain module. */
3979
4096
  export function generateDartDomain(mod: DomainModule, opts: GenerateDartDomainOptions = {}): string {
4097
+ registerDartNames(mod.aggregates); // `.dartName` overrides for this domain's Dart naming derivations.
3980
4098
  const domainKey = mod.domain ?? mod.name;
3981
4099
  const skipAggregateIds = opts.skipAggregateIds ?? new Set<string>();
3982
4100
  const skipDirectiveIds = opts.skipDirectiveIds ?? new Set<string>();
@@ -4075,7 +4193,13 @@ export function generateDartDomain(mod: DomainModule, opts: GenerateDartDomainOp
4075
4193
  // `jsonEncode` — the ONE case the generated file needs `dart:convert` (null_semantics.md).
4076
4194
  const needsJsonConvert = localAggregates.some((a) =>
4077
4195
  Object.values(a.fields as Record<string, Field>).some(
4078
- (f) => admitsNull(f) && f.kind === "json" && f.valueObject === undefined,
4196
+ (f) =>
4197
+ // A null-admitting plain-json slot re-stringifies via jsonEncode…
4198
+ (admitsNull(f) && f.kind === "json" && f.valueObject === undefined) ||
4199
+ // …and so does a Surfaced plain-json field's aggregate-class leaf decode.
4200
+ ((f.driver as { kind?: string } | undefined)?.kind === "Surfaced" &&
4201
+ f.kind === "json" &&
4202
+ f.valueObject === undefined),
4079
4203
  ),
4080
4204
  );
4081
4205
  const localDirectives = mod.directives.filter((d) => !skipDirectiveIds.has(d.id));