@githolon/dsl 0.88.0 → 0.90.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.
- package/dart/lib/src/dev_cloud.dart +1 -1
- package/dart/lib/src/test_harness.dart +245 -1
- package/dart/pubspec.yaml +1 -1
- package/dart/test/cloud_bound_topology_test.dart +140 -0
- package/package.json +1 -1
- package/src/codegen_dart.ts +31 -2
- package/src/codegen_proof.ts +65 -9
- package/src/compile_package_main.ts +131 -25
- package/src/fields.ts +1 -1
- package/src/value_object.ts +10 -10
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
//
|
|
6
6
|
// ```dart
|
|
7
7
|
// final cloud = await NomosTestCloud.start(serverEntry: devServerPath); // spawns the dev cloud + proxy
|
|
8
|
-
// final harness = await NomosTestHarness.
|
|
8
|
+
// final harness = await NomosTestHarness.cloudBound(cloud: cloud, deployJsonPaths: [...], platformWorkspace: 'co2-platform', subject: 'user:test');
|
|
9
9
|
// // ... author offline ...
|
|
10
10
|
// cloud.setOnline(false); // PARTITION — the up leg can't reach custody
|
|
11
11
|
// await harness.session('estate').sync(admit: true); // stays local; nothing lands remotely
|
|
@@ -33,6 +33,9 @@
|
|
|
33
33
|
import 'dart:convert';
|
|
34
34
|
import 'dart:io';
|
|
35
35
|
|
|
36
|
+
import 'package:crypto/crypto.dart' show sha256;
|
|
37
|
+
|
|
38
|
+
import 'dev_cloud.dart';
|
|
36
39
|
import 'node_runner_transport.dart';
|
|
37
40
|
import 'webview_bridge.dart' show NomosBridge, NomosSession;
|
|
38
41
|
|
|
@@ -147,6 +150,25 @@ class NomosTestHarness {
|
|
|
147
150
|
/// When set, overrides EVERY phase's default deadline (see [HarnessPhaseTimeout]).
|
|
148
151
|
final Duration? _phaseTimeout;
|
|
149
152
|
|
|
153
|
+
/// The test cloud a [cloudBound] harness runs against (null for the offline constructors). Partition it
|
|
154
|
+
/// with [NomosTestCloud.setOnline]; assert remote custody with [NomosTestCloud.remoteHead].
|
|
155
|
+
final NomosTestCloud? testCloud;
|
|
156
|
+
|
|
157
|
+
/// The HOME workspace a [cloudBound] harness mounted (born remotely by the platform ceremony). Null for
|
|
158
|
+
/// the offline constructors.
|
|
159
|
+
final String? homeWorkspace;
|
|
160
|
+
|
|
161
|
+
/// The mounted home as a [NomosSession] (the [cloudBound] app-shaped entry point — bind the generated
|
|
162
|
+
/// `HomeClient` to it). Throws for an offline harness (no home ceremony ran).
|
|
163
|
+
NomosSession get homeSession {
|
|
164
|
+
final ws = homeWorkspace;
|
|
165
|
+
if (ws == null) {
|
|
166
|
+
throw NomosTestHarnessException(
|
|
167
|
+
'homeSession is only available on a cloudBound harness (this one was built by ephemeral/cluster)');
|
|
168
|
+
}
|
|
169
|
+
return session(ws);
|
|
170
|
+
}
|
|
171
|
+
|
|
150
172
|
NomosTestHarness._(this.bridge, this.workspace, this._transport, this._workdir,
|
|
151
173
|
this._onDiagnostic, List<String> sessionNames,
|
|
152
174
|
{Map<String, String> devicePublics = const {},
|
|
@@ -154,7 +176,9 @@ class NomosTestHarness {
|
|
|
154
176
|
Map<String, List<String>> lawHashes = const {},
|
|
155
177
|
Map<String, String> snapshots = const {},
|
|
156
178
|
_InstallLane? installLane,
|
|
157
|
-
Duration? phaseTimeout
|
|
179
|
+
Duration? phaseTimeout,
|
|
180
|
+
this.testCloud,
|
|
181
|
+
this.homeWorkspace})
|
|
158
182
|
: _sessionNames = List.of(sessionNames), // GROWABLE — restoreWorkspace may add a fresh session
|
|
159
183
|
_devicePublics = devicePublics,
|
|
160
184
|
_lawHashes = Map.of(lawHashes),
|
|
@@ -613,6 +637,226 @@ class NomosTestHarness {
|
|
|
613
637
|
}
|
|
614
638
|
}
|
|
615
639
|
|
|
640
|
+
/// CLOUD-BOUND HARNESS (co2's real-app topology): a [NomosTestCloud] plays the remote control plane, the
|
|
641
|
+
/// REAL authenticated home-birth ceremony runs REMOTELY on [platformWorkspace], and the born home is
|
|
642
|
+
/// mounted into ONE local engine plane as a normal cloud-bound [NomosSession] ([homeSession]). From there
|
|
643
|
+
/// the app shape holds exactly: generated `.births()` directives run LOCALLY (the app never clones the
|
|
644
|
+
/// platform), children open as sibling sessions (`outcome.bornRefs.single.acquire(harness.bridge)`),
|
|
645
|
+
/// writes/projections work offline, and [converge] establishes remote custody parent-first when
|
|
646
|
+
/// connectivity returns ([NomosTestCloud.setOnline]). Entirely inside the test cloud's trust universe.
|
|
647
|
+
///
|
|
648
|
+
/// ```dart
|
|
649
|
+
/// final cloud = await NomosTestCloud.start(serverEntry: devServer);
|
|
650
|
+
/// final h = await NomosTestHarness.cloudBound(
|
|
651
|
+
/// cloud: cloud, deployJsonPaths: ['build/co2.deploy.json'],
|
|
652
|
+
/// platformWorkspace: 'co2-platform', subject: 'user:test');
|
|
653
|
+
/// final home = h.homeSession; // the app's entry point
|
|
654
|
+
/// cloud.setOnline(false); // PARTITION
|
|
655
|
+
/// // ... local births + writes ...
|
|
656
|
+
/// cloud.setOnline(true);
|
|
657
|
+
/// await h.converge(); // home first, then locally-born children
|
|
658
|
+
/// expect((await cloud.remoteHead(home.workspace)).initialized, isTrue);
|
|
659
|
+
/// ```
|
|
660
|
+
static Future<NomosTestHarness> cloudBound({
|
|
661
|
+
required NomosTestCloud cloud,
|
|
662
|
+
required List<String> deployJsonPaths,
|
|
663
|
+
String platformWorkspace = 'test-platform',
|
|
664
|
+
required String subject,
|
|
665
|
+
String? authToken,
|
|
666
|
+
String clientId = 'test-runner',
|
|
667
|
+
String? nodeEntry,
|
|
668
|
+
String? resolveFrom,
|
|
669
|
+
String node = 'node',
|
|
670
|
+
bool autoTearDown = true,
|
|
671
|
+
void Function(Future<void> Function())? registerTearDown,
|
|
672
|
+
void Function(String line)? onDiagnostic,
|
|
673
|
+
Duration readyTimeout = const Duration(seconds: 30),
|
|
674
|
+
Duration connectTimeout = const Duration(seconds: 120),
|
|
675
|
+
Duration? phaseTimeout,
|
|
676
|
+
bool decoySession = true,
|
|
677
|
+
}) async {
|
|
678
|
+
if (deployJsonPaths.isEmpty) {
|
|
679
|
+
throw NomosTestHarnessException(
|
|
680
|
+
'no deployJsonPaths — pass the build/<name>.deploy.json `npx githolon compile` emits');
|
|
681
|
+
}
|
|
682
|
+
final entry = nodeEntry ??
|
|
683
|
+
await _resolveEntry(node, resolveFrom ?? Directory.current.path);
|
|
684
|
+
final bareUid = subject.startsWith('user:') ? subject.substring(5) : subject;
|
|
685
|
+
final http = HttpClient();
|
|
686
|
+
Future<Map<String, Object?>> post(String path, Object? body,
|
|
687
|
+
{Map<String, String> headers = const {}}) async {
|
|
688
|
+
final req = await http.postUrl(Uri.parse('${cloud.origin}$path'));
|
|
689
|
+
req.headers.set('content-type', 'application/json');
|
|
690
|
+
req.headers.set('x-nomos-principal', bareUid);
|
|
691
|
+
if (authToken != null) req.headers.set('x-nomos-auth', authToken);
|
|
692
|
+
headers.forEach(req.headers.set);
|
|
693
|
+
if (body != null) req.add(utf8.encode(body is String ? body : jsonEncode(body)));
|
|
694
|
+
final resp = await req.close();
|
|
695
|
+
final text = await resp.transform(utf8.decoder).join();
|
|
696
|
+
final decoded = (jsonDecode(text) as Map).cast<String, Object?>();
|
|
697
|
+
if (resp.statusCode >= 300 || decoded['ok'] == false) {
|
|
698
|
+
throw NomosTestHarnessException(
|
|
699
|
+
'cloudBound setup: POST $path → ${resp.statusCode}: ${text.substring(0, text.length.clamp(0, 400))}');
|
|
700
|
+
}
|
|
701
|
+
return decoded;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
final workdir = await Directory.systemTemp.createTemp('nomos-harness-');
|
|
705
|
+
NodeRunnerTransport? transport;
|
|
706
|
+
try {
|
|
707
|
+
// 1/5 — the remote control plane: create the platform workspace + deploy the law under the test
|
|
708
|
+
// cloud's own trust universe (its K_root; nothing here touches production).
|
|
709
|
+
String? frameworkHash;
|
|
710
|
+
final lawHashes = <String>[];
|
|
711
|
+
await _phased(
|
|
712
|
+
phase: '1/5: create platform + deploy law on the test cloud',
|
|
713
|
+
workspace: platformWorkspace,
|
|
714
|
+
awaiting: 'POST /v2/workspaces + /domains',
|
|
715
|
+
timeout: phaseTimeout ?? _kInstallDeadline,
|
|
716
|
+
onDiagnostic: onDiagnostic,
|
|
717
|
+
run: () async {
|
|
718
|
+
final created = await post('/v2/workspaces/$platformWorkspace', null);
|
|
719
|
+
frameworkHash =
|
|
720
|
+
((created['controller'] as Map?)?['domainHash'])?.toString();
|
|
721
|
+
for (final p in deployJsonPaths) {
|
|
722
|
+
final body = await File(p).readAsString();
|
|
723
|
+
await post('/v2/workspaces/$platformWorkspace/domains', body);
|
|
724
|
+
final summary = ((jsonDecode(body) as Map)['summary'] as Map?);
|
|
725
|
+
final h = summary?['domainHash']?.toString();
|
|
726
|
+
if (h != null && h.isNotEmpty) lawHashes.add(h);
|
|
727
|
+
}
|
|
728
|
+
});
|
|
729
|
+
if (frameworkHash == null || frameworkHash!.isEmpty) {
|
|
730
|
+
throw NomosTestHarnessException(
|
|
731
|
+
'cloudBound: the test cloud returned no controller.domainHash — cannot run the birth ceremony (frameworkHash unknown)');
|
|
732
|
+
}
|
|
733
|
+
if (lawHashes.isEmpty) {
|
|
734
|
+
throw NomosTestHarnessException(
|
|
735
|
+
'cloudBound: no deploy.json carried a summary.domainHash — cannot run the birth ceremony (lawHash unknown)');
|
|
736
|
+
}
|
|
737
|
+
// 2/5 — THE HOME CEREMONY, remotely: the platform's own gateway offer births the home (the ONE
|
|
738
|
+
// control-plane contact; the app lane below never touches the platform again).
|
|
739
|
+
String? home;
|
|
740
|
+
await _phased(
|
|
741
|
+
phase: '2/5: remote home-birth ceremony',
|
|
742
|
+
workspace: platformWorkspace,
|
|
743
|
+
awaiting: 'POST /v2/workspaces/$platformWorkspace/birthHome',
|
|
744
|
+
timeout: phaseTimeout ?? _kInstallDeadline,
|
|
745
|
+
onDiagnostic: onDiagnostic,
|
|
746
|
+
run: () async {
|
|
747
|
+
final out = await post('/v2/workspaces/$platformWorkspace/birthHome', {
|
|
748
|
+
'owner': subject,
|
|
749
|
+
'frameworkHash': frameworkHash,
|
|
750
|
+
'lawHash': lawHashes.first,
|
|
751
|
+
'bornAt': DateTime.now().toUtc().toIso8601String(),
|
|
752
|
+
});
|
|
753
|
+
final born = (out['born'] as List?) ?? const [];
|
|
754
|
+
final bornNames = born.map((b) => (b as Map)['workspace']?.toString()).whereType<String>().where((w) => w.isNotEmpty).toList();
|
|
755
|
+
home = bornNames.isEmpty ? null : bornNames.first;
|
|
756
|
+
home ??= _homeNameOf(subject); // deterministic fallback (home-<sha256(subject)[..40]>)
|
|
757
|
+
});
|
|
758
|
+
// 3/5 — the LOCAL engine plane, CLOUD-BOUND (no offline env: the runner pulls the runtime + the
|
|
759
|
+
// home's custody from the test cloud like a real device).
|
|
760
|
+
transport = await _phased(
|
|
761
|
+
phase: '3/5: spawn the node runner (cloud-bound)',
|
|
762
|
+
awaiting: 'Process.start($node)',
|
|
763
|
+
timeout: phaseTimeout ?? _kSpawnDeadline,
|
|
764
|
+
onDiagnostic: onDiagnostic,
|
|
765
|
+
run: () => NodeRunnerTransport.start(
|
|
766
|
+
entry: entry, resolveFrom: resolveFrom, node: node, onDiagnostic: onDiagnostic));
|
|
767
|
+
final plane = NomosBridge(transport!);
|
|
768
|
+
await _phased(
|
|
769
|
+
phase: '4/5: runner ready',
|
|
770
|
+
awaiting: "event:'ready'",
|
|
771
|
+
timeout: phaseTimeout ?? readyTimeout,
|
|
772
|
+
onDiagnostic: onDiagnostic,
|
|
773
|
+
run: () => plane.ready);
|
|
774
|
+
// 5/5 — mount the born home as a normal cloud-bound session (clone from the test cloud).
|
|
775
|
+
await _phased(
|
|
776
|
+
phase: '5/5: mount the born home',
|
|
777
|
+
workspace: home,
|
|
778
|
+
awaiting: 'connect(cloud:, workspace: $home)',
|
|
779
|
+
timeout: phaseTimeout ?? connectTimeout,
|
|
780
|
+
onDiagnostic: onDiagnostic,
|
|
781
|
+
run: () => plane.connect(
|
|
782
|
+
cloud: cloud.origin,
|
|
783
|
+
workspace: home!,
|
|
784
|
+
clientId: clientId,
|
|
785
|
+
authToken: authToken,
|
|
786
|
+
));
|
|
787
|
+
// NO DECOY on a cloudBound harness: the app is genuinely multi-workspace (the home plus every
|
|
788
|
+
// locally-born child), so the plane is never sole-session — the decoy the offline harness
|
|
789
|
+
// needs (to break single-session inference) would just be a spurious local-only workspace here.
|
|
790
|
+
final _ = decoySession; // (accepted for signature parity; a cloudBound plane needs no decoy)
|
|
791
|
+
final harness = NomosTestHarness._(
|
|
792
|
+
plane.session(home!),
|
|
793
|
+
home!,
|
|
794
|
+
transport,
|
|
795
|
+
workdir,
|
|
796
|
+
onDiagnostic,
|
|
797
|
+
[home!],
|
|
798
|
+
frameworkHash: frameworkHash,
|
|
799
|
+
lawHashes: {home!: lawHashes},
|
|
800
|
+
phaseTimeout: phaseTimeout,
|
|
801
|
+
testCloud: cloud,
|
|
802
|
+
homeWorkspace: home,
|
|
803
|
+
);
|
|
804
|
+
if (autoTearDown && registerTearDown != null) {
|
|
805
|
+
registerTearDown(() => harness.dispose());
|
|
806
|
+
}
|
|
807
|
+
return harness;
|
|
808
|
+
} catch (e) {
|
|
809
|
+
try { transport?.dispose(); } catch (_) {}
|
|
810
|
+
try { await workdir.delete(recursive: true); } catch (_) {}
|
|
811
|
+
rethrow;
|
|
812
|
+
} finally {
|
|
813
|
+
http.close(force: true);
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/// The framework home-slot derivation (`home-<sha256(subject)[0..40]>`) — the fallback when the birth
|
|
818
|
+
/// offer response carries no `born` surface (an older dev cloud).
|
|
819
|
+
static String _homeNameOf(String subject) =>
|
|
820
|
+
'home-${sha256.convert(utf8.encode(subject)).toString().substring(0, 40)}';
|
|
821
|
+
|
|
822
|
+
/// CONVERGE local custody with the test cloud (co2 topology step 8): sync every connected session —
|
|
823
|
+
/// PARENT-FIRST by connect order, so the home's chain lands remotely and its birth offer-effects create
|
|
824
|
+
/// each locally-born child's remote custody BEFORE that child's own sync converges against it. Repeats
|
|
825
|
+
/// until every session reports neither `pushFailed` nor `stillAhead`, or [timeout] (then throws with the
|
|
826
|
+
/// per-workspace last verdicts). Returns the final sync result per workspace.
|
|
827
|
+
Future<Map<String, Map<String, Object?>>> converge(
|
|
828
|
+
{Duration timeout = const Duration(seconds: 120), List<String>? workspaces}) async {
|
|
829
|
+
// Every LIVE session on the plane (not the harness's construction-time list — a locally-born child
|
|
830
|
+
// acquired after construction must converge too), PARENT-FIRST: the home syncs before its children, so
|
|
831
|
+
// its birth offer-effect creates each child's remote custody BEFORE that child pushes its own writes.
|
|
832
|
+
final live = bridge.connectedSessions.where((w) => w != _decoyWorkspace).toList();
|
|
833
|
+
final hw = homeWorkspace;
|
|
834
|
+
if (hw != null && live.remove(hw)) live.insert(0, hw); // the parent leads
|
|
835
|
+
final targets = workspaces ?? live;
|
|
836
|
+
final last = <String, Map<String, Object?>>{};
|
|
837
|
+
final pending = List.of(targets);
|
|
838
|
+
final deadline = DateTime.now().add(timeout);
|
|
839
|
+
while (pending.isNotEmpty) {
|
|
840
|
+
for (final ws in List.of(pending)) {
|
|
841
|
+
try {
|
|
842
|
+
final r = (await bridge.session(ws).sync(admit: true)).cast<String, Object?>();
|
|
843
|
+
last[ws] = r;
|
|
844
|
+
if (r['pushFailed'] != true && r['stillAhead'] != true) pending.remove(ws);
|
|
845
|
+
} catch (e) {
|
|
846
|
+
last[ws] = {'error': e.toString()};
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
if (pending.isEmpty) break;
|
|
850
|
+
if (DateTime.now().isAfter(deadline)) {
|
|
851
|
+
throw NomosTestHarnessException(
|
|
852
|
+
'converge: ${pending.length} workspace(s) did not converge within ${timeout.inSeconds}s — '
|
|
853
|
+
'${pending.map((w) => '$w: ${jsonEncode(last[w] ?? {})}').join('; ')}');
|
|
854
|
+
}
|
|
855
|
+
await Future<void>.delayed(const Duration(milliseconds: 500));
|
|
856
|
+
}
|
|
857
|
+
return last;
|
|
858
|
+
}
|
|
859
|
+
|
|
616
860
|
// ── CUSTODY DIAGNOSTIC HELPERS (architecture/session_bound_clients.md ask 9) ──
|
|
617
861
|
|
|
618
862
|
/// The raw multi-workspace plane under [bridge] (the session handle a single-workspace harness
|
package/dart/pubspec.yaml
CHANGED
|
@@ -11,7 +11,7 @@ description: >-
|
|
|
11
11
|
Nomos 2 Dart frontend types — the hand-written kernel wire types plus the
|
|
12
12
|
code-generated per-domain aggregate + directive-payload types. (De)serializes
|
|
13
13
|
the EXACT serde JSON the Rust kernel + projection produce/consume.
|
|
14
|
-
version: 0.
|
|
14
|
+
version: 0.57.0
|
|
15
15
|
|
|
16
16
|
environment:
|
|
17
17
|
sdk: ^3.5.0
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// THE CLOUD-BOUND TOPOLOGY ACCEPTANCE (co2's exact 10-step ask) — the real-app shape end-to-end:
|
|
2
|
+
//
|
|
3
|
+
// 1. the remote platform births the home (the ONE control-plane contact — the authenticated ceremony);
|
|
4
|
+
// 2. the app mounts the home locally (a normal cloud-bound NomosSession; co2-platform is never cloned);
|
|
5
|
+
// 3. the network PARTITIONS (NomosTestCloud.setOnline(false));
|
|
6
|
+
// 4. the home births the estate LOCALLY (`birthEstateWorkspace` — a `.births()` offer on this device);
|
|
7
|
+
// 5. the estate opens as a sibling session and ACCEPTS LOCAL WRITES while offline;
|
|
8
|
+
// 6. UI-facing queries return those writes immediately (no sync awaited, ever);
|
|
9
|
+
// 7. the network HEALS;
|
|
10
|
+
// 8. `harness.converge()` establishes remote custody PARENT-FIRST (the home's synced chain folds the
|
|
11
|
+
// birth offer-effect remotely, creating the estate's custody; then the estate converges against it);
|
|
12
|
+
// 9. a FRESH observer (the dev cloud's own remote projection) sees the estate writes;
|
|
13
|
+
// 10. custody is REAL on both: remote heads born with commits (the dev cloud adopted the chains through
|
|
14
|
+
// the same gate everything else uses — replay IS validation).
|
|
15
|
+
//
|
|
16
|
+
// Entirely inside the test cloud's trust universe: nothing here touches production K_root or custody.
|
|
17
|
+
import 'dart:convert';
|
|
18
|
+
import 'dart:io';
|
|
19
|
+
|
|
20
|
+
import 'package:nomos_client/nomos_headless.dart';
|
|
21
|
+
import 'package:test/test.dart';
|
|
22
|
+
|
|
23
|
+
String get _root => '${Directory.current.path}/../..';
|
|
24
|
+
String get _repoEntry => '$_root/cloud/web-client/runner/node-entry.mjs';
|
|
25
|
+
String get _devServer => '$_root/cloud/dev-server/server.mjs';
|
|
26
|
+
String get _deploy =>
|
|
27
|
+
'$_root/bench/scale/fixtures/cloud-topology/build/cloud-topology.deploy.json';
|
|
28
|
+
const _subject = 'user:topology-test';
|
|
29
|
+
|
|
30
|
+
String? _missingPrereq() {
|
|
31
|
+
if (!File(_repoEntry).existsSync()) return 'in-repo runner not found at $_repoEntry';
|
|
32
|
+
if (!File(_devServer).existsSync()) return 'in-repo dev server not found at $_devServer';
|
|
33
|
+
if (!File(_deploy).existsSync()) {
|
|
34
|
+
return 'compile the fixture: cd bench/scale/fixtures/cloud-topology && node ../../../../dsl/compile_package.mjs — missing $_deploy';
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
void main() {
|
|
40
|
+
test('the co2 topology: remote home ceremony → local estate birth offline → converge → remote custody',
|
|
41
|
+
() async {
|
|
42
|
+
final skip = _missingPrereq();
|
|
43
|
+
if (skip != null) {
|
|
44
|
+
markTestSkipped(skip);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
final diag = Platform.environment['NOMOS_HARNESS_DIAG'] == '1'
|
|
48
|
+
? (String l) => stderr.writeln(l)
|
|
49
|
+
: null;
|
|
50
|
+
final cloud = await NomosTestCloud.start(serverEntry: _devServer, onDiagnostic: diag);
|
|
51
|
+
NomosTestHarness? harness;
|
|
52
|
+
try {
|
|
53
|
+
// 1+2 — the remote ceremony + the local mount, in one constructor (the app's boot path).
|
|
54
|
+
harness = await NomosTestHarness.cloudBound(
|
|
55
|
+
cloud: cloud,
|
|
56
|
+
deployJsonPaths: [_deploy],
|
|
57
|
+
platformWorkspace: 'topology-platform',
|
|
58
|
+
subject: _subject,
|
|
59
|
+
nodeEntry: _repoEntry,
|
|
60
|
+
onDiagnostic: diag,
|
|
61
|
+
);
|
|
62
|
+
final home = harness.homeSession;
|
|
63
|
+
expect(home.workspace, startsWith('home-'),
|
|
64
|
+
reason: 'the remote ceremony born the deterministic home slot');
|
|
65
|
+
final remoteHome = await cloud.remoteHead(home.workspace);
|
|
66
|
+
expect(remoteHome.initialized, isTrue,
|
|
67
|
+
reason: 'the home was born REMOTELY (custody exists before the app ever mounted it)');
|
|
68
|
+
|
|
69
|
+
// 3 — PARTITION.
|
|
70
|
+
cloud.setOnline(false);
|
|
71
|
+
|
|
72
|
+
// 4 — the home births the estate LOCALLY (the app lane never needs the network).
|
|
73
|
+
final outcome = await harness.bridge.session(home.workspace).offerCreates(
|
|
74
|
+
domain: 'home',
|
|
75
|
+
directiveId: 'birthEstateWorkspace',
|
|
76
|
+
payload: {
|
|
77
|
+
'owner': _subject,
|
|
78
|
+
'frameworkHash': harness.frameworkHash,
|
|
79
|
+
'lawHash': harness.lawHashes(home.workspace).first,
|
|
80
|
+
'bornAt': '2026-07-14T00:00:00Z',
|
|
81
|
+
},
|
|
82
|
+
actor: _subject,
|
|
83
|
+
);
|
|
84
|
+
expect(outcome.born, isNotEmpty, reason: 'the local .births() offer spawned the estate');
|
|
85
|
+
final estateWs = outcome.born.single;
|
|
86
|
+
expect(estateWs, startsWith('estate-'));
|
|
87
|
+
|
|
88
|
+
// 5 — the estate opens as a sibling session and accepts a LOCAL WRITE while offline.
|
|
89
|
+
final estate = await harness.bridge.acquireSession(estateWs);
|
|
90
|
+
await estate.offerCreates(
|
|
91
|
+
domain: 'estate',
|
|
92
|
+
directiveId: 'writeEstateNote',
|
|
93
|
+
payload: {'author': _subject, 'body': 'written offline', 'writtenAt': '2026-07-14T00:00:01Z'},
|
|
94
|
+
actor: _subject,
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
// 6 — the UI-facing query returns the write IMMEDIATELY (local projection; nothing awaited sync).
|
|
98
|
+
final localRows = await estate.query('notesByAuthor', {'author': _subject});
|
|
99
|
+
expect(localRows, hasLength(1));
|
|
100
|
+
expect((localRows.single['data'] as Map)['body'], 'written offline');
|
|
101
|
+
|
|
102
|
+
// …and the estate has NO remote custody yet — the partition is real.
|
|
103
|
+
final remoteEstateOffline = await cloud.remoteHead(estateWs);
|
|
104
|
+
expect(remoteEstateOffline.initialized, isFalse,
|
|
105
|
+
reason: 'while partitioned, the locally-born estate must have NO remote custody');
|
|
106
|
+
|
|
107
|
+
// 7+8 — HEAL, then converge parent-first (home's chain lands; its birth offer-effect creates the
|
|
108
|
+
// estate's remote custody; the estate then converges against it).
|
|
109
|
+
cloud.setOnline(true);
|
|
110
|
+
await harness.converge(timeout: const Duration(seconds: 180));
|
|
111
|
+
|
|
112
|
+
// 9 — a FRESH observer: the dev cloud's own remote projection serves the estate write.
|
|
113
|
+
final http = HttpClient();
|
|
114
|
+
try {
|
|
115
|
+
final req = await http.getUrl(Uri.parse(
|
|
116
|
+
'${cloud.origin}/v2/workspaces/$estateWs/notesByAuthor?author=${Uri.encodeQueryComponent(_subject)}'));
|
|
117
|
+
final resp = await req.close();
|
|
118
|
+
final body = jsonDecode(await resp.transform(utf8.decoder).join());
|
|
119
|
+
final rows = ((body as Map)['rows'] as List?) ?? const [];
|
|
120
|
+
expect(rows, hasLength(1),
|
|
121
|
+
reason: 'the remote projection (a fresh observer) serves the offline-written note after converge');
|
|
122
|
+
expect(((rows.single as Map)['data'] as Map)['body'], 'written offline');
|
|
123
|
+
} finally {
|
|
124
|
+
http.close(force: true);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// 10 — custody is REAL on both (the dev cloud adopted the chains through the one gate).
|
|
128
|
+
final remoteHome2 = await cloud.remoteHead(home.workspace);
|
|
129
|
+
final remoteEstate2 = await cloud.remoteHead(estateWs);
|
|
130
|
+
expect(remoteHome2.initialized, isTrue);
|
|
131
|
+
expect(remoteHome2.commits, greaterThan(0));
|
|
132
|
+
expect(remoteEstate2.initialized, isTrue,
|
|
133
|
+
reason: 'converge established the estate\'s remote custody (parent birth folded remotely)');
|
|
134
|
+
expect(remoteEstate2.commits, greaterThan(0));
|
|
135
|
+
} finally {
|
|
136
|
+
try { await harness?.dispose(); } catch (_) {}
|
|
137
|
+
await cloud.stop();
|
|
138
|
+
}
|
|
139
|
+
}, timeout: const Timeout(Duration(minutes: 6)));
|
|
140
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@githolon/dsl",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.90.0",
|
|
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",
|
package/src/codegen_dart.ts
CHANGED
|
@@ -2139,8 +2139,32 @@ function emitDartContractClass(c: import("./contract.js").ContractHandle): strin
|
|
|
2139
2139
|
* import header so `compile_package_main`'s uniform rewrite (→ vendored relative / → nomos_types) applies;
|
|
2140
2140
|
* the classes themselves reference only nomos_types primitives (+ shared VO classes), so the file is PURE.
|
|
2141
2141
|
*/
|
|
2142
|
-
export function generateDartContracts(
|
|
2142
|
+
export function generateDartContracts(
|
|
2143
|
+
contracts: readonly import("./contract.js").ContractHandle[],
|
|
2144
|
+
opts: {
|
|
2145
|
+
/** The shared `nomos_framework.dart` import URI when that file exists — contract-used VOs (e.g. a
|
|
2146
|
+
* `ReferenceCode.field()`) render inside the DTOs, and their classes are HOISTED there (layer-zero),
|
|
2147
|
+
* so contracts.dart must import it (co2 #117b). */
|
|
2148
|
+
readonly frameworkImportUri?: string;
|
|
2149
|
+
/** Local aggregates' typed-id class names — a contract named after a local aggregate must NOT
|
|
2150
|
+
* re-declare its `<X>Id` (the aggregate's own class, with `.fromRow`, already exists). */
|
|
2151
|
+
readonly aggregateIdClassNames?: ReadonlySet<string>;
|
|
2152
|
+
} = {},
|
|
2153
|
+
): string {
|
|
2143
2154
|
const sorted = [...contracts].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
2155
|
+
// CONTRACT TYPED IDS (co2 #117a): `idOf(SomeContract)` in a payload types the field `<ContractId>Id` —
|
|
2156
|
+
// exactly like an aggregate's typed id — but a contract has no aggregate emitter to declare the class.
|
|
2157
|
+
// Emit each contract's id as a ZERO-COST String extension type here (the shared layer every domain
|
|
2158
|
+
// imports): a const ctor from the wire string + `.value` — the exact surface the generated payloads use.
|
|
2159
|
+
const idTypes = sorted
|
|
2160
|
+
.map((c) => typedIdClassForAggregateType(c.id))
|
|
2161
|
+
.filter((cls) => !(opts.aggregateIdClassNames?.has(cls) ?? false))
|
|
2162
|
+
.map((cls) => [
|
|
2163
|
+
`/// TYPED CONTRACT ID \`${cls}\` — a ZERO-COST nominal String (Dart extension type): the id of the`,
|
|
2164
|
+
`/// foreign aggregate behind the contract, as \`idOf(<Contract>)\` payload fields carry it. Construct`,
|
|
2165
|
+
`/// it from a \`ForeignRef.id\` / a queried row id: \`${cls}(ref.id)\`; \`.value\` is the wire string.`,
|
|
2166
|
+
`extension type const ${cls}(String value) {}`,
|
|
2167
|
+
].join("\n"));
|
|
2144
2168
|
return [
|
|
2145
2169
|
`// GENERATED by nomos-compile — the package's shared domain CONTRACTS (public DTOs). DO NOT EDIT.`,
|
|
2146
2170
|
`// Every domain depends on this shared layer; no domain imports another's concrete types (the acyclic graph).`,
|
|
@@ -2149,6 +2173,9 @@ export function generateDartContracts(contracts: readonly import("./contract.js"
|
|
|
2149
2173
|
`library;`,
|
|
2150
2174
|
``,
|
|
2151
2175
|
`import 'package:nomos_client/nomos_client.dart';`,
|
|
2176
|
+
...(opts.frameworkImportUri !== undefined ? [`import '${opts.frameworkImportUri}';`] : []),
|
|
2177
|
+
``,
|
|
2178
|
+
...idTypes,
|
|
2152
2179
|
``,
|
|
2153
2180
|
...sorted.map(emitDartContractClass),
|
|
2154
2181
|
``,
|
|
@@ -4250,7 +4277,9 @@ export function generateDartDomain(mod: DomainModule, opts: GenerateDartDomainOp
|
|
|
4250
4277
|
const brandNames = new Set<string>();
|
|
4251
4278
|
const walkUnitField = (f: ValueObjectFieldDescriptor): void => {
|
|
4252
4279
|
if (f.type === "number" && f.unit !== undefined) unitNames.add(f.unit);
|
|
4253
|
-
|
|
4280
|
+
// A standalone brandedId declares its own extension type; an idOf REFERENCE does NOT — the aggregate
|
|
4281
|
+
// emits the canonical `<Agg>Id` class (with `.fromRow`), so re-declaring it here dup'd the symbol (co2).
|
|
4282
|
+
else if (f.type === "string" && f.brand !== undefined && f.idOf === undefined) brandNames.add(f.brand);
|
|
4254
4283
|
else if (f.type === "array") walkUnitField(f.items);
|
|
4255
4284
|
else if (f.type === "map") walkUnitField(f.values);
|
|
4256
4285
|
};
|
package/src/codegen_proof.ts
CHANGED
|
@@ -48,7 +48,7 @@ import { clearDirectiveId } from "./clear_directive.js";
|
|
|
48
48
|
import { voMetaOf, voSample } from "./value_object.js";
|
|
49
49
|
import { mintedCreateField } from "./workspace_routing.js";
|
|
50
50
|
import { deterministicPorts } from "./ctx.js";
|
|
51
|
-
import { __setDispatchDomain } from "./framework/relations.js";
|
|
51
|
+
import { __setDispatchDomain, __setDispatchContext } from "./framework/relations.js";
|
|
52
52
|
import { __resetBirths, __drainBirths } from "./births.js";
|
|
53
53
|
import { __resetAuthoring, __drainAuthoring } from "./authoring.js";
|
|
54
54
|
import { __resetReads, __drainReads } from "./read.js";
|
|
@@ -127,10 +127,20 @@ function uniquifyBirthPayload(
|
|
|
127
127
|
disc: string,
|
|
128
128
|
schema: z.ZodTypeAny | undefined,
|
|
129
129
|
): Record<string, unknown> {
|
|
130
|
+
// Deterministic 12-hex discriminator for FORMAT-CONSTRAINED samples (a uuid-shaped subject can't take a
|
|
131
|
+
// `-<disc>` suffix — the field schema would refuse it; vary the uuid TAIL instead, format-preserving).
|
|
132
|
+
const discHex = [...disc].reduce((h, ch) => (Math.imul(h, 31) + ch.charCodeAt(0)) >>> 0, 7).toString(16).padStart(8, "0").repeat(2).slice(0, 12);
|
|
130
133
|
const suffixed: Record<string, unknown> = {};
|
|
131
134
|
let changed = false;
|
|
132
135
|
for (const [k, v] of Object.entries(payload)) {
|
|
133
136
|
if (typeof v === "string" && v.startsWith("sample-")) { suffixed[k] = `${v}-${disc}`; changed = true; }
|
|
137
|
+
else if (typeof v === "string" && v.includes(ZERO_UUID)) {
|
|
138
|
+
// The synthesized subject/uuid sentinel — uniquify the tail so sibling birth legs derive DISTINCT
|
|
139
|
+
// deterministic child names (co2 #118 follow-on: six gated births each parentSetup-birthed the SAME
|
|
140
|
+
// home — the second one found it already born, `born=[]`, and the leg died).
|
|
141
|
+
suffixed[k] = v.replace(ZERO_UUID, `00000000-0000-0000-0000-${discHex}`);
|
|
142
|
+
changed = true;
|
|
143
|
+
}
|
|
134
144
|
else suffixed[k] = v;
|
|
135
145
|
}
|
|
136
146
|
if (!changed) return payload;
|
|
@@ -151,11 +161,28 @@ function uniquifyBirthPayload(
|
|
|
151
161
|
return suffixed;
|
|
152
162
|
}
|
|
153
163
|
|
|
164
|
+
// Constrained-string candidates probed against a field's OWN schema when the readable `sample-<field>`
|
|
165
|
+
// fails it (a `.regex()/.length()/.email()/.url()` bound — co2: a locale-pattern `defaultLocale` was
|
|
166
|
+
// handed `sample-defaultLocale`, violating its own schema and killing the whole leg downstream). Ordered
|
|
167
|
+
// common-first; the READABLE sample stays the first try so unconstrained fields are byte-stable.
|
|
168
|
+
const STRING_CANDIDATES: readonly string[] = [
|
|
169
|
+
"sample", "en", "en-GB", "AA", "A1", "a", "1", "2026-01-01", "2026-01-01T00:00:00Z",
|
|
170
|
+
"a@example.com", "https://example.com", "#000000", "00000000-0000-0000-0000-000000000000",
|
|
171
|
+
"0000000000000000000000000000000000000000000000000000000000000000", // a 64-hex digest
|
|
172
|
+
];
|
|
154
173
|
function sampleString(zt: z.ZodTypeAny, field: string): string {
|
|
155
174
|
const readable = `sample-${field}`;
|
|
156
175
|
if (zt.safeParse(readable).success) return readable;
|
|
157
176
|
for (const cand of SUBJECT_CANDIDATES) if (zt.safeParse(cand).success) return cand;
|
|
158
|
-
return
|
|
177
|
+
for (const cand of STRING_CANDIDATES) if (zt.safeParse(cand).success) return cand;
|
|
178
|
+
// NO INVALID SAMPLE (co2): returning a schema-violating string poisons every consumer downstream (the
|
|
179
|
+
// recipe probe, the proof leg, the generated .mts) with a mystery refusal — throw NAMED instead (the
|
|
180
|
+
// callers' try/catch degrades to the same honest skip an unsynthesizable shape gets).
|
|
181
|
+
throw new Error(
|
|
182
|
+
`codegen-proof: field '${field}': no sample satisfies its own string schema (a .regex()/.length() ` +
|
|
183
|
+
`bound none of the ${1 + SUBJECT_CANDIDATES.length + STRING_CANDIDATES.length} candidates fit) — ` +
|
|
184
|
+
`loosen the pattern, or prove that directive by hand in test/e2e.mts`,
|
|
185
|
+
);
|
|
159
186
|
}
|
|
160
187
|
|
|
161
188
|
// Candidate numbers probed against a field's own schema (in a sensible order) so a `.min()/.max()/
|
|
@@ -1408,7 +1435,17 @@ export function discoverRelationBootstrapLeg(mod: DomainModule, gatedOverride?:
|
|
|
1408
1435
|
// (2) COMPUTED-BASE: when R is computed, walk the relation-schema rewrite for the directly-grantable BASE
|
|
1409
1436
|
// relations B (e.g. gate `estateMember` → `estateOwner`/`admin`), and find a granter for a base —
|
|
1410
1437
|
// granting B makes the actor satisfy R through the computed chain (what estate's seed does).
|
|
1411
|
-
|
|
1438
|
+
// A `.delegatedRole(...)` granter verifies a SIGNED delegation cert no synthesizer can produce — it can
|
|
1439
|
+
// never be seated by a proof (the cert gate refuses the sample), so it is NOT a candidate (the computed-
|
|
1440
|
+
// base/seed lanes then find the law's seatable path instead — co2 #118: acceptDelegatedCatalogueCreator
|
|
1441
|
+
// shadowed seedHomeCreator and the leg died on the cert gate).
|
|
1442
|
+
const candidates = directives.filter(
|
|
1443
|
+
(d) =>
|
|
1444
|
+
d !== gated &&
|
|
1445
|
+
typeof d?.payloadSchema === "object" &&
|
|
1446
|
+
d.birthsChild !== true &&
|
|
1447
|
+
(d as { delegatedRoleGate?: unknown }).delegatedRoleGate === undefined,
|
|
1448
|
+
);
|
|
1412
1449
|
// ALL granters of `rel`, ordered: `.grants(R)` markers first (unambiguous), then plan-probed `writeTuple(R)`.
|
|
1413
1450
|
const findGrantersFor = (rel: string): { granter: Directive<unknown>; via: "marker" | "inferred" }[] => {
|
|
1414
1451
|
const out: { granter: Directive<unknown>; via: "marker" | "inferred" }[] = [];
|
|
@@ -1948,7 +1985,7 @@ export function discoverChildBirthLegOrReason(
|
|
|
1948
1985
|
const seedDir = (childMod.directives as Directive<unknown>[]).find((d) => d?.id === childSeedDirectiveId);
|
|
1949
1986
|
if (seedDir !== undefined) {
|
|
1950
1987
|
childSeedAggregateId = seedDir.aggregateId;
|
|
1951
|
-
childSeedInstanceId = discoverSingletonInstanceId(seedDir);
|
|
1988
|
+
childSeedInstanceId = discoverSingletonInstanceId(seedDir, childMod);
|
|
1952
1989
|
const q = ((childMod.queries ?? []) as QueryDecl[]).find((qq) => qq.returns === seedDir.aggregateId && qq.key.length > 0);
|
|
1953
1990
|
if (q !== undefined) childQuery = { id: q.id, key: q.key };
|
|
1954
1991
|
}
|
|
@@ -2038,7 +2075,19 @@ function attachParentSetup(leg: ChildBirthLeg, allBirths: readonly ChildBirthLeg
|
|
|
2038
2075
|
/** Discover the SINGLETON instance id a seed directive writes (a fixed `<Agg>:self`-shaped id), by running
|
|
2039
2076
|
* its plan with a probe payload + birth-probe globals and reading the first written op's instance id when
|
|
2040
2077
|
* it is a constant (the same id across two distinct probe payloads). `undefined` ⇒ not a fixed singleton. */
|
|
2041
|
-
function discoverSingletonInstanceId(seedDir: Directive<unknown
|
|
2078
|
+
function discoverSingletonInstanceId(seedDir: Directive<unknown>, ownerMod?: DomainModule): string | undefined {
|
|
2079
|
+
// RELATION-NAMESPACING PARITY (co2 #118 follow-on): the ENGINE qualifies a `writeTuple` object token /
|
|
2080
|
+
// bare `<type>:<id>` by the dispatching domain (`<domain>__<type>`) for the domain's local non-global
|
|
2081
|
+
// relationTypes — so the FOLDED tuple's instance id is the QUALIFIED one. Probe under the same dispatch
|
|
2082
|
+
// context (the same classification the engine builds), else the discovered singleton id is the bare
|
|
2083
|
+
// variant and the runner's read-back misses the row the genesis actually seeded.
|
|
2084
|
+
const qualifiable = new Set<string>();
|
|
2085
|
+
for (const rt of (ownerMod?.relationTypes ?? []) as unknown as Array<{ type: string; global?: boolean; relations: Map<string, unknown> | Record<string, unknown> }>) {
|
|
2086
|
+
if (rt.global === true) continue;
|
|
2087
|
+
const rels = rt.relations instanceof Map ? [...rt.relations.keys()] : Object.keys(rt.relations ?? {});
|
|
2088
|
+
for (const rel of rels) qualifiable.add(`${rt.type}#${rel}`);
|
|
2089
|
+
}
|
|
2090
|
+
const domain = ownerMod !== undefined ? (ownerMod.domain ?? ownerMod.name) : undefined;
|
|
2042
2091
|
const idsFor = (seed: number): string | undefined => {
|
|
2043
2092
|
let synth: SynthesizedPayload;
|
|
2044
2093
|
try {
|
|
@@ -2056,6 +2105,9 @@ function discoverSingletonInstanceId(seedDir: Directive<unknown>): string | unde
|
|
|
2056
2105
|
__resetAuthoring();
|
|
2057
2106
|
__resetReads();
|
|
2058
2107
|
__resetBirths();
|
|
2108
|
+
// Probe under the OWNER DOMAIN's dispatch context so a relation-tuple singleton id qualifies exactly
|
|
2109
|
+
// as the engine folds it (`<domain>__<type>:…`) — see the qualifiable set built above.
|
|
2110
|
+
if (domain !== undefined) __setDispatchContext({ domain, qualifiable });
|
|
2059
2111
|
try {
|
|
2060
2112
|
const returned = seedDir.plan(payload as never, deterministicPorts({ physical: seed, replica: 1 }));
|
|
2061
2113
|
const ops = [...__drainAuthoring(), ...(Array.isArray(returned) ? returned : [])];
|
|
@@ -2064,6 +2116,7 @@ function discoverSingletonInstanceId(seedDir: Directive<unknown>): string | unde
|
|
|
2064
2116
|
} catch {
|
|
2065
2117
|
id = undefined;
|
|
2066
2118
|
} finally {
|
|
2119
|
+
if (domain !== undefined) __setDispatchContext(undefined);
|
|
2067
2120
|
try { __drainReads(); __drainBirths(); } catch { /* drained */ }
|
|
2068
2121
|
}
|
|
2069
2122
|
});
|
|
@@ -2320,10 +2373,13 @@ export function legsForDomain(mod: DomainModule, modules: readonly DomainModule[
|
|
|
2320
2373
|
const uniq = uniquifyBirthPayload(withParent.birthPayload, b.birthDirectiveId, schema);
|
|
2321
2374
|
if (uniq !== withParent.birthPayload) withParent = { ...withParent, birthPayload: uniq };
|
|
2322
2375
|
}
|
|
2323
|
-
// A GATED birth
|
|
2324
|
-
//
|
|
2325
|
-
//
|
|
2326
|
-
|
|
2376
|
+
// A GATED birth: seat its relation via the SAME granter/seed discovery the relation-bootstrap leg uses
|
|
2377
|
+
// (the law's own authority path, e.g. co2's seedPlatformFounder → seedPlatformAdmin →
|
|
2378
|
+
// grantCo2EstateCreator → birthEstateWorkspace). ALSO when a parentSetup exists (co2 #118): a parent
|
|
2379
|
+
// birth's genesis does NOT necessarily seat the gated relation (co2's homeCreator is claimed POST-birth
|
|
2380
|
+
// by the owner via seedHomeCreator), so the runner seats the discovered path INSIDE the born parent —
|
|
2381
|
+
// as the birth actor — before the gated birth.
|
|
2382
|
+
if (withParent.birthRequiresRelation !== undefined) {
|
|
2327
2383
|
const birthDir = (mod.directives as Directive<unknown>[]).find((d) => d?.id === b.birthDirectiveId);
|
|
2328
2384
|
const rb = birthDir !== undefined ? discoverRelationBootstrapLeg(mod, birthDir) : undefined;
|
|
2329
2385
|
// a born-context path proves INSIDE a born workspace — not a seatable pre-grant; leave it to the gap diagnostic.
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
*/
|
|
48
48
|
import { execFileSync } from "node:child_process";
|
|
49
49
|
import { expandCapabilityExports, isCapabilityDecl } from "./capability_exports.js";
|
|
50
|
-
import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
50
|
+
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
51
51
|
import { createRequire } from "node:module";
|
|
52
52
|
import path from "node:path";
|
|
53
53
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
@@ -59,6 +59,7 @@ import {
|
|
|
59
59
|
generateDartActivityRegistry,
|
|
60
60
|
collectActivityDescriptors,
|
|
61
61
|
collectPayloadIdOfTargets,
|
|
62
|
+
typedIdClassForAggregateType,
|
|
62
63
|
type DartImport,
|
|
63
64
|
type DartRefImport,
|
|
64
65
|
type DomainModule,
|
|
@@ -503,6 +504,14 @@ function collectPackageContracts(modules: readonly DomainModule[]): ContractHand
|
|
|
503
504
|
return [...byId.values()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
504
505
|
}
|
|
505
506
|
|
|
507
|
+
/** Every aggregate's generated typed-id class name across the package — the collision guard for the
|
|
508
|
+
* contract typed-id emission (a contract named after a local aggregate must not re-declare `<X>Id`). */
|
|
509
|
+
function packageAggregateIdClassNames(modules: readonly DomainModule[]): Set<string> {
|
|
510
|
+
const out = new Set<string>();
|
|
511
|
+
for (const m of modules) for (const a of m.aggregates) out.add(typedIdClassForAggregateType(a.id));
|
|
512
|
+
return out;
|
|
513
|
+
}
|
|
514
|
+
|
|
506
515
|
/**
|
|
507
516
|
* CROSS-DOMAIN REF TRAVERSAL map (dart_codegen_parity.md §2a follow-on): every aggregate id in the package
|
|
508
517
|
* → its owning domain (`domain` for the generated client class name; `name` for the sibling
|
|
@@ -578,7 +587,10 @@ interface DartSharedSymbols {
|
|
|
578
587
|
/** The synthetic module whose emit IS `nomos_framework.dart` — undefined when nothing is shared. */
|
|
579
588
|
readonly frameworkMod: DomainModule | undefined;
|
|
580
589
|
}
|
|
581
|
-
function collectDartSharedSymbols(
|
|
590
|
+
function collectDartSharedSymbols(
|
|
591
|
+
modules: readonly DomainModule[],
|
|
592
|
+
packageContracts: readonly ContractHandle[] = [],
|
|
593
|
+
): DartSharedSymbols {
|
|
582
594
|
type ModDirective = DomainModule["directives"][number];
|
|
583
595
|
type ModQuery = NonNullable<DomainModule["queries"]>[number];
|
|
584
596
|
const frameworkAggById = new Map<string, AggregateHandle>();
|
|
@@ -600,9 +612,28 @@ function collectDartSharedSymbols(modules: readonly DomainModule[]): DartSharedS
|
|
|
600
612
|
voDescriptorByName.set(d.name, d);
|
|
601
613
|
}
|
|
602
614
|
}
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
)
|
|
615
|
+
// CONTRACT-USED VOs ARE LAYER-ZERO (co2 #117): a VO referenced by a contract FIELD (e.g. a
|
|
616
|
+
// `ReferenceCode.field()` inside `contract(...)`) is rendered inside `contracts.dart` — the shared layer
|
|
617
|
+
// every domain imports and which may import NO domain file (the acyclic rule). So its class must live in
|
|
618
|
+
// `nomos_framework.dart` REGARDLESS of how many domains use it (a single-domain VO would otherwise land in
|
|
619
|
+
// that domain's file, unreachable from contracts.dart). Transitive deps included (a nested VO renders too).
|
|
620
|
+
const contractVoNames = new Set<string>();
|
|
621
|
+
for (const c of packageContracts) {
|
|
622
|
+
for (const f of Object.values(c.fields)) {
|
|
623
|
+
const meta = (f as { valueObject?: { descriptor: { name: string }; dependencies: readonly { name: string }[] } }).valueObject;
|
|
624
|
+
if (meta === undefined) continue;
|
|
625
|
+
contractVoNames.add(meta.descriptor.name);
|
|
626
|
+
voDescriptorByName.set(meta.descriptor.name, meta.descriptor as NonNullable<DomainModule["valueObjects"]>[number]);
|
|
627
|
+
for (const dep of meta.dependencies) {
|
|
628
|
+
contractVoNames.add(dep.name);
|
|
629
|
+
voDescriptorByName.set(dep.name, dep as NonNullable<DomainModule["valueObjects"]>[number]);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
const sharedVoNames: ReadonlySet<string> = new Set([
|
|
634
|
+
...[...voDomainsByName.entries()].filter(([, n]) => n > 1).map(([name]) => name),
|
|
635
|
+
...contractVoNames,
|
|
636
|
+
]);
|
|
606
637
|
const sharedVos = [...sharedVoNames].sort().map((name) => voDescriptorByName.get(name)!);
|
|
607
638
|
const hasFrameworkSymbols =
|
|
608
639
|
frameworkAggs.length > 0 || frameworkDirs.length > 0 || frameworkQueries.length > 0 || sharedVos.length > 0;
|
|
@@ -814,7 +845,8 @@ function emitDart(
|
|
|
814
845
|
// • QUERIES — the top-level `watch<Q>`/`read<Q>` accessors.
|
|
815
846
|
// Per-domain files SKIP each and IMPORT the shared file; clients RE-EXPORT it (Dart de-dups the
|
|
816
847
|
// same-library re-export). So a barrel over all domains needs NO `hide` clause.
|
|
817
|
-
const
|
|
848
|
+
const packageContracts = collectPackageContracts(modules);
|
|
849
|
+
const shared = collectDartSharedSymbols(modules, packageContracts);
|
|
818
850
|
const { frameworkAggIds, frameworkDirIds, frameworkQueryIds, sharedVoNames, hasFrameworkSymbols } = shared;
|
|
819
851
|
const frameworkImportUri = hasFrameworkSymbols ? `${FRAMEWORK_FILE}.dart` : undefined;
|
|
820
852
|
|
|
@@ -845,10 +877,12 @@ function emitDart(
|
|
|
845
877
|
}
|
|
846
878
|
// 2a-bis. the shared DOMAIN CONTRACTS file (`contracts.dart`) — every public DTO class, ONCE. Pure
|
|
847
879
|
// (nomos_types + VOs). A foreignRef targeting a contract imports THIS, never the concrete domain.
|
|
848
|
-
const packageContracts = collectPackageContracts(modules);
|
|
849
880
|
const contractsImportUri = packageContracts.length > 0 ? `${CONTRACTS_FILE}.dart` : undefined;
|
|
850
881
|
if (packageContracts.length > 0) {
|
|
851
|
-
writeGenerated(CONTRACTS_FILE, generateDartContracts(packageContracts
|
|
882
|
+
writeGenerated(CONTRACTS_FILE, generateDartContracts(packageContracts, {
|
|
883
|
+
...(frameworkImportUri !== undefined ? { frameworkImportUri } : {}),
|
|
884
|
+
aggregateIdClassNames: packageAggregateIdClassNames(modules),
|
|
885
|
+
}));
|
|
852
886
|
}
|
|
853
887
|
// 2a-ter. the shared ACTIVITY REGISTRY (`activity_registry.dart`) — the pure `renderActivity`, ONCE. Emitted
|
|
854
888
|
// iff a directive opts in via `.activity(...)` (activity-free packages stay byte-identical). Pure
|
|
@@ -1016,7 +1050,7 @@ const NOMOS_TYPES_DEP = "^0.4.1";
|
|
|
1016
1050
|
// 0.48.x: session-bound clients (XClient.bind/NomosClientInterface/DomainClientSessionMismatch/
|
|
1017
1051
|
// DomainNotInstalledForSession, engine.follow, pendingConflicts lanes) — the generated client
|
|
1018
1052
|
// REQUIRES this surface. 0.48.1 fixes NomosSession.watchPendingConflicts delegation.
|
|
1019
|
-
const NOMOS_CLIENT_DEP = "^0.
|
|
1053
|
+
const NOMOS_CLIENT_DEP = "^0.57.0";
|
|
1020
1054
|
// DRIFT TRIPWIRE: pubspec_pins.test.ts asserts these equal the CURRENT versions in dsl/dart/pubspec.yaml
|
|
1021
1055
|
// and dsl/dart_types/pubspec.yaml — a Dart release without this bump fails the suite, so tenants never
|
|
1022
1056
|
// land back in dependency_overrides (the exact co2 report this closes, third occurrence).
|
|
@@ -1120,7 +1154,8 @@ function emitDartPackages(
|
|
|
1120
1154
|
];
|
|
1121
1155
|
if (new Set(roleNames).size !== roleNames.length) fail(`dart.packages: types/client/test/compat names must be distinct`);
|
|
1122
1156
|
|
|
1123
|
-
const
|
|
1157
|
+
const packageContracts = collectPackageContracts(modules);
|
|
1158
|
+
const shared = collectDartSharedSymbols(modules, packageContracts);
|
|
1124
1159
|
const written: string[] = [];
|
|
1125
1160
|
const genRel = DART_GENERATED_SUBDIR.split(path.sep).join("/");
|
|
1126
1161
|
|
|
@@ -1175,9 +1210,13 @@ function emitDartPackages(
|
|
|
1175
1210
|
if (shared.frameworkMod !== undefined) writeTypes(FRAMEWORK_FILE, generateDartDomain(shared.frameworkMod));
|
|
1176
1211
|
// DOMAIN CONTRACTS (layered): the shared `contracts.dart` DTO file in the TYPES package (pure —
|
|
1177
1212
|
// nomos_types + VOs). A contract-target foreignRef imports THIS sibling, never the concrete domain.
|
|
1178
|
-
const packageContracts = collectPackageContracts(modules);
|
|
1179
1213
|
const contractsImportUri = packageContracts.length > 0 ? `${CONTRACTS_FILE}.dart` : undefined;
|
|
1180
|
-
if (packageContracts.length > 0)
|
|
1214
|
+
if (packageContracts.length > 0) {
|
|
1215
|
+
writeTypes(CONTRACTS_FILE, generateDartContracts(packageContracts, {
|
|
1216
|
+
...(shared.frameworkMod !== undefined ? { frameworkImportUri: `${FRAMEWORK_FILE}.dart` } : {}),
|
|
1217
|
+
aggregateIdClassNames: packageAggregateIdClassNames(modules),
|
|
1218
|
+
}));
|
|
1219
|
+
}
|
|
1181
1220
|
// ACTIVITY REGISTRY (layered): the pure `renderActivity` in the TYPES package (nomos_types only). The
|
|
1182
1221
|
// client resolves it through the types-package barrel (which re-exports every types file). Opt-in only.
|
|
1183
1222
|
const packageActivity = collectActivityDescriptors(modules);
|
|
@@ -1476,6 +1515,26 @@ async function main(): Promise<void> {
|
|
|
1476
1515
|
if (!existsSync(configPath)) fail(`config not found: ${configPath}`);
|
|
1477
1516
|
const cfgDir = path.dirname(configPath);
|
|
1478
1517
|
|
|
1518
|
+
// DUAL-INSTANCE GUARD (DX): the law's `import "@githolon/dsl"` resolves from the LAW's own node_modules,
|
|
1519
|
+
// while this compiler runs from wherever it was invoked. Two DIFFERENT dsl instances silently split every
|
|
1520
|
+
// module-level registry (the fluent-authoring drain, brands, markers) — plans probe empty, lints
|
|
1521
|
+
// false-fire, ids drift. Detect the skew and WARN LOUDLY with the two paths (never fail — a
|
|
1522
|
+
// deliberately-vendored setup may be intentional, and same-version split instances still work for
|
|
1523
|
+
// compilation itself, which imports the law through the compiler's own module graph).
|
|
1524
|
+
try {
|
|
1525
|
+
const lawReq = createRequire(pathToFileURL(path.join(cfgDir, "noop.js")));
|
|
1526
|
+
const lawDslPkg = lawReq.resolve("@githolon/dsl/package.json");
|
|
1527
|
+
const ownDslPkg = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "package.json");
|
|
1528
|
+
if (path.resolve(lawDslPkg) !== path.resolve(ownDslPkg)) {
|
|
1529
|
+
const vOf = (p: string): string => { try { return (JSON.parse(readFileSync(p, "utf8")) as { version?: string }).version ?? "?"; } catch { return "?"; } };
|
|
1530
|
+
console.error(
|
|
1531
|
+
`⚠ nomos-compile: TWO @githolon/dsl instances — the law resolves ${vOf(lawDslPkg)} at ${path.dirname(lawDslPkg)}, but this compiler runs ${vOf(path.resolve(ownDslPkg))} at ${path.dirname(path.resolve(ownDslPkg))}. ` +
|
|
1532
|
+
`Split instances split the DSL's module registries (phantom lint errors, empty plan probes, id drift) — even at the same version string when the code differs. ` +
|
|
1533
|
+
`Run the law package's OWN \`npx githolon compile\`, or align the two installs.`,
|
|
1534
|
+
);
|
|
1535
|
+
}
|
|
1536
|
+
} catch { /* no dsl resolvable from the law dir (vendored/workspace setups) — nothing to compare */ }
|
|
1537
|
+
|
|
1479
1538
|
const cfgModule = (await import(pathToFileURL(configPath).href)) as { default?: PackageConfig };
|
|
1480
1539
|
const cfg = cfgModule.default;
|
|
1481
1540
|
if (!cfg || typeof cfg.name !== "string" || !Array.isArray(cfg.domains) || cfg.domains.length === 0) {
|
|
@@ -2683,20 +2742,67 @@ async function main(): Promise<void> {
|
|
|
2683
2742
|
// false red) when `dart` is absent or the package's deps aren't resolved yet (needs `pub get`). Opt out
|
|
2684
2743
|
// with NOMOS_SKIP_DART_ANALYZE=1.
|
|
2685
2744
|
if (dartOut !== undefined && process.env.NOMOS_SKIP_DART_ANALYZE !== "1") {
|
|
2686
|
-
|
|
2687
|
-
|
|
2688
|
-
|
|
2689
|
-
|
|
2690
|
-
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
|
|
2697
|
-
|
|
2698
|
-
|
|
2745
|
+
// The emitted PACKAGE dirs (each has a pubspec.yaml): the layered emit produces several under `dartOut`;
|
|
2746
|
+
// a flat emit puts one at `dartOut`. Analyze each — but RESOLVE its deps first (`dart pub get`), else
|
|
2747
|
+
// the analyzer reports unresolved `package:` imports as errors and the gate can't tell a codegen bug
|
|
2748
|
+
// from an un-got package (the co2 "analyze skipped → false success" trap).
|
|
2749
|
+
const dOut: string = dartOut;
|
|
2750
|
+
// Analyze exactly THE FILES THIS COMPILE EMITTED (`dartFiles`) — never the whole dir, which would
|
|
2751
|
+
// sweep in the transactional `.nomos-bak` backup and any stale legacy-layout leftovers and judge the
|
|
2752
|
+
// compile on files it did not write. First `dart pub get` each emitted PACKAGE (a dir up-chain with a
|
|
2753
|
+
// pubspec) so `package:` imports resolve; a self-contained vendored emit has no pubspec (no get needed).
|
|
2754
|
+
const emittedDart = dartFiles.filter((f) => f.endsWith(".dart"));
|
|
2755
|
+
const pkgDirOf = (file: string): string | undefined => {
|
|
2756
|
+
let dir = path.dirname(file);
|
|
2757
|
+
for (let hops = 0; hops < 8; hops++) {
|
|
2758
|
+
if (existsSync(path.join(dir, "pubspec.yaml"))) return dir;
|
|
2759
|
+
if (path.resolve(dir) === path.resolve(dOut) || dir === path.dirname(dir)) return undefined;
|
|
2760
|
+
dir = path.dirname(dir);
|
|
2699
2761
|
}
|
|
2762
|
+
return undefined;
|
|
2763
|
+
};
|
|
2764
|
+
// Only files under a PUB PACKAGE (a pubspec up-chain) are verifiable — a VENDORED no-pubspec emit's
|
|
2765
|
+
// runtime `package:nomos_client` imports cannot resolve without a package config, so judging those
|
|
2766
|
+
// files would false-fail every vendored fixture. Analyze the in-package set; note the loose remainder.
|
|
2767
|
+
const inPackage = emittedDart.filter((f) => pkgDirOf(f) !== undefined);
|
|
2768
|
+
const pkgDirs = [...new Set(inPackage.map(pkgDirOf).filter((d): d is string => d !== undefined))];
|
|
2769
|
+
let dartMissing = false;
|
|
2770
|
+
let unresolved: string | undefined;
|
|
2771
|
+
for (const pkg of pkgDirs) {
|
|
2772
|
+
try {
|
|
2773
|
+
execFileSync("dart", ["pub", "get"], { cwd: pkg, stdio: "pipe", encoding: "utf8" });
|
|
2774
|
+
} catch (e) {
|
|
2775
|
+
const err = e as { code?: string; stdout?: string; stderr?: string };
|
|
2776
|
+
if (err.code === "ENOENT") { dartMissing = true; break; }
|
|
2777
|
+
unresolved = `${path.relative(process.cwd(), pkg)}: ${`${err.stdout ?? ""}${err.stderr ?? ""}`.trim().split("\n").slice(-3).join(" ")}`;
|
|
2778
|
+
}
|
|
2779
|
+
}
|
|
2780
|
+
// Only analyze when EVERY package's deps resolved: `dart analyze` against an unresolved package reports
|
|
2781
|
+
// every framework symbol (NomosRef, …) as undefined — a meaningless flood, not a real defect. An
|
|
2782
|
+
// unresolved dep is already surfaced as UNVERIFIED below (e.g. a fixture pinning an as-yet-unpublished
|
|
2783
|
+
// nomos_client — it verifies for real the moment that version lands).
|
|
2784
|
+
if (!dartMissing && unresolved === undefined && inPackage.length > 0) {
|
|
2785
|
+
try {
|
|
2786
|
+
execFileSync("dart", ["analyze", "--no-fatal-warnings", ...inPackage], { stdio: "pipe", encoding: "utf8" });
|
|
2787
|
+
} catch (e) {
|
|
2788
|
+
const err = e as { code?: string; stdout?: string; stderr?: string };
|
|
2789
|
+
if (err.code === "ENOENT") { dartMissing = true; }
|
|
2790
|
+
else {
|
|
2791
|
+
const out = `${err.stdout ?? ""}${err.stderr ?? ""}`.trim();
|
|
2792
|
+
fail(`the generated Dart does NOT compile — \`dart analyze\` found errors (compile-valid must mean compilable). Fix the law/codegen or set NOMOS_SKIP_DART_ANALYZE=1 to emit anyway:\n${out}`);
|
|
2793
|
+
}
|
|
2794
|
+
}
|
|
2795
|
+
}
|
|
2796
|
+
if (dartMissing) {
|
|
2797
|
+
// No `dart` on PATH — we genuinely cannot verify. Say so LOUDLY (never a `✓`), so a broken client is
|
|
2798
|
+
// NOT reported as a clean compile; installing dart turns this into a real gate.
|
|
2799
|
+
console.log(` dart ⚠ UNVERIFIED — \`dart\` is not on PATH, so the generated Dart could NOT be analyzed (install dart to gate it)`);
|
|
2800
|
+
} else if (unresolved !== undefined) {
|
|
2801
|
+
console.log(` dart ⚠ UNVERIFIED — \`dart pub get\` failed, so analysis is incomplete (${unresolved})`);
|
|
2802
|
+
} else if (inPackage.length > 0) {
|
|
2803
|
+
console.log(` dart ✓ generated Dart analyzes clean (compile-valid ⇒ compilable)`);
|
|
2804
|
+
} else if (emittedDart.length > 0) {
|
|
2805
|
+
console.log(` dart — vendored (no pub package) emit — runtime package imports are unverifiable without a pubspec; analysis skipped (use dart.packages for a fully-gated pub package)`);
|
|
2700
2806
|
}
|
|
2701
2807
|
}
|
|
2702
2808
|
}
|
package/src/fields.ts
CHANGED
|
@@ -170,7 +170,7 @@ export type ValueObjectFieldDescriptor =
|
|
|
170
170
|
/** A plain string, OR a BRANDED nominal string when `brand` is present (a `brandedId(...)`
|
|
171
171
|
* — the generated TS branded alias / Dart extension-type wrapper's name). Wire-identical
|
|
172
172
|
* to a plain string; the brand is a compile-time nominal tag only. */
|
|
173
|
-
| { readonly type: "string"; readonly brand?: string; readonly optional?: true }
|
|
173
|
+
| { readonly type: "string"; readonly brand?: string; readonly idOf?: string; readonly optional?: true }
|
|
174
174
|
/** `int: true` when the zod carried `.int()` (Dart `int`, TS `number`); `unit` when the field
|
|
175
175
|
* was declared through `unit(...)` — the generated Dart extension-type wrapper's name. */
|
|
176
176
|
| { readonly type: "number"; readonly int?: true; readonly unit?: string; readonly optional?: true }
|
package/src/value_object.ts
CHANGED
|
@@ -362,16 +362,16 @@ function describeZod(
|
|
|
362
362
|
if (ownBrand !== undefined) return takeVoField(ownBrand as ValueObjectFieldMeta, deps);
|
|
363
363
|
switch (zodKind(schema)) {
|
|
364
364
|
case "string": {
|
|
365
|
-
const
|
|
366
|
-
|
|
367
|
-
// idOf
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
return
|
|
365
|
+
const brandedBrand = (schema as unknown as Record<symbol, unknown>)[NOMOS_BRANDED_ID_BRAND];
|
|
366
|
+
const idOfAgg = (schema as unknown as Record<symbol, unknown>)[NOMOS_IDOF_BRAND];
|
|
367
|
+
// idOf(Agg) REFERENCES the aggregate's canonical typed-id class `<Agg>Id` (emitted ONCE by the
|
|
368
|
+
// aggregate) — it must NOT re-declare a standalone extension type (that duplicated the id symbol and
|
|
369
|
+
// stripped its `.fromRow`, co2). Carry `idOf` so the emitter references + imports it and skips the
|
|
370
|
+
// brand-extension-type emission. A standalone `brandedId("X")` still declares its own extension type.
|
|
371
|
+
if (typeof idOfAgg === "string") {
|
|
372
|
+
return { type: "string", brand: `${idOfAgg}Id`, idOf: idOfAgg };
|
|
373
|
+
}
|
|
374
|
+
return typeof brandedBrand === "string" ? { type: "string", brand: brandedBrand } : { type: "string" };
|
|
375
375
|
}
|
|
376
376
|
case "record": {
|
|
377
377
|
if (zodRecordKeyKind(schema) !== "string") {
|