@githolon/dsl 0.10.0 → 0.12.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/example/nomos_webview_transport.dart +63 -0
- package/dart/lib/nomos_dsl.dart +3 -0
- package/dart/lib/src/webview_bridge.dart +161 -0
- package/dart/test/webview_bridge_test.dart +225 -0
- package/package.json +1 -1
- package/src/codegen_dart.ts +1 -1
- package/src/codegen_dart_client.ts +219 -0
- package/src/compile_package_main.ts +21 -4
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// COPY THIS INTO YOUR FLUTTER APP. It is an EXAMPLE, not part of the `nomos_dsl` library: it depends on
|
|
2
|
+
// `webview_flutter`, which the pure-Dart `nomos_dsl` package deliberately does NOT depend on (so the bridge
|
|
3
|
+
// protocol logic stays Flutter-free and unit-testable). This ~50-line transport is the only app-specific glue.
|
|
4
|
+
//
|
|
5
|
+
// It implements `BridgeTransport` (from `package:nomos_dsl/nomos_dsl.dart`) over a `webview_flutter`
|
|
6
|
+
// WebViewController loading the published `nomos-runner` bundle:
|
|
7
|
+
// • host → runner: `controller.runJavaScript("window.__nomosSend(<json>)")`
|
|
8
|
+
// • runner → host: a `JavaScriptChannel("NomosHost")` whose onMessage feeds the bridge's message stream.
|
|
9
|
+
//
|
|
10
|
+
// WIRING (in your widget's initState):
|
|
11
|
+
// final controller = WebViewController()
|
|
12
|
+
// ..setJavaScriptMode(JavaScriptMode.unrestricted);
|
|
13
|
+
// final transport = WebViewTransport(controller); // registers the NomosHost channel
|
|
14
|
+
// await controller.loadFlutterAsset('assets/nomos/nomos-runner.html');
|
|
15
|
+
// final bridge = NomosBridge(transport);
|
|
16
|
+
// await bridge.ready; // resolves on the runner's {event:ready}
|
|
17
|
+
// await bridge.connect(cloud: 'https://nomos.captainapp.co.uk', workspace: myWs, clientId: deviceId);
|
|
18
|
+
// final head = await bridge.dispatch(domain: 'guestbook', directiveId: 'signGuestbook',
|
|
19
|
+
// payload: {'author': 'ada', 'message': 'hi', 'mood': 'delighted'}, domainHash: GUESTBOOK_DOMAIN_HASH);
|
|
20
|
+
// final rows = await bridge.query('entriesByAuthor', {'author': 'ada'});
|
|
21
|
+
//
|
|
22
|
+
// (Put the runner bundle at `assets/nomos/nomos-runner.{html,js}` and declare it under `flutter: assets:`.)
|
|
23
|
+
|
|
24
|
+
import 'dart:async';
|
|
25
|
+
import 'dart:convert';
|
|
26
|
+
|
|
27
|
+
import 'package:nomos_dsl/nomos_dsl.dart';
|
|
28
|
+
import 'package:webview_flutter/webview_flutter.dart';
|
|
29
|
+
|
|
30
|
+
/// A [BridgeTransport] over a `webview_flutter` controller hosting the `nomos-runner` bundle.
|
|
31
|
+
class WebViewTransport implements BridgeTransport {
|
|
32
|
+
final WebViewController controller;
|
|
33
|
+
final _incoming = StreamController<Map<String, Object?>>.broadcast();
|
|
34
|
+
|
|
35
|
+
WebViewTransport(this.controller) {
|
|
36
|
+
// runner → host: the bundle posts JSON strings on the "NomosHost" channel.
|
|
37
|
+
controller.addJavaScriptChannel(
|
|
38
|
+
'NomosHost',
|
|
39
|
+
onMessageReceived: (JavaScriptMessage m) {
|
|
40
|
+
try {
|
|
41
|
+
final decoded = jsonDecode(m.message);
|
|
42
|
+
if (decoded is Map) _incoming.add(decoded.cast<String, Object?>());
|
|
43
|
+
} catch (_) {/* a non-JSON console line — ignore */}
|
|
44
|
+
},
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
@override
|
|
49
|
+
Stream<Map<String, Object?>> get messages => _incoming.stream;
|
|
50
|
+
|
|
51
|
+
@override
|
|
52
|
+
void send(Map<String, Object?> message) {
|
|
53
|
+
// host → runner: hand the JSON to the bundle. Double-encode so it survives as a JS string literal.
|
|
54
|
+
final json = jsonEncode(message);
|
|
55
|
+
controller.runJavaScript('window.__nomosSend(${jsonEncode(json)})');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/// Call once, right after the page finishes loading (e.g. from `NavigationDelegate.onPageFinished`), so the
|
|
59
|
+
/// runner flushes any message (the initial `{event:"ready"}`) it emitted before this channel was wired.
|
|
60
|
+
void flushReady() => controller.runJavaScript('window.__nomosReady && window.__nomosReady()');
|
|
61
|
+
|
|
62
|
+
void dispose() => _incoming.close();
|
|
63
|
+
}
|
package/dart/lib/nomos_dsl.dart
CHANGED
|
@@ -28,6 +28,9 @@ export 'src/driver.dart';
|
|
|
28
28
|
export 'src/dispatch.dart';
|
|
29
29
|
export 'src/subscriptions.dart';
|
|
30
30
|
export 'src/schema_validation.dart';
|
|
31
|
+
// THE WEBVIEW BRIDGE (app-binding boundary) — the async Dart runtime that drives the REAL githolon running
|
|
32
|
+
// in a WebView, so the generated typed client never needs a `MemoryPersistence` fake.
|
|
33
|
+
export 'src/webview_bridge.dart';
|
|
31
34
|
// The PROVIDER boundary (server-side capability-provider SDK runtime: TaskOutcome /
|
|
32
35
|
// TaskSuccess / TaskFailure / TaskResult / TaskProvider / HostClock). The symmetric twin
|
|
33
36
|
// of `dispatch.dart`; the generated per-task provider classes build on it.
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// NOMOS — Nomos Sovereign: participants act · verify · remember LOCALLY; hosted
|
|
2
|
+
// remotes are replaceable custody/transport, not truth. ⇒ ONE Nomos GitHolon
|
|
3
|
+
// wasm32-wasip1 artifact {kernel · projection · embedded QuickJS engine}.
|
|
4
|
+
// If a file isn't this / hosting this / authoring for this / proving this — it's gone.
|
|
5
|
+
|
|
6
|
+
/// THE WEBVIEW BRIDGE — the Dart half of the app-binding boundary. A Flutter app talks to the REAL githolon
|
|
7
|
+
/// (the `@githolon/client` runner running in a WebView on mobile / the same page on Flutter web) over ONE
|
|
8
|
+
/// stable message protocol, so the generated typed Dart client drives the real kernel — never a
|
|
9
|
+
/// `MemoryPersistence` pretending to be a GitHolon.
|
|
10
|
+
///
|
|
11
|
+
/// A WebView round-trip is ASYNC, so the bridge is async (unlike the sync FRB `Authorer` in `dispatch.dart`).
|
|
12
|
+
/// The generated per-domain Dart client wraps a [NomosBridge]; the bridge owns request/reply correlation +
|
|
13
|
+
/// reactive `watch` streams. The platform TRANSPORT ([BridgeTransport]) is injected — `dart:js_util` on web,
|
|
14
|
+
/// a WebView JS-channel on mobile — so this protocol logic is pure Dart + fully unit-testable.
|
|
15
|
+
library;
|
|
16
|
+
|
|
17
|
+
import 'dart:async';
|
|
18
|
+
|
|
19
|
+
/// The protocol version the runner and the bridge agree on (`webview-runner.mjs` `WEBVIEW_BRIDGE_VERSION`).
|
|
20
|
+
const int kWebviewBridgeVersion = 1;
|
|
21
|
+
|
|
22
|
+
/// The platform channel to the WebView runner: send a JSON-shaped message; receive the runner's messages.
|
|
23
|
+
/// Web impl jsifies/dartifies over `dart:js_util`; mobile impl JSON-encodes over a WebView JS-channel. The
|
|
24
|
+
/// bridge treats messages as plain `Map<String, Object?>` — the transport owns the channel serialization.
|
|
25
|
+
abstract interface class BridgeTransport {
|
|
26
|
+
void send(Map<String, Object?> message);
|
|
27
|
+
Stream<Map<String, Object?>> get messages;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/// A typed refusal surfaced from the githolon's gate (a law refusal, a drift, a malformed op). Carries the
|
|
31
|
+
/// human-first message verbatim — the generated client lets it throw, loudly, exactly like the TS client.
|
|
32
|
+
class NomosBridgeError implements Exception {
|
|
33
|
+
final String message;
|
|
34
|
+
NomosBridgeError(this.message);
|
|
35
|
+
@override
|
|
36
|
+
String toString() => 'NomosBridgeError: $message';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/// THE BRIDGE. Construct over a [BridgeTransport], `await ready`, then `connect(...)` and drive the holon.
|
|
40
|
+
class NomosBridge {
|
|
41
|
+
final BridgeTransport _transport;
|
|
42
|
+
final Map<int, Completer<Object?>> _pending = {};
|
|
43
|
+
final Map<String, StreamController<List<dynamic>>> _watches = {};
|
|
44
|
+
final Completer<void> _ready = Completer<void>();
|
|
45
|
+
int _seq = 0;
|
|
46
|
+
late final StreamSubscription<Map<String, Object?>> _sub;
|
|
47
|
+
|
|
48
|
+
NomosBridge(this._transport) {
|
|
49
|
+
_sub = _transport.messages.listen(_onMessage);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/// Completes once the runner has posted `{ event: "ready" }` (the wasm + client are booted).
|
|
53
|
+
Future<void> get ready => _ready.future;
|
|
54
|
+
|
|
55
|
+
void _onMessage(Map<String, Object?> m) {
|
|
56
|
+
if (m['v'] != kWebviewBridgeVersion) return; // a foreign-version message is ignored (forward-compatible)
|
|
57
|
+
final event = m['event'];
|
|
58
|
+
if (event == 'ready') {
|
|
59
|
+
if (!_ready.isCompleted) _ready.complete();
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (event == 'watch') {
|
|
63
|
+
final ctrl = _watches[m['subId']];
|
|
64
|
+
if (ctrl != null && !ctrl.isClosed) ctrl.add((m['rows'] as List?) ?? const []);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
final id = m['id'];
|
|
68
|
+
if (id is! int) return;
|
|
69
|
+
final completer = _pending.remove(id);
|
|
70
|
+
if (completer == null) return;
|
|
71
|
+
if (m['ok'] == true) {
|
|
72
|
+
completer.complete(m['result']);
|
|
73
|
+
} else {
|
|
74
|
+
completer.completeError(NomosBridgeError((m['error'] ?? 'the githolon refused the request').toString()));
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/// Send one request and await the typed reply (the runner answers ok/error). The ONLY low-level call —
|
|
79
|
+
/// every typed method below is a thin wrapper, so the request/reply contract lives in exactly one place.
|
|
80
|
+
Future<Object?> _call(String op, [Map<String, Object?> args = const {}]) {
|
|
81
|
+
final id = ++_seq;
|
|
82
|
+
final completer = Completer<Object?>();
|
|
83
|
+
_pending[id] = completer;
|
|
84
|
+
_transport.send({'v': kWebviewBridgeVersion, 'id': id, 'op': op, 'args': args});
|
|
85
|
+
return completer.future;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
Map<String, Object?> _asMap(Object? v) => (v as Map?)?.cast<String, Object?>() ?? const {};
|
|
89
|
+
List<Map<String, dynamic>> _asRows(Object? v) =>
|
|
90
|
+
((_asMap(v)['rows'] as List?) ?? const []).map((e) => (e as Map).cast<String, dynamic>()).toList();
|
|
91
|
+
|
|
92
|
+
// ── the runtime-aware surface (the boundary the generated Dart client wraps) ──
|
|
93
|
+
|
|
94
|
+
/// Open the user/estate workspace: pull its ledger, boot the local holon under the installed law.
|
|
95
|
+
Future<void> connect({required String cloud, required String workspace, String? clientId, String? authToken, String? restoreFrom}) async {
|
|
96
|
+
await _call('connect', {'cloud': cloud, 'workspace': workspace, if (clientId != null) 'clientId': clientId, if (authToken != null) 'authToken': authToken, if (restoreFrom != null) 'restoreFrom': restoreFrom});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/// LOCAL-FIRST write: author `directiveId` under `domainHash`. Returns the new local head; durable on [sync].
|
|
100
|
+
Future<String> dispatch({required String domain, required String directiveId, required Map<String, Object?> payload, required String domainHash}) async {
|
|
101
|
+
final r = _asMap(await _call('dispatch', {'domain': domain, 'directiveId': directiveId, 'payload': payload, 'domainHash': domainHash}));
|
|
102
|
+
return r['head'].toString();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/// §5 cross-custody delivery: a `recordShare` carrying a pre-signed §3 quote into a home holon.
|
|
106
|
+
Future<String> deliverShare({required Map<String, Object?> quote, required String domainHash}) async {
|
|
107
|
+
final r = _asMap(await _call('deliverShare', {'quote': quote, 'domainHash': domainHash}));
|
|
108
|
+
return r['head'].toString();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/// A declared query's rows from the local projection.
|
|
112
|
+
Future<List<Map<String, dynamic>>> query(String queryId, [Map<String, Object?> params = const {}]) async =>
|
|
113
|
+
_asRows(await _call('query', {'queryId': queryId, 'params': params}));
|
|
114
|
+
|
|
115
|
+
/// A single aggregate by id (the by-id read).
|
|
116
|
+
Future<List<Map<String, dynamic>>> queryById(String id) async => _asRows(await _call('queryById', {'id': id}));
|
|
117
|
+
|
|
118
|
+
/// A maintained O(1) count / sum.
|
|
119
|
+
Future<int> count(String countId, [String group = '']) async => ((_asMap(await _call('count', {'countId': countId, 'group': group}))['count']) as num).toInt();
|
|
120
|
+
Future<int> sum(String sumId, [String group = '']) async => ((_asMap(await _call('sum', {'sumId': sumId, 'group': group}))['sum']) as num).toInt();
|
|
121
|
+
|
|
122
|
+
/// Push local writes → the session branch → cloud admission, and pull canonical main back.
|
|
123
|
+
Future<Map<String, Object?>> sync({bool admit = true}) async => _asMap(await _call('sync', {'admit': admit}));
|
|
124
|
+
|
|
125
|
+
/// Diagnostics — is the expected law installed/active/drift-free here? (The generated client surfaces it
|
|
126
|
+
/// so the app can answer "can I author this directive, and if not what deploy fixes it".)
|
|
127
|
+
Future<Map<String, Object?>> lawStatus(String hash) async => _asMap(await _call('lawStatus', {'hash': hash}));
|
|
128
|
+
|
|
129
|
+
/// Reactive local read: a stream of row-sets that re-emits on every local change. Cancelling the
|
|
130
|
+
/// subscription tells the runner to drop the watch.
|
|
131
|
+
Stream<List<dynamic>> watch(String queryId, [Map<String, Object?> params = const {}]) =>
|
|
132
|
+
_watchStream(kind: 'query', queryId: queryId, params: params);
|
|
133
|
+
Stream<List<dynamic>> watchById(String id) => _watchStream(kind: 'byId', id: id);
|
|
134
|
+
|
|
135
|
+
Stream<List<dynamic>> _watchStream({required String kind, String? queryId, Map<String, Object?>? params, String? id}) {
|
|
136
|
+
final subId = 'w${++_seq}';
|
|
137
|
+
late final StreamController<List<dynamic>> ctrl;
|
|
138
|
+
ctrl = StreamController<List<dynamic>>(
|
|
139
|
+
onListen: () => _call('watch', {'subId': subId, 'kind': kind, if (queryId != null) 'queryId': queryId, if (params != null) 'params': params, if (id != null) 'id': id}),
|
|
140
|
+
onCancel: () async {
|
|
141
|
+
_watches.remove(subId);
|
|
142
|
+
try { await _call('unwatch', {'subId': subId}); } catch (_) {}
|
|
143
|
+
},
|
|
144
|
+
);
|
|
145
|
+
_watches[subId] = ctrl;
|
|
146
|
+
return ctrl.stream;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/// Tear down: stop the transport subscription + fail any in-flight calls.
|
|
150
|
+
Future<void> dispose() async {
|
|
151
|
+
await _sub.cancel();
|
|
152
|
+
for (final c in _pending.values) {
|
|
153
|
+
if (!c.isCompleted) c.completeError(NomosBridgeError('bridge disposed'));
|
|
154
|
+
}
|
|
155
|
+
_pending.clear();
|
|
156
|
+
for (final w in _watches.values) {
|
|
157
|
+
if (!w.isClosed) await w.close();
|
|
158
|
+
}
|
|
159
|
+
_watches.clear();
|
|
160
|
+
}
|
|
161
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// NOMOS — the WEBVIEW BRIDGE protocol test (the Dart half of the app-binding boundary). A FAKE transport
|
|
2
|
+
// stands in for the WebView runner (`webview-runner.mjs`): it auto-answers each request from a stub holon,
|
|
3
|
+
// exactly as the real runner would, so the bridge's request/reply correlation, reactive `watch` streams, and
|
|
4
|
+
// error framing are proven in pure Dart — no browser, no MemoryPersistence. The bridge and the JS runner are
|
|
5
|
+
// the two ends of ONE protocol (`kWebviewBridgeVersion`).
|
|
6
|
+
import 'dart:async';
|
|
7
|
+
import 'dart:convert';
|
|
8
|
+
import 'dart:io';
|
|
9
|
+
|
|
10
|
+
import 'package:nomos_dsl/nomos_dsl.dart';
|
|
11
|
+
import 'package:test/test.dart';
|
|
12
|
+
|
|
13
|
+
/// A fake runner-over-a-transport: records sent requests, replies the way `webview-runner.mjs` does, and can
|
|
14
|
+
/// push reactive `watch` events. Lets us drive [NomosBridge] without a real WebView.
|
|
15
|
+
class FakeRunner implements BridgeTransport {
|
|
16
|
+
final _incoming = StreamController<Map<String, Object?>>.broadcast();
|
|
17
|
+
final List<Map<String, Object?>> sent = [];
|
|
18
|
+
String? lastWatchSubId;
|
|
19
|
+
|
|
20
|
+
FakeRunner() {
|
|
21
|
+
// the runner announces readiness at construction.
|
|
22
|
+
scheduleMicrotask(() => _incoming.add({'v': kWebviewBridgeVersion, 'event': 'ready'}));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
@override
|
|
26
|
+
Stream<Map<String, Object?>> get messages => _incoming.stream;
|
|
27
|
+
|
|
28
|
+
@override
|
|
29
|
+
void send(Map<String, Object?> message) {
|
|
30
|
+
sent.add(message);
|
|
31
|
+
final id = message['id'] as int;
|
|
32
|
+
final op = message['op'] as String;
|
|
33
|
+
final args = (message['args'] as Map).cast<String, Object?>();
|
|
34
|
+
// reply exactly as the runner's ops do (the contract both ends share).
|
|
35
|
+
Map<String, Object?> reply(Object? result) => {'v': kWebviewBridgeVersion, 'id': id, 'ok': true, 'result': result};
|
|
36
|
+
scheduleMicrotask(() {
|
|
37
|
+
switch (op) {
|
|
38
|
+
case 'connect': _incoming.add(reply({'workspace': args['workspace']})); break;
|
|
39
|
+
case 'dispatch':
|
|
40
|
+
// a directive the law refuses answers ok:false (the runner frames the gate's verdict).
|
|
41
|
+
if (args['directiveId'] == 'refuseMe') {
|
|
42
|
+
_incoming.add({'v': kWebviewBridgeVersion, 'id': id, 'ok': false, 'error': 'the law refused this intent'});
|
|
43
|
+
} else {
|
|
44
|
+
_incoming.add(reply({'head': 'HEAD-${args['directiveId']}'}));
|
|
45
|
+
}
|
|
46
|
+
break;
|
|
47
|
+
case 'deliverShare': _incoming.add(reply({'head': 'HEAD-share'})); break;
|
|
48
|
+
case 'query': _incoming.add(reply({'rows': [{'id': 'a', 'data': {'q': args['queryId']}}]})); break;
|
|
49
|
+
case 'queryById': _incoming.add(reply({'rows': [{'id': args['id'], 'data': {'ok': true}}]})); break;
|
|
50
|
+
case 'count': _incoming.add(reply({'count': 7})); break;
|
|
51
|
+
case 'sync': _incoming.add(reply({'pushed': true})); break;
|
|
52
|
+
case 'lawStatus': _incoming.add(reply({'installed': true, 'drifted': false})); break;
|
|
53
|
+
case 'badop': _incoming.add({'v': kWebviewBridgeVersion, 'id': id, 'ok': false, 'error': 'the law refused this intent'}); break;
|
|
54
|
+
case 'watch': lastWatchSubId = args['subId'] as String; _incoming.add(reply({'subId': args['subId']})); break;
|
|
55
|
+
case 'unwatch': _incoming.add(reply({'subId': args['subId']})); break;
|
|
56
|
+
default: _incoming.add({'v': kWebviewBridgeVersion, 'id': id, 'ok': false, 'error': "unknown op '$op'"});
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/// Simulate the holon firing a reactive change to an active watch.
|
|
62
|
+
void pushWatch(String subId, List<dynamic> rows) => _incoming.add({'v': kWebviewBridgeVersion, 'event': 'watch', 'subId': subId, 'rows': rows});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/// A transport that answers EVERY request with one canned `result` (the golden corpus's reply) and records
|
|
66
|
+
/// the request the bridge sent — so we can assert both halves of the wire contract from the Dart side.
|
|
67
|
+
class ReplayTransport implements BridgeTransport {
|
|
68
|
+
final Map<String, Object?> result;
|
|
69
|
+
final _incoming = StreamController<Map<String, Object?>>.broadcast();
|
|
70
|
+
Map<String, Object?>? lastRequest;
|
|
71
|
+
ReplayTransport(this.result) {
|
|
72
|
+
scheduleMicrotask(() => _incoming.add({'v': kWebviewBridgeVersion, 'event': 'ready'}));
|
|
73
|
+
}
|
|
74
|
+
@override
|
|
75
|
+
Stream<Map<String, Object?>> get messages => _incoming.stream;
|
|
76
|
+
@override
|
|
77
|
+
void send(Map<String, Object?> message) {
|
|
78
|
+
lastRequest = message;
|
|
79
|
+
final id = message['id'];
|
|
80
|
+
scheduleMicrotask(() => _incoming.add({'v': kWebviewBridgeVersion, 'id': id, 'ok': true, 'result': result}));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/// Load the shared golden corpus (the same file the JS runner test replays). Probed across the candidate
|
|
85
|
+
/// working dirs `dart test` may run from, so it doesn't depend on one invocation cwd.
|
|
86
|
+
Map<String, Object?> _loadCorpus() {
|
|
87
|
+
const candidates = [
|
|
88
|
+
'../../cloud/web-client/test/fixtures/bridge-protocol.golden.json', // cwd = dsl/dart (the usual)
|
|
89
|
+
'cloud/web-client/test/fixtures/bridge-protocol.golden.json', // cwd = repo root
|
|
90
|
+
'../../../cloud/web-client/test/fixtures/bridge-protocol.golden.json',
|
|
91
|
+
];
|
|
92
|
+
for (final p in candidates) {
|
|
93
|
+
final f = File(p);
|
|
94
|
+
if (f.existsSync()) return (jsonDecode(f.readAsStringSync()) as Map).cast<String, Object?>();
|
|
95
|
+
}
|
|
96
|
+
fail('golden corpus not found from ${Directory.current.path}; tried $candidates');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/// Drive one corpus op through the typed bridge method and return its typed result (so we can compare it to
|
|
100
|
+
/// what the corpus says the unwrapped value should be).
|
|
101
|
+
Future<Object?> _invoke(NomosBridge b, String op, Map<String, Object?> a) async {
|
|
102
|
+
switch (op) {
|
|
103
|
+
case 'connect':
|
|
104
|
+
await b.connect(cloud: a['cloud'] as String, workspace: a['workspace'] as String, clientId: a['clientId'] as String?);
|
|
105
|
+
return null;
|
|
106
|
+
case 'dispatch':
|
|
107
|
+
return b.dispatch(domain: a['domain'] as String, directiveId: a['directiveId'] as String, payload: (a['payload'] as Map).cast<String, Object?>(), domainHash: a['domainHash'] as String);
|
|
108
|
+
case 'deliverShare':
|
|
109
|
+
return b.deliverShare(quote: (a['quote'] as Map).cast<String, Object?>(), domainHash: a['domainHash'] as String);
|
|
110
|
+
case 'query':
|
|
111
|
+
return b.query(a['queryId'] as String, (a['params'] as Map).cast<String, Object?>());
|
|
112
|
+
case 'queryById':
|
|
113
|
+
return b.queryById(a['id'] as String);
|
|
114
|
+
case 'count':
|
|
115
|
+
return b.count(a['countId'] as String, a['group'] as String);
|
|
116
|
+
case 'sum':
|
|
117
|
+
return b.sum(a['sumId'] as String, a['group'] as String);
|
|
118
|
+
case 'sync':
|
|
119
|
+
return b.sync(admit: a['admit'] as bool);
|
|
120
|
+
case 'lawStatus':
|
|
121
|
+
return b.lawStatus(a['hash'] as String);
|
|
122
|
+
default:
|
|
123
|
+
fail('corpus op not wired into the Dart bridge: $op');
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/// What the corpus says the typed (unwrapped) return value should be, given the reply's `result`.
|
|
128
|
+
Object? _expectedTyped(String op, Map<String, Object?> result) {
|
|
129
|
+
switch (op) {
|
|
130
|
+
case 'dispatch':
|
|
131
|
+
case 'deliverShare':
|
|
132
|
+
return result['head'];
|
|
133
|
+
case 'query':
|
|
134
|
+
case 'queryById':
|
|
135
|
+
return result['rows'];
|
|
136
|
+
case 'count':
|
|
137
|
+
return result['count'];
|
|
138
|
+
case 'sum':
|
|
139
|
+
return result['sum'];
|
|
140
|
+
case 'sync':
|
|
141
|
+
case 'lawStatus':
|
|
142
|
+
return result;
|
|
143
|
+
default:
|
|
144
|
+
return null; // connect: void
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
void main() {
|
|
149
|
+
// ── THE GOLDEN CORPUS — the cross-language conformance the JS runner replays too ──
|
|
150
|
+
// For each case: call the typed bridge method with the corpus `args`, then assert (1) the request the bridge
|
|
151
|
+
// SENT carries exactly those args, and (2) the typed value the bridge parsed from the corpus `result` matches.
|
|
152
|
+
// The JS runner asserts the SAME corpus from the other side — so neither end can drift the wire alone.
|
|
153
|
+
group('golden corpus (shared with the JS runner)', () {
|
|
154
|
+
final corpus = _loadCorpus();
|
|
155
|
+
final cases = (corpus['cases'] as List).cast<Map<String, Object?>>();
|
|
156
|
+
test('version pins the bridge version', () => expect(corpus['version'], kWebviewBridgeVersion));
|
|
157
|
+
for (final c in cases) {
|
|
158
|
+
final op = c['op'] as String;
|
|
159
|
+
final args = (c['args'] as Map).cast<String, Object?>();
|
|
160
|
+
final result = (c['result'] as Map).cast<String, Object?>();
|
|
161
|
+
test("'$op' request args + parsed result match the corpus", () async {
|
|
162
|
+
final transport = ReplayTransport(result);
|
|
163
|
+
final bridge = NomosBridge(transport);
|
|
164
|
+
await bridge.ready;
|
|
165
|
+
final typed = await _invoke(bridge, op, args);
|
|
166
|
+
expect((transport.lastRequest!['args'] as Map).cast<String, Object?>(), equals(args), reason: 'sent request args');
|
|
167
|
+
if (op != 'connect') expect(typed, equals(_expectedTyped(op, result)), reason: 'parsed typed result');
|
|
168
|
+
await bridge.dispose();
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
late FakeRunner runner;
|
|
174
|
+
late NomosBridge bridge;
|
|
175
|
+
setUp(() { runner = FakeRunner(); bridge = NomosBridge(runner); });
|
|
176
|
+
tearDown(() => bridge.dispose());
|
|
177
|
+
|
|
178
|
+
test('ready completes once the runner boots', () async {
|
|
179
|
+
await bridge.ready.timeout(const Duration(seconds: 1));
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test('dispatch round-trips a typed head; args reach the runner verbatim', () async {
|
|
183
|
+
await bridge.ready;
|
|
184
|
+
final head = await bridge.dispatch(domain: 'guestbook', directiveId: 'signGuestbook', payload: {'author': 'ada'}, domainHash: 'abc');
|
|
185
|
+
expect(head, 'HEAD-signGuestbook');
|
|
186
|
+
final req = runner.sent.firstWhere((m) => m['op'] == 'dispatch');
|
|
187
|
+
expect((req['args'] as Map)['payload'], {'author': 'ada'});
|
|
188
|
+
expect((req['args'] as Map)['domainHash'], 'abc');
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test('connect / deliverShare / query / queryById / count / sync / lawStatus all wrap the runner', () async {
|
|
192
|
+
await bridge.ready;
|
|
193
|
+
await bridge.connect(cloud: 'https://c', workspace: 'ws', clientId: 'c1');
|
|
194
|
+
expect(await bridge.deliverShare(quote: {'publicKey': 'k'}, domainHash: 'h'), 'HEAD-share');
|
|
195
|
+
expect((await bridge.query('byAuthor', {'author': 'ada'})).first['data'], {'q': 'byAuthor'});
|
|
196
|
+
expect((await bridge.queryById('Entry_1')).first['id'], 'Entry_1');
|
|
197
|
+
expect(await bridge.count('entries'), 7);
|
|
198
|
+
expect(await bridge.sync(), {'pushed': true});
|
|
199
|
+
expect(await bridge.lawStatus('abc'), {'installed': true, 'drifted': false});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test('watch is a reactive stream; a runner push reaches the listener; cancel unwatches', () async {
|
|
203
|
+
await bridge.ready;
|
|
204
|
+
final got = <List<dynamic>>[];
|
|
205
|
+
final sub = bridge.watch('byAuthor', {'author': 'ada'}).listen(got.add);
|
|
206
|
+
await Future<void>.delayed(Duration.zero); // let the watch register
|
|
207
|
+
runner.pushWatch(runner.lastWatchSubId!, [{'id': 'a', 'data': {'n': 2}}]);
|
|
208
|
+
await Future<void>.delayed(Duration.zero);
|
|
209
|
+
expect(got, [[{'id': 'a', 'data': {'n': 2}}]]);
|
|
210
|
+
await sub.cancel();
|
|
211
|
+
expect(runner.sent.any((m) => m['op'] == 'unwatch'), isTrue);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test('a law refusal throws NomosBridgeError with the verbatim message', () async {
|
|
215
|
+
await bridge.ready;
|
|
216
|
+
Object? err;
|
|
217
|
+
try {
|
|
218
|
+
await bridge.dispatch(domain: 'x', directiveId: 'refuseMe', payload: {}, domainHash: 'h');
|
|
219
|
+
} on NomosBridgeError catch (e) {
|
|
220
|
+
err = e;
|
|
221
|
+
}
|
|
222
|
+
expect(err, isA<NomosBridgeError>());
|
|
223
|
+
expect((err as NomosBridgeError).message, contains('law refused'));
|
|
224
|
+
});
|
|
225
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@githolon/dsl",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.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
|
@@ -1265,7 +1265,7 @@ function parseField(s: AggFieldSpec, aggregate: string): string {
|
|
|
1265
1265
|
* `SiteRootAggregate` → `Site`) and append `ReadModel` (`SiteReadModel`,
|
|
1266
1266
|
* `BuildingReadModel`, `ThingReadModel`, `TrackableThingReadModel`). Pure + deterministic
|
|
1267
1267
|
* — the same wire type always yields the same name. */
|
|
1268
|
-
function readModelClassName(aggregateType: string): string {
|
|
1268
|
+
export function readModelClassName(aggregateType: string): string {
|
|
1269
1269
|
let stem = aggregateType;
|
|
1270
1270
|
if (stem.endsWith("RootAggregate")) {
|
|
1271
1271
|
stem = stem.slice(0, -"RootAggregate".length);
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// NOMOS — Nomos Sovereign: participants act · verify · remember LOCALLY; hosted
|
|
2
|
+
// remotes are replaceable custody/transport, not truth. ⇒ ONE Nomos GitHolon
|
|
3
|
+
// wasm32-wasip1 artifact {kernel · projection · embedded QuickJS engine}.
|
|
4
|
+
// If a file isn't this / hosting this / authoring for this / proving this — it's gone.
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* ASYNC DART BRIDGE-CLIENT CODEGEN (#61) — the Flutter sibling of `codegen_ts.ts`.
|
|
8
|
+
*
|
|
9
|
+
* `codegen_dart.ts` emits the per-domain TYPES: read-model classes (`…ReadModel.fromProjected`),
|
|
10
|
+
* typed intent DTOs (`…Payload.toPayloadJson()`), typed ids, enums. Until this file a Flutter app
|
|
11
|
+
* still hand-typed directive ids + query ids against the raw `NomosBridge` (string + Map). This closes
|
|
12
|
+
* that: ONE generated `<name>.client.dart` per domain, emitted by `nomos-compile` AFTER the package is
|
|
13
|
+
* hashed (the deployed law's content hash baked in), carrying a `<Domain>Client` over a connected
|
|
14
|
+
* [NomosBridge] (the WebView runner) with:
|
|
15
|
+
*
|
|
16
|
+
* * a typed DISPATCH method per directive — takes the generated `…Payload`, serialises via its
|
|
17
|
+
* `toPayloadJson()`, auto-stamps omitted `…At` timestamps (the same caller-clock sugar the TS
|
|
18
|
+
* client applies), returns the new local head;
|
|
19
|
+
* * a typed declared-QUERY accessor per `query(...)` — one-shot `Future<List<…ReadModel>>` + a
|
|
20
|
+
* reactive `watch…` `Stream<List<…ReadModel>>`, parsed off the bridge's `{type,id,data}` rows;
|
|
21
|
+
* * by-id read/watch + a type-index `list…` per aggregate;
|
|
22
|
+
* * typed O(1) count/sum accessors.
|
|
23
|
+
*
|
|
24
|
+
* It IMPORTS the sibling `<name>.dart` (for the read-model + payload classes) and the vendored
|
|
25
|
+
* `nomos_dsl.dart` barrel (for `NomosBridge`). NO manifest/hash impact — pure frontend codegen, exactly
|
|
26
|
+
* like `codegen_dart.ts`. NO FALLBACK: an unsupported shape throws at compile time.
|
|
27
|
+
*/
|
|
28
|
+
import type { z } from "zod";
|
|
29
|
+
|
|
30
|
+
import type { AggregateHandle } from "./aggregate.js";
|
|
31
|
+
import type { Field } from "./fields.js";
|
|
32
|
+
import type { Directive } from "./directive.js";
|
|
33
|
+
import type { QueryDecl } from "./query.js";
|
|
34
|
+
import type { DomainModule } from "./codegen_dart.js";
|
|
35
|
+
import { cap, readModelClassName } from "./codegen_dart.js";
|
|
36
|
+
import { finishCount } from "./count.js";
|
|
37
|
+
import { finishSum } from "./sum.js";
|
|
38
|
+
import { autoStampFields } from "./codegen_ts.js";
|
|
39
|
+
|
|
40
|
+
const camel = (s: string) => s.replace(/[_\s-]+(\w)/g, (_m, c: string) => c.toUpperCase());
|
|
41
|
+
const lcFirst = (s: string) => (s.length ? s[0]!.toLowerCase() + s.slice(1) : s);
|
|
42
|
+
|
|
43
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
44
|
+
type AnyDirective = Directive<any>;
|
|
45
|
+
|
|
46
|
+
/** Dart reserved words that can't be method names — suffixed `_` if one slips through. */
|
|
47
|
+
const DART_RESERVED = new Set([
|
|
48
|
+
"assert", "break", "case", "catch", "class", "const", "continue", "default", "do", "else", "enum",
|
|
49
|
+
"extends", "false", "final", "finally", "for", "if", "in", "is", "new", "null", "rethrow", "return",
|
|
50
|
+
"super", "switch", "this", "throw", "true", "try", "var", "void", "while", "with", "dynamic",
|
|
51
|
+
]);
|
|
52
|
+
const safeMethod = (s: string): string => (DART_RESERVED.has(s) ? `${s}_` : s);
|
|
53
|
+
|
|
54
|
+
/** `guestbook` / `tool-library` / `Tool Library` → `GuestbookClient` / `ToolLibraryClient`. */
|
|
55
|
+
export const dartClientClassName = (domain: string): string => `${cap(camel(domain))}Client`;
|
|
56
|
+
/** `guestbook` → `guestbookDomainHash` — the Dart top-level const (lowerCamel by lint). */
|
|
57
|
+
export const dartHashConstName = (fileBase: string): string => `${lcFirst(camel(fileBase))}DomainHash`;
|
|
58
|
+
|
|
59
|
+
const dartStr = (s: string) => `'${s.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`;
|
|
60
|
+
|
|
61
|
+
/** The Dart param type for a query index key (int-kinded keys → `int`, else `String`). */
|
|
62
|
+
function dartKeyType(agg: AggregateHandle | undefined, key: string): string {
|
|
63
|
+
const lead = key.includes(".") ? key.split(".")[0]! : key;
|
|
64
|
+
const f = agg?.fields[lead] as Field | undefined;
|
|
65
|
+
return f && (f.kind === "int" || f.kind === "counter") ? "int" : "String";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** A Dart-safe param identifier from a (possibly dotted) index key: `a.b.c` → `aBC`. */
|
|
69
|
+
function dartKeyParam(key: string): string {
|
|
70
|
+
const parts = key.split(".").filter((p) => p !== "");
|
|
71
|
+
const id = parts.length === 0 ? "key" : parts[0]! + parts.slice(1).map((p) => cap(p)).join("");
|
|
72
|
+
const cleaned = id.replace(/[^A-Za-z0-9_]/g, "_");
|
|
73
|
+
const safe = /^[0-9]/.test(cleaned) ? `v${cleaned}` : cleaned;
|
|
74
|
+
return safeMethod(safe);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Parse ONE bridge row (`{type,id,data}`) into a typed read model. Works for a `Map` (one-shot
|
|
78
|
+
* `query`/`queryById` rows) or a `dynamic` (`watch` stream rows) — indexing resolves on both. */
|
|
79
|
+
function rowToModel(model: string, rowVar: string): string {
|
|
80
|
+
return `${model}.fromProjected((${rowVar}['data'] as Map).cast<String, dynamic>(), (${rowVar}['id'] ?? '').toString())`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface DartBridgeClientOptions {
|
|
84
|
+
/** sha256 of the built `.package.usda` — the law this client dispatches under. */
|
|
85
|
+
readonly domainHash: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Generate the `<name>.client.dart` async bridge client for one domain module. */
|
|
89
|
+
export function generateDartBridgeClient(mod: DomainModule, opts: DartBridgeClientOptions): string {
|
|
90
|
+
const domain = mod.domain ?? mod.name;
|
|
91
|
+
const className = dartClientClassName(domain);
|
|
92
|
+
const hashConst = dartHashConstName(mod.name);
|
|
93
|
+
const aggById = new Map<string, AggregateHandle>();
|
|
94
|
+
for (const a of mod.aggregates) aggById.set(a.id, a);
|
|
95
|
+
|
|
96
|
+
const methods: string[] = [];
|
|
97
|
+
|
|
98
|
+
// ── directives → typed dispatch ──
|
|
99
|
+
const dirs = (mod.directives as AnyDirective[]).filter(
|
|
100
|
+
(d) => d && typeof d.id === "string" && typeof (d as { payloadSchema?: unknown }).payloadSchema === "object",
|
|
101
|
+
);
|
|
102
|
+
for (const d of dirs) {
|
|
103
|
+
const payloadClass = `${cap(d.id)}Payload`;
|
|
104
|
+
const stamps = autoStampFields((d as unknown as { payloadSchema: z.ZodTypeAny }).payloadSchema);
|
|
105
|
+
const method = safeMethod(d.id);
|
|
106
|
+
if (stamps.length > 0) {
|
|
107
|
+
methods.push(
|
|
108
|
+
` /// Author \`${domain}/${d.id}\` locally under the installed law; returns the new local head.`,
|
|
109
|
+
` /// Omitted ${stamps.map((s) => `\`${s}\``).join(" / ")} auto-stamp(s) ISO now() at dispatch.`,
|
|
110
|
+
` Future<String> ${method}(${payloadClass} payload) {`,
|
|
111
|
+
` final data = payload.toPayloadJson();`,
|
|
112
|
+
` final now = DateTime.now().toUtc().toIso8601String();`,
|
|
113
|
+
...stamps.map((s) => ` data.putIfAbsent(${dartStr(s)}, () => now);`),
|
|
114
|
+
` return bridge.dispatch(domain: ${dartStr(domain)}, directiveId: ${dartStr(d.id)}, payload: data, domainHash: domainHash);`,
|
|
115
|
+
` }`,
|
|
116
|
+
``,
|
|
117
|
+
);
|
|
118
|
+
} else {
|
|
119
|
+
methods.push(
|
|
120
|
+
` /// Author \`${domain}/${d.id}\` locally under the installed law; returns the new local head.`,
|
|
121
|
+
` Future<String> ${method}(${payloadClass} payload) =>`,
|
|
122
|
+
` bridge.dispatch(domain: ${dartStr(domain)}, directiveId: ${dartStr(d.id)}, payload: payload.toPayloadJson(), domainHash: domainHash);`,
|
|
123
|
+
``,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ── declared queries → one-shot + reactive ──
|
|
129
|
+
for (const q of (mod.queries ?? []) as QueryDecl[]) {
|
|
130
|
+
const model = readModelClassName(q.returns);
|
|
131
|
+
const agg = aggById.get(q.returns);
|
|
132
|
+
const params = q.key.map((k) => `required ${dartKeyType(agg, k)} ${dartKeyParam(k)}`).join(", ");
|
|
133
|
+
const argMap = `{${q.key.map((k) => `${dartStr(k)}: ${dartKeyParam(k)}`).join(", ")}}`;
|
|
134
|
+
const m = safeMethod(lcFirst(camel(q.id)));
|
|
135
|
+
methods.push(
|
|
136
|
+
` /// Declared query \`${q.id}\` (indexed probe — routed by the read manifest).`,
|
|
137
|
+
` Future<List<${model}>> ${m}({${params}}) async =>`,
|
|
138
|
+
` (await bridge.query(${dartStr(q.id)}, ${argMap})).map((r) => ${rowToModel(model, "r")}).toList();`,
|
|
139
|
+
``,
|
|
140
|
+
` /// Reactive \`${q.id}\` — re-emits on every local change (cancel the subscription to stop).`,
|
|
141
|
+
` Stream<List<${model}>> watch${cap(camel(q.id))}({${params}}) =>`,
|
|
142
|
+
` bridge.watch(${dartStr(q.id)}, ${argMap}).map((rows) => rows.map((r) => ${rowToModel(model, "r")}).toList());`,
|
|
143
|
+
``,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ── counts / sums → O(1) typed reads ──
|
|
148
|
+
for (const c of (mod.counts ?? []).map((raw) => finishCount(raw))) {
|
|
149
|
+
const m = safeMethod(lcFirst(camel(c.id)));
|
|
150
|
+
methods.push(
|
|
151
|
+
c.by != null
|
|
152
|
+
? ` /// Declared count \`${c.id}\` — the maintained O(1) tally per \`${c.by}\`.\n Future<int> ${m}(String ${dartKeyParam(c.by)}) => bridge.count(${dartStr(c.id)}, ${dartKeyParam(c.by)});`
|
|
153
|
+
: ` /// Declared count \`${c.id}\` — the maintained O(1) grand total.\n Future<int> ${m}() => bridge.count(${dartStr(c.id)});`,
|
|
154
|
+
``,
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
for (const s of (mod.sums ?? []).map((raw) => finishSum(raw))) {
|
|
158
|
+
const m = safeMethod(lcFirst(camel(s.id)));
|
|
159
|
+
methods.push(
|
|
160
|
+
s.by != null
|
|
161
|
+
? ` /// Declared sum \`${s.id}\` — the maintained O(1) total per \`${s.by}\`.\n Future<int> ${m}(String ${dartKeyParam(s.by)}) => bridge.sum(${dartStr(s.id)}, ${dartKeyParam(s.by)});`
|
|
162
|
+
: ` /// Declared sum \`${s.id}\` — the maintained O(1) grand total.\n Future<int> ${m}() => bridge.sum(${dartStr(s.id)});`,
|
|
163
|
+
``,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ── per-aggregate by-id read/watch + type-index list ──
|
|
168
|
+
for (const agg of mod.aggregates) {
|
|
169
|
+
const model = readModelClassName(agg.id);
|
|
170
|
+
const lc = lcFirst(camel(agg.id));
|
|
171
|
+
const Cap = cap(camel(agg.id));
|
|
172
|
+
methods.push(
|
|
173
|
+
` /// Read one ${agg.id} by aggregate id.`,
|
|
174
|
+
` Future<List<${model}>> ${safeMethod(`${lc}ById`)}(String id) async =>`,
|
|
175
|
+
` (await bridge.queryById(id)).map((r) => ${rowToModel(model, "r")}).toList();`,
|
|
176
|
+
``,
|
|
177
|
+
` /// Watch one ${agg.id} by aggregate id (local reactive read).`,
|
|
178
|
+
` Stream<List<${model}>> watch${Cap}ById(String id) =>`,
|
|
179
|
+
` bridge.watchById(id).map((rows) => rows.map((r) => ${rowToModel(model, "r")}).toList());`,
|
|
180
|
+
``,
|
|
181
|
+
` /// Every ${agg.id} (the type-index probe — O(result), not a scan).`,
|
|
182
|
+
` Future<List<${model}>> ${safeMethod(`list${Cap}s`)}() async =>`,
|
|
183
|
+
` (await bridge.query('aggregatesByType', {'__type': ${dartStr(agg.id)}})).map((r) => ${rowToModel(model, "r")}).toList();`,
|
|
184
|
+
``,
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return [
|
|
189
|
+
`// AUTO-GENERATED by nomos-compile — typed ASYNC Dart client for domain "${domain}".`,
|
|
190
|
+
`// Drives the REAL githolon over a NomosBridge (the WebView runner). Regenerated every compile; do`,
|
|
191
|
+
`// not edit. Construct with a connected bridge:`,
|
|
192
|
+
`//`,
|
|
193
|
+
`// final bridge = NomosBridge(transport); // transport = your WebViewTransport`,
|
|
194
|
+
`// await bridge.ready;`,
|
|
195
|
+
`// await bridge.connect(cloud: cloud, workspace: ws, clientId: deviceId);`,
|
|
196
|
+
`// final app = ${className}(bridge);`,
|
|
197
|
+
`// await app.${dirs[0] ? safeMethod(dirs[0].id) : "someDirective"}(/* typed payload */);`,
|
|
198
|
+
``,
|
|
199
|
+
`import 'nomos_dsl.dart';`,
|
|
200
|
+
`import '${mod.name}.dart';`,
|
|
201
|
+
`// Re-export the sibling types (read models + payloads + the bridge runtime) so ONE import of this`,
|
|
202
|
+
`// client file gives an app the whole typed surface.`,
|
|
203
|
+
`export '${mod.name}.dart';`,
|
|
204
|
+
`export 'nomos_dsl.dart' show NomosBridge, BridgeTransport, NomosBridgeError, kWebviewBridgeVersion;`,
|
|
205
|
+
``,
|
|
206
|
+
`/// sha256 of the deployed \`.package.usda\` — the installed law this client dispatches under.`,
|
|
207
|
+
`const String ${hashConst} = ${dartStr(opts.domainHash)};`,
|
|
208
|
+
``,
|
|
209
|
+
`/// Typed async client for the \`${domain}\` domain, bound to a connected [NomosBridge].`,
|
|
210
|
+
`class ${className} {`,
|
|
211
|
+
` final NomosBridge bridge;`,
|
|
212
|
+
` final String domainHash;`,
|
|
213
|
+
` ${className}(this.bridge, {this.domainHash = ${hashConst}});`,
|
|
214
|
+
``,
|
|
215
|
+
...methods,
|
|
216
|
+
`}`,
|
|
217
|
+
``,
|
|
218
|
+
].join("\n");
|
|
219
|
+
}
|
|
@@ -63,6 +63,7 @@ import {
|
|
|
63
63
|
type DomainModule,
|
|
64
64
|
type PermissionVocabulary,
|
|
65
65
|
} from "./codegen_dart.js";
|
|
66
|
+
import { generateDartBridgeClient } from "./codegen_dart_client.js";
|
|
66
67
|
import type { AnyCount } from "./count.js";
|
|
67
68
|
import type { QueryDecl } from "./query.js";
|
|
68
69
|
import type { SpatialDecl } from "./spatial.js";
|
|
@@ -408,6 +409,9 @@ const DART_SUPPORT_SRC = [
|
|
|
408
409
|
"dispatch.dart",
|
|
409
410
|
"subscriptions.dart",
|
|
410
411
|
"provider.dart",
|
|
412
|
+
// The async WebView bridge runtime (NomosBridge / BridgeTransport) the generated
|
|
413
|
+
// `<name>.client.dart` drives — the barrel re-exports it, so it must be vendored too.
|
|
414
|
+
"webview_bridge.dart",
|
|
411
415
|
] as const;
|
|
412
416
|
|
|
413
417
|
/**
|
|
@@ -416,7 +420,7 @@ const DART_SUPPORT_SRC = [
|
|
|
416
420
|
* `emit_dart.ts` drives) + the vendored `nomos_dsl.dart` barrel and its `src/`
|
|
417
421
|
* support files, with the generated files' `package:` import rewritten relative.
|
|
418
422
|
*/
|
|
419
|
-
function emitDart(modules: readonly DomainModule[], dartOut: string): string[] {
|
|
423
|
+
function emitDart(modules: readonly DomainModule[], dartOut: string, domainHash: string): string[] {
|
|
420
424
|
mkdirSync(path.join(dartOut, "src"), { recursive: true });
|
|
421
425
|
|
|
422
426
|
// 1. the vendored framework support (barrel + src/), copied byte-for-byte except
|
|
@@ -431,10 +435,18 @@ function emitDart(modules: readonly DomainModule[], dartOut: string): string[] {
|
|
|
431
435
|
copyFileSync(path.join(libDir, "src", f), path.join(dartOut, "src", f));
|
|
432
436
|
}
|
|
433
437
|
|
|
434
|
-
// 2. one generated file per composed domain
|
|
438
|
+
// 2. one generated file per composed domain — the TYPES (`<name>.dart`) + the async bridge
|
|
439
|
+
// CLIENT (`<name>.client.dart`, the Flutter sibling of `<name>.client.ts`).
|
|
435
440
|
const written: string[] = [];
|
|
436
441
|
for (const mod of modules) {
|
|
437
|
-
|
|
442
|
+
// Default the read-model allowlist to EVERY aggregate when the domain didn't pin one, so
|
|
443
|
+
// `<name>.dart` carries a read model per aggregate — the bridge client imports them. A domain
|
|
444
|
+
// that pins `readModelAggregates` keeps its choice (byte-identical to before this default).
|
|
445
|
+
const modForDart: DomainModule =
|
|
446
|
+
mod.readModelAggregates === undefined
|
|
447
|
+
? { ...mod, readModelAggregates: mod.aggregates.map((a) => a.id) }
|
|
448
|
+
: mod;
|
|
449
|
+
const src = generateDartDomain(modForDart);
|
|
438
450
|
if (!src.includes(DART_PACKAGE_IMPORT)) {
|
|
439
451
|
fail(
|
|
440
452
|
`generated Dart for domain '${mod.name}' does not carry the expected support import (${DART_PACKAGE_IMPORT}) — codegen_dart header drifted; fix the rewrite in compile_package_main.ts`,
|
|
@@ -443,6 +455,11 @@ function emitDart(modules: readonly DomainModule[], dartOut: string): string[] {
|
|
|
443
455
|
const p = path.join(dartOut, `${mod.name}.dart`);
|
|
444
456
|
writeFileSync(p, src.replace(DART_PACKAGE_IMPORT, DART_RELATIVE_IMPORT), "utf8");
|
|
445
457
|
written.push(p);
|
|
458
|
+
|
|
459
|
+
const clientSrc = generateDartBridgeClient(modForDart, { domainHash });
|
|
460
|
+
const cp = path.join(dartOut, `${mod.name}.client.dart`);
|
|
461
|
+
writeFileSync(cp, clientSrc, "utf8");
|
|
462
|
+
written.push(cp);
|
|
446
463
|
}
|
|
447
464
|
return written;
|
|
448
465
|
}
|
|
@@ -1351,7 +1368,7 @@ async function main(): Promise<void> {
|
|
|
1351
1368
|
cfgDir,
|
|
1352
1369
|
typeof dartCfg === "object" && dartCfg.out !== undefined ? dartCfg.out : path.join(outDir, "dart"),
|
|
1353
1370
|
);
|
|
1354
|
-
dartFiles = emitDart(domainModules, dartOut);
|
|
1371
|
+
dartFiles = emitDart(domainModules, dartOut, domainHash);
|
|
1355
1372
|
}
|
|
1356
1373
|
|
|
1357
1374
|
// ── summary ──
|