@githolon/dsl 0.45.0 → 0.47.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/CHANGELOG.md +5 -0
- package/dart/lib/src/errors.dart +250 -0
- package/dart/lib/src/test_harness.dart +182 -175
- package/dart/lib/src/webview_bridge.dart +38 -4
- package/dart/pubspec.yaml +1 -1
- package/dart/test/errors_test.dart +77 -0
- package/dart/test/test_harness_test.dart +85 -57
- package/package.json +1 -1
- package/src/codegen_dart.ts +25 -14
- package/src/codegen_dart_client.ts +33 -1
- package/src/codegen_proof.ts +255 -23
- package/src/codegen_ts.ts +33 -0
- package/src/compat_manifest.ts +20 -0
- package/src/compile_package_main.ts +174 -16
- package/src/framework/framework_aggregates.ts +18 -0
- package/src/read_projection_composition.ts +9 -9
- package/src/read_projection_namespace.ts +4 -5
- package/src/usd.ts +3 -3
- package/src/value_object.ts +10 -9
|
@@ -1,31 +1,37 @@
|
|
|
1
|
-
// NOMOS TEST HARNESS —
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// NOMOS TEST HARNESS — an EPHEMERAL, FULLY-OFFLINE real-law fixture for headless app tests. One call
|
|
2
|
+
// installs a tenant's compiled law into a FRESH LOCAL workspace (no cloud contact, ever), boots the REAL
|
|
3
|
+
// githolon (the byte-identical wasm, the real ledger, the real gate) over [NodeRunnerTransport], and hands
|
|
4
|
+
// out a [NomosBridge] the generated Dart clients drive — the SAME surface the app UI uses — under
|
|
5
|
+
// `flutter test` / `dart test` / CI.
|
|
5
6
|
//
|
|
6
7
|
// ```dart
|
|
7
8
|
// final harness = await NomosTestHarness.ephemeral(
|
|
8
9
|
// deployJsonPaths: ['build/guestbook.deploy.json'],
|
|
10
|
+
// registerTearDown: addTearDown, // auto-disposes when the test ends (see below)
|
|
9
11
|
// );
|
|
10
|
-
// final
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
// await harness.dispose();
|
|
12
|
+
// final app = guestbookClient(harness.bridge); // the generated client — same one the app uses
|
|
13
|
+
// await app.signGuestbook(author: 'ada', message: 'hi', mood: 'happy', signedAt: ...);
|
|
14
|
+
// final rows = await app.entriesByAuthor(author: 'ada');
|
|
15
|
+
// await harness.dispose(); // DETERMINISTIC teardown (also auto-fired via registerTearDown)
|
|
14
16
|
// ```
|
|
15
17
|
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
18
|
+
// HOW IT STAYS OFFLINE. Two node children, both talking only to the local disk:
|
|
19
|
+
// 1. `offline-install.mjs` runs the PROVEN offline install lane (`githolon proof`'s adapter engine) —
|
|
20
|
+
// genesis → framework controller → each tenant package's `installDomain`, folded through the ONE gate —
|
|
21
|
+
// and serializes the workspace tree to a snapshot in a fresh temp dir.
|
|
22
|
+
// 2. the node runner (`node-entry.mjs`) boots in `NOMOS_OFFLINE=1` mode: it serves the runtime wasm from the
|
|
23
|
+
// local cache (`~/.holon/runtime`, filled by any warm `githolon` run) and boots the holon from that
|
|
24
|
+
// snapshot (`restoreFrom`). Every other URL is REFUSED — a stray fetch fails loudly, never reaches the net.
|
|
21
25
|
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
+
// PREREQUISITES (documented so a failure is legible): `node` on PATH; a resolvable
|
|
27
|
+
// `@githolon/client/runner/node-entry.mjs`; and a warm `~/.holon/runtime` cache (holon.wasm + packages.json —
|
|
28
|
+
// one `githolon` run fills it). All local; no network at test time.
|
|
29
|
+
//
|
|
30
|
+
// PACKAGING NOTE: `offline-install.mjs` imports the adapter engine, which today lives beside the in-repo
|
|
31
|
+
// runner. For a PUBLISHED harness this install belongs behind a `githolon` subcommand (the CLI already
|
|
32
|
+
// bundles the engine + this exact sequence in `proof_offline`); [installHelper] is the one seam to repoint.
|
|
26
33
|
import 'dart:convert';
|
|
27
34
|
import 'dart:io';
|
|
28
|
-
import 'dart:math';
|
|
29
35
|
|
|
30
36
|
import 'node_runner_transport.dart';
|
|
31
37
|
import 'webview_bridge.dart' show NomosBridge;
|
|
@@ -38,198 +44,199 @@ class NomosTestHarnessException implements Exception {
|
|
|
38
44
|
}
|
|
39
45
|
|
|
40
46
|
class NomosTestHarness {
|
|
41
|
-
/// The
|
|
42
|
-
final
|
|
47
|
+
/// The connected bridge — hand it to the generated typed clients (the same surface the app UI uses).
|
|
48
|
+
final NomosBridge bridge;
|
|
43
49
|
|
|
44
|
-
/// The
|
|
50
|
+
/// The local workspace name the law was installed into.
|
|
45
51
|
final String workspace;
|
|
46
52
|
|
|
47
|
-
final
|
|
48
|
-
final
|
|
49
|
-
final String? _nodeEntry;
|
|
50
|
-
final String? _resolveFrom;
|
|
53
|
+
final NodeRunnerTransport _transport;
|
|
54
|
+
final Directory _workdir;
|
|
51
55
|
final void Function(String line)? _onDiagnostic;
|
|
52
|
-
final List<NodeRunnerTransport> _transports = [];
|
|
53
56
|
bool _disposed = false;
|
|
54
57
|
|
|
55
|
-
NomosTestHarness._(this.
|
|
56
|
-
this.
|
|
58
|
+
NomosTestHarness._(this.bridge, this.workspace, this._transport, this._workdir,
|
|
59
|
+
this._onDiagnostic);
|
|
57
60
|
|
|
58
|
-
///
|
|
61
|
+
/// Install [deployJsonPaths] into a fresh LOCAL workspace and boot a bridge over it — NO cloud contact.
|
|
59
62
|
///
|
|
60
|
-
/// [deployJsonPaths] — the `build/<name>.deploy.json` files `nomos-compile` emits (
|
|
61
|
-
/// platform
|
|
62
|
-
/// [workspace] —
|
|
63
|
-
/// [
|
|
64
|
-
///
|
|
65
|
-
///
|
|
63
|
+
/// [deployJsonPaths] — the `build/<name>.deploy.json` files `nomos-compile` emits (installed in order;
|
|
64
|
+
/// framework/platform law before a domain that depends on it).
|
|
65
|
+
/// [workspace] — the local workspace name (default `test-ws`; law-only, never leaves the machine).
|
|
66
|
+
/// [runtimeDir] — the runtime cache (default `~/.holon/runtime`); must hold `holon.wasm` + `packages.json`.
|
|
67
|
+
/// [nodeEntry] — path to `node-entry.mjs`; when omitted it is resolved from [resolveFrom] via
|
|
68
|
+
/// `require.resolve('@githolon/client/runner/node-entry.mjs')`.
|
|
69
|
+
/// [installHelper] — path to `offline-install.mjs`; defaults to the file beside [nodeEntry].
|
|
70
|
+
/// [registerTearDown] — pass the test framework's `addTearDown` to auto-[dispose] when the test ends
|
|
71
|
+
/// (feature-detecting `package:test` from a library is not clean in Dart, so this is explicit and safe).
|
|
72
|
+
/// [autoTearDown] — when true (default) and [registerTearDown] is given, registers [dispose] as a teardown.
|
|
66
73
|
static Future<NomosTestHarness> ephemeral({
|
|
67
|
-
List<String> deployJsonPaths
|
|
68
|
-
String
|
|
69
|
-
String
|
|
70
|
-
String?
|
|
71
|
-
String? authToken,
|
|
74
|
+
required List<String> deployJsonPaths,
|
|
75
|
+
String workspace = 'test-ws',
|
|
76
|
+
String clientId = 'test-runner',
|
|
77
|
+
String? runtimeDir,
|
|
72
78
|
String? nodeEntry,
|
|
79
|
+
String? installHelper,
|
|
73
80
|
String? resolveFrom,
|
|
81
|
+
String node = 'node',
|
|
82
|
+
bool autoTearDown = true,
|
|
83
|
+
void Function(Future<void> Function())? registerTearDown,
|
|
74
84
|
void Function(String line)? onDiagnostic,
|
|
85
|
+
Duration readyTimeout = const Duration(seconds: 30),
|
|
75
86
|
}) async {
|
|
76
|
-
|
|
77
|
-
final ws = workspace ?? 'test-${_hex(12)}';
|
|
78
|
-
final harness = NomosTestHarness._(_stripSlash(cloud), ws, id.principal,
|
|
79
|
-
id.authToken, nodeEntry, resolveFrom, onDiagnostic);
|
|
80
|
-
await harness._create();
|
|
81
|
-
for (final p in deployJsonPaths) {
|
|
82
|
-
await harness.deployLaw(p);
|
|
83
|
-
}
|
|
84
|
-
return harness;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
static String _brief(String s) => s.length > 600 ? '${s.substring(0, 600)}…[+${s.length - 600} chars]' : s;
|
|
88
|
-
|
|
89
|
-
/// Deploy one more compiled package (`<name>.deploy.json`) into the workspace — e.g. a law evolve mid-test.
|
|
90
|
-
Future<void> deployLaw(String deployJsonPath) async {
|
|
91
|
-
final f = File(deployJsonPath);
|
|
92
|
-
if (!f.existsSync()) {
|
|
87
|
+
if (deployJsonPaths.isEmpty) {
|
|
93
88
|
throw NomosTestHarnessException(
|
|
94
|
-
'
|
|
89
|
+
'no deployJsonPaths — pass the build/<name>.deploy.json `npx githolon compile` emits');
|
|
95
90
|
}
|
|
96
|
-
final
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
91
|
+
final entry = nodeEntry ??
|
|
92
|
+
await _resolveEntry(node, resolveFrom ?? Directory.current.path);
|
|
93
|
+
final helper = installHelper ??
|
|
94
|
+
'${File(entry).parent.path}/offline-install.mjs';
|
|
95
|
+
|
|
96
|
+
// A FRESH temp dir per harness — the snapshot + any transient runner state live here and nowhere else,
|
|
97
|
+
// so dispose() leaves nothing behind.
|
|
98
|
+
final workdir = await Directory.systemTemp.createTemp('nomos-harness-');
|
|
99
|
+
// Total-ordered teardown even on a mid-setup failure: build the transport lazily and clean up on throw.
|
|
100
|
+
NodeRunnerTransport? transport;
|
|
101
|
+
try {
|
|
102
|
+
final snapshot = '${workdir.path}/snapshot.bin';
|
|
103
|
+
await _runInstall(
|
|
104
|
+
node: node,
|
|
105
|
+
helper: helper,
|
|
106
|
+
deployJsonPaths: deployJsonPaths,
|
|
107
|
+
workspace: workspace,
|
|
108
|
+
snapshot: snapshot,
|
|
109
|
+
runtimeDir: runtimeDir,
|
|
110
|
+
onDiagnostic: onDiagnostic);
|
|
111
|
+
|
|
112
|
+
transport = await NodeRunnerTransport.start(
|
|
113
|
+
entry: entry,
|
|
114
|
+
resolveFrom: resolveFrom,
|
|
115
|
+
node: node,
|
|
116
|
+
environment: {
|
|
117
|
+
'NOMOS_OFFLINE': '1',
|
|
118
|
+
'NOMOS_OFFLINE_RESTORE': snapshot,
|
|
119
|
+
if (runtimeDir != null) 'NOMOS_OFFLINE_RUNTIME': runtimeDir,
|
|
120
|
+
},
|
|
121
|
+
onDiagnostic: onDiagnostic,
|
|
122
|
+
);
|
|
123
|
+
final bridge = NomosBridge(transport);
|
|
124
|
+
await bridge.ready.timeout(readyTimeout,
|
|
125
|
+
onTimeout: () => throw NomosTestHarnessException(
|
|
126
|
+
'the offline runner never signalled ready within $readyTimeout '
|
|
127
|
+
'(is `node` on PATH and ~/.holon/runtime warm?)'));
|
|
128
|
+
await bridge.connect(
|
|
129
|
+
cloud: 'https://offline.invalid', // ignored: the offline runner serves everything locally
|
|
130
|
+
workspace: workspace,
|
|
131
|
+
clientId: clientId,
|
|
132
|
+
allowUnborn: true,
|
|
133
|
+
);
|
|
134
|
+
final harness =
|
|
135
|
+
NomosTestHarness._(bridge, workspace, transport, workdir, onDiagnostic);
|
|
136
|
+
if (autoTearDown && registerTearDown != null) {
|
|
137
|
+
registerTearDown(harness.dispose);
|
|
138
|
+
}
|
|
139
|
+
return harness;
|
|
140
|
+
} catch (_) {
|
|
141
|
+
// Setup failed — tear down deterministically so nothing is left running or on disk.
|
|
142
|
+
if (transport != null) {
|
|
143
|
+
try {
|
|
144
|
+
await transport.dispose();
|
|
145
|
+
} catch (_) {}
|
|
146
|
+
}
|
|
147
|
+
try {
|
|
148
|
+
await workdir.delete(recursive: true);
|
|
149
|
+
} catch (_) {}
|
|
150
|
+
rethrow;
|
|
101
151
|
}
|
|
102
152
|
}
|
|
103
153
|
|
|
104
|
-
///
|
|
105
|
-
///
|
|
106
|
-
|
|
107
|
-
String clientId = 'test-runner',
|
|
108
|
-
String? authToken,
|
|
109
|
-
String? authorSecret,
|
|
110
|
-
bool allowUnborn = false,
|
|
111
|
-
}) async {
|
|
112
|
-
if (_disposed) throw StateError('harness is disposed');
|
|
113
|
-
final transport = await NodeRunnerTransport.start(
|
|
114
|
-
entry: _nodeEntry,
|
|
115
|
-
resolveFrom: _resolveFrom,
|
|
116
|
-
onDiagnostic: _onDiagnostic,
|
|
117
|
-
);
|
|
118
|
-
_transports.add(transport);
|
|
119
|
-
final bridge = NomosBridge(transport);
|
|
120
|
-
await bridge.ready.timeout(const Duration(seconds: 30));
|
|
121
|
-
await bridge.connect(
|
|
122
|
-
cloud: cloud,
|
|
123
|
-
workspace: workspace,
|
|
124
|
-
clientId: clientId,
|
|
125
|
-
authToken: authToken ?? _authToken,
|
|
126
|
-
authorSecret: authorSecret,
|
|
127
|
-
allowUnborn: allowUnborn,
|
|
128
|
-
);
|
|
129
|
-
return bridge;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
/// Tear down every runner this harness spawned. The throwaway workspace's ledger stays on the cloud
|
|
133
|
-
/// (there is no open destroy lane — reclaim is law-gated); its unique name keeps it inert.
|
|
154
|
+
/// Deterministic, idempotent, total-ordered teardown: stop the runner (SIGTERM → 5s deadline → SIGKILL,
|
|
155
|
+
/// via [NodeRunnerTransport.dispose]), then delete the temp dir. Safe to call twice; safe to call after a
|
|
156
|
+
/// failed setup. Nothing left running, nothing left on disk.
|
|
134
157
|
Future<void> dispose() async {
|
|
135
158
|
if (_disposed) return;
|
|
136
159
|
_disposed = true;
|
|
137
|
-
|
|
138
|
-
await
|
|
160
|
+
try {
|
|
161
|
+
await _transport.dispose();
|
|
162
|
+
} catch (e) {
|
|
163
|
+
_diag('[nomos-test-harness] transport dispose error (continuing to temp-dir cleanup): $e');
|
|
139
164
|
}
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
Future<void> _create() async {
|
|
146
|
-
final r = await _post('/v2/workspaces/$workspace');
|
|
147
|
-
if (r.$1 >= 300 || !(r.$2['ok'] == true)) {
|
|
148
|
-
throw NomosTestHarnessException(
|
|
149
|
-
'workspace birth for $workspace refused (${r.$1}): ${_brief(r.$3)} — '
|
|
150
|
-
'a birth needs a principal (npx githolon login --agent, or pass principal:/authToken:)');
|
|
165
|
+
try {
|
|
166
|
+
if (await _workdir.exists()) await _workdir.delete(recursive: true);
|
|
167
|
+
} catch (e) {
|
|
168
|
+
_diag('[nomos-test-harness] temp-dir cleanup error: $e');
|
|
151
169
|
}
|
|
152
170
|
}
|
|
153
171
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
if (_principal != null) req.headers.set('x-nomos-principal', _principal);
|
|
161
|
-
if (contentType != null) {
|
|
162
|
-
req.headers.contentType = ContentType.parse('$contentType; charset=utf-8');
|
|
163
|
-
}
|
|
164
|
-
if (body != null) req.add(utf8.encode(body));
|
|
165
|
-
final res = await req.close();
|
|
166
|
-
final text = await res.transform(utf8.decoder).join();
|
|
167
|
-
Map<String, Object?> decoded = const {};
|
|
168
|
-
try {
|
|
169
|
-
final d = jsonDecode(text);
|
|
170
|
-
if (d is Map) decoded = d.cast<String, Object?>();
|
|
171
|
-
} catch (_) {}
|
|
172
|
-
return (res.statusCode, decoded, text);
|
|
173
|
-
} finally {
|
|
174
|
-
client.close();
|
|
172
|
+
void _diag(String line) {
|
|
173
|
+
final f = _onDiagnostic;
|
|
174
|
+
if (f != null) {
|
|
175
|
+
f(line);
|
|
176
|
+
} else {
|
|
177
|
+
stderr.writeln(line);
|
|
175
178
|
}
|
|
176
179
|
}
|
|
177
180
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
181
|
+
// ── offline install (a short-lived node child; produces the restore snapshot) ──
|
|
182
|
+
|
|
183
|
+
static Future<void> _runInstall({
|
|
184
|
+
required String node,
|
|
185
|
+
required String helper,
|
|
186
|
+
required List<String> deployJsonPaths,
|
|
187
|
+
required String workspace,
|
|
188
|
+
required String snapshot,
|
|
189
|
+
String? runtimeDir,
|
|
190
|
+
void Function(String line)? onDiagnostic,
|
|
191
|
+
}) async {
|
|
192
|
+
if (!File(helper).existsSync()) {
|
|
193
|
+
throw NomosTestHarnessException(
|
|
194
|
+
'offline-install helper not found at $helper — pass installHelper: explicitly '
|
|
195
|
+
'(it ships beside node-entry.mjs)');
|
|
196
|
+
}
|
|
197
|
+
final r = await Process.run(node, [
|
|
198
|
+
helper,
|
|
199
|
+
'--deploy', deployJsonPaths.join(','),
|
|
200
|
+
'--workspace', workspace,
|
|
201
|
+
'--out', snapshot,
|
|
202
|
+
if (runtimeDir != null) ...['--runtime', runtimeDir],
|
|
203
|
+
]);
|
|
204
|
+
final err = (r.stderr as String).trim();
|
|
205
|
+
if (err.isNotEmpty) {
|
|
206
|
+
for (final line in err.split('\n')) {
|
|
207
|
+
(onDiagnostic ?? stderr.writeln)('[offline-install] $line');
|
|
208
|
+
}
|
|
186
209
|
}
|
|
187
|
-
|
|
188
|
-
p ??= t != null ? _jwtSub(t) : null;
|
|
189
|
-
if (p == null && t == null) {
|
|
210
|
+
if (r.exitCode != 0) {
|
|
190
211
|
throw NomosTestHarnessException(
|
|
191
|
-
'
|
|
192
|
-
'(the harness reads ~/.holon/credentials.json) or pass principal:/authToken:');
|
|
212
|
+
'offline law install failed (exit ${r.exitCode}): ${err.isEmpty ? "(no stderr)" : err}');
|
|
193
213
|
}
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
214
|
+
// The helper prints one JSON line on success; verify it before spawning the runner.
|
|
215
|
+
final out = (r.stdout as String).trim();
|
|
216
|
+
final last = out.isEmpty ? '' : out.split('\n').last;
|
|
217
|
+
Map<String, Object?> decoded = const {};
|
|
198
218
|
try {
|
|
199
|
-
final
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
final session = (d['session'] as Map?)?.cast<String, Object?>();
|
|
206
|
-
return (
|
|
207
|
-
principal: session?['uid']?.toString() ?? d['principal']?.toString(),
|
|
208
|
-
authToken: session?['accessToken']?.toString(),
|
|
209
|
-
);
|
|
210
|
-
} catch (_) {
|
|
211
|
-
return (principal: null, authToken: null);
|
|
219
|
+
final d = jsonDecode(last);
|
|
220
|
+
if (d is Map) decoded = d.cast<String, Object?>();
|
|
221
|
+
} catch (_) {}
|
|
222
|
+
if (decoded['ok'] != true || !File(snapshot).existsSync()) {
|
|
223
|
+
throw NomosTestHarnessException(
|
|
224
|
+
'offline law install produced no snapshot (stdout: ${out.isEmpty ? "(empty)" : out})');
|
|
212
225
|
}
|
|
213
226
|
}
|
|
214
227
|
|
|
215
|
-
static String
|
|
216
|
-
final
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
228
|
+
static Future<String> _resolveEntry(String node, String from) async {
|
|
229
|
+
final r = await Process.run(
|
|
230
|
+
node,
|
|
231
|
+
['-p', "require.resolve('@githolon/client/runner/node-entry.mjs')"],
|
|
232
|
+
workingDirectory: from,
|
|
233
|
+
);
|
|
234
|
+
final out = (r.stdout as String).trim();
|
|
235
|
+
if (r.exitCode != 0 || out.isEmpty) {
|
|
236
|
+
throw NomosTestHarnessException(
|
|
237
|
+
'cannot resolve @githolon/client/runner/node-entry.mjs from $from — '
|
|
238
|
+
'is @githolon/client installed (npm install)? Pass nodeEntry: to override. stderr: ${r.stderr}');
|
|
225
239
|
}
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
static String _stripSlash(String s) =>
|
|
229
|
-
s.endsWith('/') ? s.substring(0, s.length - 1) : s;
|
|
230
|
-
|
|
231
|
-
static String _hex(int n) {
|
|
232
|
-
final rng = Random.secure();
|
|
233
|
-
return List.generate(n, (_) => rng.nextInt(16).toRadixString(16)).join();
|
|
240
|
+
return out;
|
|
234
241
|
}
|
|
235
242
|
}
|
|
@@ -275,6 +275,23 @@ class NomosCreated {
|
|
|
275
275
|
String toString() => id;
|
|
276
276
|
}
|
|
277
277
|
|
|
278
|
+
/// A geographic bounding box for spatial reads (`[minLng, minLat] .. [maxLng, maxLat]`,
|
|
279
|
+
/// RFC 7946 lng/lat order — the R*Tree probe's extent).
|
|
280
|
+
class NomosBbox {
|
|
281
|
+
final double minLng;
|
|
282
|
+
final double minLat;
|
|
283
|
+
final double maxLng;
|
|
284
|
+
final double maxLat;
|
|
285
|
+
const NomosBbox({
|
|
286
|
+
required this.minLng,
|
|
287
|
+
required this.minLat,
|
|
288
|
+
required this.maxLng,
|
|
289
|
+
required this.maxLat,
|
|
290
|
+
});
|
|
291
|
+
Map<String, Object?> toJson() =>
|
|
292
|
+
{'minLng': minLng, 'minLat': minLat, 'maxLng': maxLng, 'maxLat': maxLat};
|
|
293
|
+
}
|
|
294
|
+
|
|
278
295
|
/// The result of [NomosBridge.offerCreates]: the new local [head] plus every aggregate the plan minted.
|
|
279
296
|
class NomosOfferOutcome {
|
|
280
297
|
final String head;
|
|
@@ -341,8 +358,9 @@ class NomosBridge {
|
|
|
341
358
|
Future<void> get ready => _ready.future;
|
|
342
359
|
|
|
343
360
|
void _onMessage(Map<String, Object?> m) {
|
|
344
|
-
if (m['v'] != kWebviewBridgeVersion)
|
|
361
|
+
if (m['v'] != kWebviewBridgeVersion) {
|
|
345
362
|
return; // a foreign-version message is ignored (forward-compatible)
|
|
363
|
+
}
|
|
346
364
|
final event = m['event'];
|
|
347
365
|
if (event == 'ready') {
|
|
348
366
|
if (!_ready.isCompleted) _ready.complete();
|
|
@@ -411,8 +429,9 @@ class NomosBridge {
|
|
|
411
429
|
if (event == 'span') {
|
|
412
430
|
// LIVE per-layer span: { event:'span', span:{ phase:'begin'|'end', id, track, name, kind, t0|t1 } }.
|
|
413
431
|
_spanBuffer.add(m);
|
|
414
|
-
if (_spanBuffer.length > 400)
|
|
432
|
+
if (_spanBuffer.length > 400) {
|
|
415
433
|
_spanBuffer.removeRange(0, _spanBuffer.length - 400);
|
|
434
|
+
}
|
|
416
435
|
if (!_spanEvents.isClosed) _spanEvents.add(m);
|
|
417
436
|
return;
|
|
418
437
|
}
|
|
@@ -761,6 +780,13 @@ class NomosBridge {
|
|
|
761
780
|
Future<List<Map<String, dynamic>>> queryById(String id) async =>
|
|
762
781
|
_asRows(await _call('queryById', {'id': id}));
|
|
763
782
|
|
|
783
|
+
/// THE SPATIAL READ: rows of the declared spatial index's of-type whose geometry
|
|
784
|
+
/// intersects [bbox] — an R*Tree probe on the local projection, same row shape as [query].
|
|
785
|
+
Future<List<Map<String, dynamic>>> spatialWithin(
|
|
786
|
+
String spatialId, NomosBbox bbox) async =>
|
|
787
|
+
_asRows(await _call(
|
|
788
|
+
'spatialWithin', {'spatialId': spatialId, 'bbox': bbox.toJson()}));
|
|
789
|
+
|
|
764
790
|
/// A maintained O(1) count / sum.
|
|
765
791
|
Future<int> count(String countId, [String group = '']) async =>
|
|
766
792
|
((_asMap(await _call('count', {'countId': countId, 'group': group}))[
|
|
@@ -914,6 +940,10 @@ class NomosBridge {
|
|
|
914
940
|
Stream<List<dynamic>> watchById(String id) =>
|
|
915
941
|
_watchStream(kind: 'byId', id: id);
|
|
916
942
|
|
|
943
|
+
/// Reactive SPATIAL read: re-emits the R*Tree probe's rows whenever they change locally.
|
|
944
|
+
Stream<List<dynamic>> watchSpatial(String spatialId, NomosBbox bbox) =>
|
|
945
|
+
_watchStream(kind: 'spatial', spatialId: spatialId, bbox: bbox);
|
|
946
|
+
|
|
917
947
|
/// SHAPE COMPATIBILITY (#72): one-shot verdict for this client's structural [requirement]
|
|
918
948
|
/// (its baked `…InterfaceJson`, decoded) against the workspace's ON-LEDGER offer (auto-sourced).
|
|
919
949
|
/// `{ ok, missing[], incompatible[] }` — names the exact directive/aggregate/field, not a hash.
|
|
@@ -953,7 +983,9 @@ class NomosBridge {
|
|
|
953
983
|
{required String kind,
|
|
954
984
|
String? queryId,
|
|
955
985
|
Map<String, Object?>? params,
|
|
956
|
-
String? id
|
|
986
|
+
String? id,
|
|
987
|
+
String? spatialId,
|
|
988
|
+
NomosBbox? bbox}) {
|
|
957
989
|
final subId = 'w${++_seq}';
|
|
958
990
|
late final StreamController<List<dynamic>> ctrl;
|
|
959
991
|
ctrl = StreamController<List<dynamic>>(
|
|
@@ -962,7 +994,9 @@ class NomosBridge {
|
|
|
962
994
|
'kind': kind,
|
|
963
995
|
if (queryId != null) 'queryId': queryId,
|
|
964
996
|
if (params != null) 'params': params,
|
|
965
|
-
if (id != null) 'id': id
|
|
997
|
+
if (id != null) 'id': id,
|
|
998
|
+
if (spatialId != null) 'spatialId': spatialId,
|
|
999
|
+
if (bbox != null) 'bbox': bbox.toJson(),
|
|
966
1000
|
}),
|
|
967
1001
|
onCancel: () async {
|
|
968
1002
|
_watches.remove(subId);
|
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.32.0
|
|
15
15
|
|
|
16
16
|
environment:
|
|
17
17
|
sdk: ^3.5.0
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// NOMOS — TYPED ERROR MINTING (§7 structured runtime error DX). `nomosErrorFromInfo` routes a structured
|
|
2
|
+
// `NomosErrorInfo` to the MOST SPECIFIC typed class by stable code prefix, so an app's `errorBuilder` branches
|
|
3
|
+
// on `is` and reads structured fields (not scraped prose). Every typed error still extends `NomosBridgeError`.
|
|
4
|
+
import 'package:nomos_client/nomos_client.dart';
|
|
5
|
+
import 'package:test/test.dart';
|
|
6
|
+
|
|
7
|
+
NomosErrorInfo info(String code, {Map<String, Object?> attrs = const {}, String? workspace, String? operation}) =>
|
|
8
|
+
NomosErrorInfo(code: code, message: 'refused', workspace: workspace, operation: operation, attributes: attrs);
|
|
9
|
+
|
|
10
|
+
void main() {
|
|
11
|
+
test('law.* mints LawMismatchError with both sides + compat verdict + the exact message', () {
|
|
12
|
+
final e = nomosErrorFromInfo(info(
|
|
13
|
+
'nomos.law.mismatch',
|
|
14
|
+
workspace: 'ws-1',
|
|
15
|
+
operation: 'signGuestbook',
|
|
16
|
+
attrs: {
|
|
17
|
+
'nomos.law.generatedFor': 'aaaa',
|
|
18
|
+
'nomos.law.workspaceRuns': 'bbbb',
|
|
19
|
+
'nomos.law.structurallyCompatible': false,
|
|
20
|
+
'nomos.law.incompatibleMember': 'mood',
|
|
21
|
+
},
|
|
22
|
+
));
|
|
23
|
+
expect(e, isA<NomosBridgeError>()); // still catchable generically
|
|
24
|
+
expect(e, isA<LawMismatchError>());
|
|
25
|
+
final m = e as LawMismatchError;
|
|
26
|
+
expect(m.code, 'nomos.law.mismatch');
|
|
27
|
+
expect(m.workspace, 'ws-1');
|
|
28
|
+
expect(m.generatedFor, 'aaaa');
|
|
29
|
+
expect(m.workspaceRuns, 'bbbb');
|
|
30
|
+
expect(m.operation, 'signGuestbook');
|
|
31
|
+
expect(m.structurallyCompatible, isFalse);
|
|
32
|
+
expect(m.incompatibleMember, 'mood');
|
|
33
|
+
expect(m.recommendedAction, NomosRecommendedAction.recompileClient);
|
|
34
|
+
expect(
|
|
35
|
+
m.mismatchMessage,
|
|
36
|
+
'client generated for law aaaa, workspace runs law bbbb; structurally incompatible on mood — '
|
|
37
|
+
'recompile the client (githolon compile / npx nomos-compile) against the deployed law',
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('law.* compatible verdict says "proceeding"', () {
|
|
42
|
+
final m = nomosErrorFromInfo(info('nomos.law.drift', attrs: {
|
|
43
|
+
'nomos.law.generatedFor': 'aaaa',
|
|
44
|
+
'nomos.law.workspaceRuns': 'bbbb',
|
|
45
|
+
'nomos.law.structurallyCompatible': true,
|
|
46
|
+
})) as LawMismatchError;
|
|
47
|
+
expect(m.mismatchMessage, startsWith('client generated for law aaaa, workspace runs law bbbb; structurally compatible — proceeding —'));
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('auth.* mints AuthError with reauth as the remedy', () {
|
|
51
|
+
final e = nomosErrorFromInfo(info('nomos.auth.issuer_mismatch', workspace: 'ws-1', attrs: {'nomos.auth.issuer': 'https://evil'}));
|
|
52
|
+
expect(e, isA<AuthError>());
|
|
53
|
+
final a = e as AuthError;
|
|
54
|
+
expect(a.code, 'nomos.auth.issuer_mismatch');
|
|
55
|
+
expect(a.issuer, 'https://evil');
|
|
56
|
+
expect(a.recommendedAction, NomosRecommendedAction.reauth);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('load.* mints LoadError carrying the artifact + source URL', () {
|
|
60
|
+
final e = nomosErrorFromInfo(info('nomos.load.wasm_fetch_failed', attrs: {
|
|
61
|
+
'nomos.load.artifact': 'holon.wasm',
|
|
62
|
+
'nomos.load.sourceUrl': 'https://nomos/runtime/holon.wasm',
|
|
63
|
+
}));
|
|
64
|
+
expect(e, isA<LoadError>());
|
|
65
|
+
final l = e as LoadError;
|
|
66
|
+
expect(l.artifact, 'holon.wasm');
|
|
67
|
+
expect(l.sourceUrl, 'https://nomos/runtime/holon.wasm');
|
|
68
|
+
expect(l.recommendedAction, NomosRecommendedAction.retry);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('custody.* still mints CustodyError (unchanged), unknown codes stay generic', () {
|
|
72
|
+
expect(nomosErrorFromInfo(info('nomos.custody.missing_object')), isA<CustodyError>());
|
|
73
|
+
final g = nomosErrorFromInfo(info('nomos.bridge.error'));
|
|
74
|
+
expect(g, isA<NomosBridgeError>());
|
|
75
|
+
expect(g, isNot(isA<LawMismatchError>()));
|
|
76
|
+
});
|
|
77
|
+
}
|