@githolon/dsl 0.63.0 → 0.65.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/webview_bridge.dart +87 -1
- package/dart_types/lib/nomos_types.dart +4 -0
- package/dart_types/lib/src/foreign_ref.dart +214 -0
- package/package.json +1 -1
- package/src/aggregate.ts +21 -0
- package/src/codegen_dart.ts +66 -1
- package/src/codegen_ts.ts +135 -10
- package/src/compile_package_main.ts +3 -0
- package/src/fields.ts +56 -5
- package/src/usd.ts +30 -0
- package/src/usd_layers.ts +14 -1
|
@@ -19,7 +19,13 @@ import 'dart:convert';
|
|
|
19
19
|
import 'dart:math';
|
|
20
20
|
|
|
21
21
|
import 'package:nomos_types/nomos_types.dart'
|
|
22
|
-
show
|
|
22
|
+
show
|
|
23
|
+
nomosErrorFromInfo,
|
|
24
|
+
NomosTraceContext,
|
|
25
|
+
NomosErrorInfo,
|
|
26
|
+
NomosForeignReader,
|
|
27
|
+
NomosForeignWorkspaceReader,
|
|
28
|
+
ForeignAttestation;
|
|
23
29
|
|
|
24
30
|
/// The protocol version the runner and the bridge agree on (`webview-runner.mjs` `WEBVIEW_BRIDGE_VERSION`).
|
|
25
31
|
const int kWebviewBridgeVersion = 1;
|
|
@@ -840,6 +846,15 @@ class NomosBridge {
|
|
|
840
846
|
NomosSession session(String workspace) =>
|
|
841
847
|
_sessionViews.putIfAbsent(workspace, () => NomosSession(this, workspace));
|
|
842
848
|
|
|
849
|
+
/// A [NomosForeignReader] backed by THIS bridge's multi-workspace plane — what a generated `ForeignRef`
|
|
850
|
+
/// (a `t.foreignRef` cross-workspace locator) resolves against: `row.data.listing.resolve(bridge.foreignReader())`.
|
|
851
|
+
/// `foreignWorkspace(ws).queryById(id)` MOUNTS the foreign workspace on this device's plane ON DEMAND
|
|
852
|
+
/// ([connect] is idempotent — "first connect wins", and `allowUnborn` so a missing workspace reads empty →
|
|
853
|
+
/// `ForeignNotFound`, not a throw) and reads it. [cloud] is the cloud the foreign workspaces sync from
|
|
854
|
+
/// (usually the same cloud your primary workspace uses); omit for a purely local plane. This is a LOCATOR
|
|
855
|
+
/// resolver, not a foreign key — the foreign holon is sovereign, mounted only when you choose to resolve.
|
|
856
|
+
NomosForeignReader foreignReader({String? cloud}) => _BridgeForeignReader(this, cloud);
|
|
857
|
+
|
|
843
858
|
/// The workspace names [connect] has opened on this plane (the live sessions).
|
|
844
859
|
List<String> get connectedSessions => List.unmodifiable(_sessionNames);
|
|
845
860
|
|
|
@@ -1728,3 +1743,74 @@ class NomosSession extends NomosBridge {
|
|
|
1728
1743
|
@override
|
|
1729
1744
|
List<Map<String, Object?>> get recentSpans => parent.recentSpans;
|
|
1730
1745
|
}
|
|
1746
|
+
|
|
1747
|
+
/// THE BRIDGE-BACKED FOREIGN READER — a [NomosForeignReader] over a device's multi-workspace [NomosBridge]
|
|
1748
|
+
/// plane. Constructed via `bridge.foreignReader({cloud})`; a generated `ForeignRef` resolves against it.
|
|
1749
|
+
/// Mounting a foreign workspace has real cost (it opens + syncs that holon on demand) — which is the honest
|
|
1750
|
+
/// price of a cross-workspace read, paid only when you resolve, never up front.
|
|
1751
|
+
class _BridgeForeignReader implements NomosForeignReader {
|
|
1752
|
+
_BridgeForeignReader(this._bridge, this._cloud);
|
|
1753
|
+
final NomosBridge _bridge;
|
|
1754
|
+
final String? _cloud;
|
|
1755
|
+
|
|
1756
|
+
@override
|
|
1757
|
+
NomosForeignWorkspaceReader foreignWorkspace(String workspace) =>
|
|
1758
|
+
_BridgeForeignWorkspaceReader(_bridge, workspace, _cloud);
|
|
1759
|
+
|
|
1760
|
+
// No attestation hook by default — `ForeignRef.attest` throws unless the app wires the attested-read lane
|
|
1761
|
+
// (trustSource + .readsFrom). That gate is the security model (a locator is not a foreign key), not a
|
|
1762
|
+
// limitation: unverifiable "evidence" would be the real footgun.
|
|
1763
|
+
@override
|
|
1764
|
+
Future<ForeignAttestation> Function(String workspace, String id, String type)? get attestForeign => null;
|
|
1765
|
+
}
|
|
1766
|
+
|
|
1767
|
+
/// A per-workspace read handle on the bridge plane — mounts [_workspace] lazily on the first read.
|
|
1768
|
+
class _BridgeForeignWorkspaceReader implements NomosForeignWorkspaceReader {
|
|
1769
|
+
_BridgeForeignWorkspaceReader(this._bridge, this._workspace, this._cloud);
|
|
1770
|
+
final NomosBridge _bridge;
|
|
1771
|
+
final String _workspace;
|
|
1772
|
+
final String? _cloud;
|
|
1773
|
+
Future<void>? _mounting;
|
|
1774
|
+
|
|
1775
|
+
// Mount ONCE (idempotent on the plane — "first connect wins"). `allowUnborn` so a not-yet-born foreign
|
|
1776
|
+
// workspace reads empty (→ ForeignNotFound) rather than throwing. A genuinely unreachable cloud throws,
|
|
1777
|
+
// which resolveResult classifies as ForeignUnreachable.
|
|
1778
|
+
Future<void> _ensureMounted() =>
|
|
1779
|
+
_mounting ??= _bridge.connect(
|
|
1780
|
+
cloud: _cloud,
|
|
1781
|
+
workspace: _workspace,
|
|
1782
|
+
session: _workspace,
|
|
1783
|
+
allowUnborn: true,
|
|
1784
|
+
);
|
|
1785
|
+
|
|
1786
|
+
@override
|
|
1787
|
+
Future<List<Map<String, dynamic>>> queryById(String id) async {
|
|
1788
|
+
await _ensureMounted();
|
|
1789
|
+
return _bridge.session(_workspace).queryById(id);
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
// Reactive: emit the current value, then re-read on every plane sync tick (the foreign workspace's folds
|
|
1793
|
+
// arrive as sync events). Chatty but correct — a foreignRef watch is a convenience, not a hot path.
|
|
1794
|
+
@override
|
|
1795
|
+
Stream<List<Map<String, dynamic>>>? watchById(String id) {
|
|
1796
|
+
late final StreamController<List<Map<String, dynamic>>> ctrl;
|
|
1797
|
+
StreamSubscription<Object?>? sub;
|
|
1798
|
+
Future<void> emit() async {
|
|
1799
|
+
try {
|
|
1800
|
+
if (!ctrl.isClosed) ctrl.add(await queryById(id));
|
|
1801
|
+
} catch (_) {
|
|
1802
|
+
// a transient read failure is not fatal to the subscription — the next sync tick retries.
|
|
1803
|
+
}
|
|
1804
|
+
}
|
|
1805
|
+
ctrl = StreamController<List<Map<String, dynamic>>>(
|
|
1806
|
+
onListen: () {
|
|
1807
|
+
unawaited(emit());
|
|
1808
|
+
sub = _bridge.session(_workspace).syncEvents.listen((_) => unawaited(emit()));
|
|
1809
|
+
},
|
|
1810
|
+
onCancel: () async {
|
|
1811
|
+
await sub?.cancel();
|
|
1812
|
+
},
|
|
1813
|
+
);
|
|
1814
|
+
return ctrl.stream;
|
|
1815
|
+
}
|
|
1816
|
+
}
|
|
@@ -27,3 +27,7 @@ export 'src/schema_validation.dart';
|
|
|
27
27
|
export 'src/aggregate_meta.dart';
|
|
28
28
|
export 'src/error_info.dart';
|
|
29
29
|
export 'src/errors.dart';
|
|
30
|
+
// The FOREIGN-REF locator runtime (t.foreignRef): the typed cross-workspace `ForeignRef<T>` + the structural
|
|
31
|
+
// `NomosForeignReader`/`NomosForeignWorkspaceReader` read surfaces + the named resolve outcomes. Pure data +
|
|
32
|
+
// abstract interfaces (no bridge) — so both the layered TYPES package and nomos_client resolve them.
|
|
33
|
+
export 'src/foreign_ref.dart';
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// NOMOS — the FOREIGN-REF runtime (t.foreignRef): a TYPED CROSS-WORKSPACE LOCATOR, the Dart twin of the
|
|
2
|
+
// TS client's `ForeignRef`/`NomosForeignReader` (codegen_ts.ts). A `t.foreignRef({ workspace, target })`
|
|
3
|
+
// field stores the foreign aggregate's id, alongside a SIBLING field on the same aggregate holding the
|
|
4
|
+
// foreign WORKSPACE id (so the target workspace is dynamic per row). The generated read model surfaces the
|
|
5
|
+
// field as a `ForeignRef<T>` — NOT a raw string — so the app can `resolve`/`watch`/`attest` the target on
|
|
6
|
+
// demand WITHOUT cloning the other holon: it opens the foreign workspace's read surface lazily.
|
|
7
|
+
//
|
|
8
|
+
// A ForeignRef is a LOCATOR, not an atomic foreign key: reading it is informational (resolve/exists/watch);
|
|
9
|
+
// only `attest` is law-grade (signed evidence the reference resolves) and it works ONLY when the law
|
|
10
|
+
// DECLARES the dependency (`trustSource` + `.readsFrom`) — otherwise it throws, by design.
|
|
11
|
+
//
|
|
12
|
+
// These types live in the pure `nomos_types` core (no bridge, no lifecycle) so BOTH the layered TYPES
|
|
13
|
+
// package (frontend-safe) AND the runtime `nomos_client` package resolve them; `nomos_client` re-exports
|
|
14
|
+
// this barrel, and its `NomosBridge` implements [NomosForeignReader] over the device's multi-workspace plane.
|
|
15
|
+
library;
|
|
16
|
+
|
|
17
|
+
/// Reads a FOREIGN workspace on demand — the surface a [ForeignRef] resolves against. It is STRUCTURAL: any
|
|
18
|
+
/// object with this shape works. `nomos_client`'s `NomosBridge` implements it over the device's local
|
|
19
|
+
/// multi-workspace plane (`bridge.session(ws).queryById(id)`, mounting the workspace lazily); the TS twin is
|
|
20
|
+
/// the `openRealm()` reader. A plain single-workspace holon is NOT one — pass the multi-workspace reader.
|
|
21
|
+
abstract class NomosForeignReader {
|
|
22
|
+
/// A read handle for [workspace] (mounted on the local plane on demand, never cloned).
|
|
23
|
+
NomosForeignWorkspaceReader foreignWorkspace(String workspace);
|
|
24
|
+
|
|
25
|
+
/// The LAW-GRADE attestation hook — non-`null` ONLY when the app wires the attested-read lane
|
|
26
|
+
/// (`trustSource` + `.readsFrom`). `null` here ⇒ [ForeignRef.attest] throws, by design (a locator is not an
|
|
27
|
+
/// atomic foreign key). Override in a reader that carries the attested-read lane.
|
|
28
|
+
Future<ForeignAttestation> Function(String workspace, String id, String type)?
|
|
29
|
+
get attestForeign => null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/// One foreign workspace's by-id read surface (used by [ForeignRef.resolve]/[ForeignRef.watch]). Returns the
|
|
33
|
+
/// RAW projection rows (`[{type,id,data}, …]`) — the ForeignRef decodes the matching row's flat `data` map
|
|
34
|
+
/// via the target `fromJson` it captured at construction (the same shape [NomosReads.decodeRows] consumes).
|
|
35
|
+
abstract class NomosForeignWorkspaceReader {
|
|
36
|
+
/// The projection rows for kernel aggregate [id] (a by-id read). Each row is a `{type, id, data}` map, its
|
|
37
|
+
/// `data` the flat already-decoded field map. An EMPTY list ⇒ the target has not been folded / is absent.
|
|
38
|
+
Future<List<Map<String, dynamic>>> queryById(String id);
|
|
39
|
+
|
|
40
|
+
/// OPTIONAL reactive by-id read — the target's rows re-emitted on each projection tick. `null` when this
|
|
41
|
+
/// reader offers no watch lane (then [ForeignRef.watch] calls back once with `null` and no-ops).
|
|
42
|
+
Stream<List<Map<String, dynamic>>>? watchById(String id) => null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/// The OUTCOME of a [ForeignRef.resolveResult], with the reason NAMED — a foreign read can come back empty
|
|
46
|
+
/// because the target does not exist, the reader lacks access, or the holon is unreachable, and those are
|
|
47
|
+
/// very different. [ForeignRef.resolve] collapses them all to `null`; use this when the WHY matters.
|
|
48
|
+
sealed class ForeignResolveResult<T> {
|
|
49
|
+
const ForeignResolveResult();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/// The target resolved — its typed [row] is present.
|
|
53
|
+
class ForeignFound<T> extends ForeignResolveResult<T> {
|
|
54
|
+
final T row;
|
|
55
|
+
const ForeignFound(this.row);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/// The reference does not resolve to a folded aggregate (no such id in the target workspace).
|
|
59
|
+
class ForeignNotFound<T> extends ForeignResolveResult<T> {
|
|
60
|
+
const ForeignNotFound();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/// The read was REFUSED — the reader's error looked like an auth/permission denial (best-effort classified).
|
|
64
|
+
class ForeignDenied<T> extends ForeignResolveResult<T> {
|
|
65
|
+
final Object error;
|
|
66
|
+
const ForeignDenied(this.error);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/// The foreign holon could not be reached (any error that did not look like a denial).
|
|
70
|
+
class ForeignUnreachable<T> extends ForeignResolveResult<T> {
|
|
71
|
+
final Object error;
|
|
72
|
+
const ForeignUnreachable(this.error);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/// LAW-GRADE signed evidence that a [ForeignRef] resolves — returned by [ForeignRef.attest] on the
|
|
76
|
+
/// attested-read lane (`trustSource` + `.readsFrom`). Pure data.
|
|
77
|
+
class ForeignAttestation {
|
|
78
|
+
/// The foreign aggregate's id the attestation is over.
|
|
79
|
+
final String id;
|
|
80
|
+
|
|
81
|
+
/// The foreign workspace the attestation is over.
|
|
82
|
+
final String workspace;
|
|
83
|
+
|
|
84
|
+
/// Whether the target existed at attestation time.
|
|
85
|
+
final bool exists;
|
|
86
|
+
|
|
87
|
+
/// The opaque signed attestation blob (the law-grade evidence).
|
|
88
|
+
final String attestation;
|
|
89
|
+
|
|
90
|
+
const ForeignAttestation({
|
|
91
|
+
required this.id,
|
|
92
|
+
required this.workspace,
|
|
93
|
+
required this.exists,
|
|
94
|
+
required this.attestation,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/// A TYPED CROSS-WORKSPACE LOCATOR (`t.foreignRef`) — the foreign aggregate's [id] + the foreign [workspace]
|
|
99
|
+
/// (from the declared sibling workspace field) + the target aggregate [type], plus the target `fromJson`
|
|
100
|
+
/// captured at construction so [resolve]/[watch] can DECODE it. NOT an atomic foreign key: [resolve]/[watch]
|
|
101
|
+
/// open the foreign holon ON DEMAND (no clone). [exists] is the cheap "is it still there?" check; [attest] is
|
|
102
|
+
/// the law-grade one (see [ForeignAttestation]). The generated read model builds these from a projection row
|
|
103
|
+
/// (the raw id field + the sibling workspace field); [id] stays the raw stored id.
|
|
104
|
+
class ForeignRef<T> {
|
|
105
|
+
/// The foreign aggregate's id (this field's stored value).
|
|
106
|
+
final String id;
|
|
107
|
+
|
|
108
|
+
/// The foreign workspace id (from the declared sibling workspace field).
|
|
109
|
+
final String workspace;
|
|
110
|
+
|
|
111
|
+
/// The foreign aggregate type this locator points at.
|
|
112
|
+
final String type;
|
|
113
|
+
|
|
114
|
+
/// The target's decoder (the aggregate `fromJson` factory), applied to a resolved row's flat `data` map.
|
|
115
|
+
final T Function(Map<String, dynamic> data, [String id]) _decode;
|
|
116
|
+
|
|
117
|
+
const ForeignRef({
|
|
118
|
+
required this.id,
|
|
119
|
+
required this.workspace,
|
|
120
|
+
required this.type,
|
|
121
|
+
required T Function(Map<String, dynamic> data, [String id]) fromJson,
|
|
122
|
+
}) : _decode = fromJson;
|
|
123
|
+
|
|
124
|
+
/// Pick the matching row (by [id], else the first) from a raw `{type,id,data}` row list and decode its flat
|
|
125
|
+
/// `data` via the captured `fromJson` (threading the row `id`, mirroring [NomosReads.decodeRows]). `null`
|
|
126
|
+
/// when the list is empty or the matched row carries no object `data`.
|
|
127
|
+
T? _decodeRows(List<Map<String, dynamic>> rows) {
|
|
128
|
+
if (rows.isEmpty) return null;
|
|
129
|
+
Map<String, dynamic>? match;
|
|
130
|
+
for (final r in rows) {
|
|
131
|
+
if (r['id'] == id) {
|
|
132
|
+
match = r;
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
match ??= rows.first;
|
|
137
|
+
final data = match['data'];
|
|
138
|
+
if (data is! Map<String, dynamic>) return null;
|
|
139
|
+
final rawId = match['id'];
|
|
140
|
+
final rowId = rawId is String ? rawId : (rawId?.toString() ?? id);
|
|
141
|
+
return _decode(data, rowId);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/// THE LOOKUP WITH THE OUTCOME NAMED — found / not-found / denied / unreachable. Opens the foreign
|
|
145
|
+
/// workspace's read surface, reads the target by [id], and classifies an empty/errored read (a thrown
|
|
146
|
+
/// auth/permission-looking error ⇒ denied; any other throw ⇒ unreachable; an empty read ⇒ not-found).
|
|
147
|
+
Future<ForeignResolveResult<T>> resolveResult(NomosForeignReader nomos) async {
|
|
148
|
+
if (id.isEmpty || workspace.isEmpty) return ForeignNotFound<T>();
|
|
149
|
+
try {
|
|
150
|
+
final rows = await nomos.foreignWorkspace(workspace).queryById(id);
|
|
151
|
+
final row = _decodeRows(rows);
|
|
152
|
+
return row != null ? ForeignFound<T>(row) : ForeignNotFound<T>();
|
|
153
|
+
} catch (error) {
|
|
154
|
+
final m = error.toString().toLowerCase();
|
|
155
|
+
final denied = RegExp(r'forbid|denied|permission|unauthor|not permitted|403|401').hasMatch(m);
|
|
156
|
+
return denied ? ForeignDenied<T>(error) : ForeignUnreachable<T>(error);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/// THE LOOKUP (the 99% case): open the foreign workspace + read the target by [id] — on demand, no clone.
|
|
161
|
+
/// Returns `null` for ANY empty outcome (not-found / denied / unreachable); use [resolveResult] for the WHY.
|
|
162
|
+
Future<T?> resolve(NomosForeignReader nomos) async {
|
|
163
|
+
final r = await resolveResult(nomos);
|
|
164
|
+
return r is ForeignFound<T> ? r.row : null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/// Subscribe to the target's read model across the workspace boundary. Returns an unsubscribe fn. Calls
|
|
168
|
+
/// [cb] with `null` once (and no-ops) when the id/workspace is empty or the reader offers no watch lane.
|
|
169
|
+
void Function() watch(NomosForeignReader nomos, void Function(T? row) cb) {
|
|
170
|
+
if (id.isEmpty || workspace.isEmpty) {
|
|
171
|
+
cb(null);
|
|
172
|
+
return () {};
|
|
173
|
+
}
|
|
174
|
+
final stream = nomos.foreignWorkspace(workspace).watchById(id);
|
|
175
|
+
if (stream == null) {
|
|
176
|
+
cb(null);
|
|
177
|
+
return () {};
|
|
178
|
+
}
|
|
179
|
+
final sub = stream.listen(
|
|
180
|
+
(rows) => cb(_decodeRows(rows)),
|
|
181
|
+
onError: (Object _) => cb(null),
|
|
182
|
+
);
|
|
183
|
+
return sub.cancel;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/// Cheap existence check — does the referenced target still exist? (found ⇒ `true`; anything else ⇒ `false`).
|
|
187
|
+
Future<bool> exists(NomosForeignReader nomos) async =>
|
|
188
|
+
(await resolveResult(nomos)) is ForeignFound<T>;
|
|
189
|
+
|
|
190
|
+
/// LAW-GRADE: signed evidence that the reference resolves, for when your law DEPENDS on the foreign fact.
|
|
191
|
+
/// Requires the attested-read lane — declare `trustSource(workspace, …)` + `.readsFrom(…)` on the directive;
|
|
192
|
+
/// without it (no `attestForeign` on the reader) this throws, by design (a locator is not an atomic FK).
|
|
193
|
+
Future<ForeignAttestation> attest(NomosForeignReader nomos) {
|
|
194
|
+
final hook = nomos.attestForeign;
|
|
195
|
+
if (hook == null) {
|
|
196
|
+
throw StateError(
|
|
197
|
+
'attest() needs the attested-read lane — declare trustSource("$workspace", …) + '
|
|
198
|
+
'.readsFrom(…) on the directive whose law depends on this reference '
|
|
199
|
+
'(a foreignRef is a locator, not an atomic foreign key)',
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
return hook(workspace, id, type);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
@override
|
|
206
|
+
bool operator ==(Object other) =>
|
|
207
|
+
other is ForeignRef<T> &&
|
|
208
|
+
other.id == id &&
|
|
209
|
+
other.workspace == workspace &&
|
|
210
|
+
other.type == type;
|
|
211
|
+
|
|
212
|
+
@override
|
|
213
|
+
int get hashCode => Object.hash(id, workspace, type);
|
|
214
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@githolon/dsl",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.65.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/aggregate.ts
CHANGED
|
@@ -243,6 +243,27 @@ export function aggregate<const Id extends string, F extends Record<string, Fiel
|
|
|
243
243
|
opts?: { invariant?: AggregateInvariantFn; invariantDeclassify?: string; cap?: number; visibility?: ReadVisibility; global?: boolean },
|
|
244
244
|
): AggregateHandle<Id, F> & AudienceMethods<Id, F> {
|
|
245
245
|
const { stored, hasMany } = partitionFields(fields);
|
|
246
|
+
// FOREIGN-REF SIBLING CHECK (pain point #4): a `t.foreignRef({ workspace, target })` field names a
|
|
247
|
+
// SIBLING string field (`workspaceField`) that holds the foreign workspace id — the target workspace is
|
|
248
|
+
// DYNAMIC, read from that sibling at resolve time. That link is STRINGLY-TYPED (a bare field name), so a
|
|
249
|
+
// typo would silently produce a locator pointing at a non-existent workspace field. Catch it HERE, at
|
|
250
|
+
// compile: the named sibling must exist on THIS aggregate and be a plain string-kind field (it holds a
|
|
251
|
+
// workspace id). It must be a real string field — not itself a foreignRef/ref — since it carries the raw id.
|
|
252
|
+
for (const [name, field] of Object.entries(stored)) {
|
|
253
|
+
const fref = (field as Field).foreignRef;
|
|
254
|
+
if (fref === undefined) continue;
|
|
255
|
+
const sibling = stored[fref.workspaceField] as Field | undefined;
|
|
256
|
+
if (sibling === undefined) {
|
|
257
|
+
throw new Error(
|
|
258
|
+
`aggregate '${id}': field '${name}' is a t.foreignRef whose workspace field '${fref.workspaceField}' does not exist on aggregate '${id}' — add a \`${fref.workspaceField}: t.string()\` field to aggregate '${id}', or fix the \`workspace:\` name in the foreignRef`,
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
if (sibling.kind !== "string") {
|
|
262
|
+
throw new Error(
|
|
263
|
+
`aggregate '${id}': field '${name}' is a t.foreignRef whose workspace field '${fref.workspaceField}' is kind '${sibling.kind}', but it must be a plain string-kind field (it holds the foreign workspace id) — declare \`${fref.workspaceField}: t.string()\``,
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
246
267
|
if (opts?.cap !== undefined && (!Number.isInteger(opts.cap) || opts.cap < 1)) {
|
|
247
268
|
throw new Error(`aggregate '${id}': cap must be a positive integer (got ${opts.cap})`);
|
|
248
269
|
}
|
package/src/codegen_dart.ts
CHANGED
|
@@ -426,6 +426,19 @@ interface AggFieldSpec {
|
|
|
426
426
|
* non-ref field (or a ref whose target type is unknown — falls back to `String`).
|
|
427
427
|
*/
|
|
428
428
|
refIdClass?: string;
|
|
429
|
+
/**
|
|
430
|
+
* For a `t.foreignRef` field (a TYPED CROSS-WORKSPACE LOCATOR, not an atomic FK): the target
|
|
431
|
+
* aggregate's Dart class (e.g. `CatalogueListing`). The field's Dart type is `ForeignRef<Target>`
|
|
432
|
+
* (from the shared `nomos_types` runtime), NOT the target's typed-id class — so the app can
|
|
433
|
+
* `resolve`/`watch`/`attest` the foreign target on demand. `undefined` for a plain same-workspace
|
|
434
|
+
* `ref` field (which stays its target's typed id class via {@link refIdClass}).
|
|
435
|
+
*/
|
|
436
|
+
foreignRefTarget?: string;
|
|
437
|
+
/**
|
|
438
|
+
* For a `t.foreignRef` field: the wire name of the SIBLING field on THIS aggregate holding the foreign
|
|
439
|
+
* WORKSPACE id (the locator's `workspace`, dynamic per row). `undefined` for a non-foreignRef field.
|
|
440
|
+
*/
|
|
441
|
+
foreignRefWorkspaceField?: string;
|
|
429
442
|
/**
|
|
430
443
|
* Whether the field is OPTIONAL on a folded aggregate — derived from the DSL field
|
|
431
444
|
* schema (GAP 1). A `set`/`map` is a COLLECTION: it is ALWAYS empty-defaultable
|
|
@@ -726,7 +739,23 @@ function aggFieldSpec(
|
|
|
726
739
|
...(field.jsonShape !== undefined ? { jsonShape: field.jsonShape } : {}),
|
|
727
740
|
};
|
|
728
741
|
case "ref": {
|
|
729
|
-
// A `
|
|
742
|
+
// A `t.foreignRef` field is a TYPED CROSS-WORKSPACE LOCATOR (not an atomic FK): surface it as
|
|
743
|
+
// `ForeignRef<Target>` (the shared `nomos_types` runtime type) — the raw stored id + the sibling
|
|
744
|
+
// workspace field give the app `resolve`/`watch`/`attest` on demand, without cloning the other holon.
|
|
745
|
+
// This takes precedence over the same-workspace typed-id path below.
|
|
746
|
+
if (field.foreignRef !== undefined) {
|
|
747
|
+
const target = field.foreignRef.targetAggregateId;
|
|
748
|
+
return {
|
|
749
|
+
name,
|
|
750
|
+
wireName,
|
|
751
|
+
kind: field.kind,
|
|
752
|
+
dartType: `ForeignRef<${target}>`,
|
|
753
|
+
optional,
|
|
754
|
+
foreignRefTarget: target,
|
|
755
|
+
foreignRefWorkspaceField: field.foreignRef.workspaceField,
|
|
756
|
+
};
|
|
757
|
+
}
|
|
758
|
+
// A plain same-workspace `ref` field carries ANOTHER aggregate's id — type it as that target's
|
|
730
759
|
// TYPED id class (cross-aggregate ref typing), so e.g. a `Building.siteId`
|
|
731
760
|
// ref is a `SiteId`, never a bare String. The target wire id is recorded on
|
|
732
761
|
// the field (`refAggregateId`); fall back to `String` only if it is absent.
|
|
@@ -1910,6 +1939,26 @@ function collectionDefaultParam(s: AggFieldSpec): string {
|
|
|
1910
1939
|
* spike path may emit); an `enum` is its bare wire string; a `json` field is the
|
|
1911
1940
|
* stored JSON which may have re-parsed into a container (re-stringified to keep the
|
|
1912
1941
|
* aggregate's `String` type). */
|
|
1942
|
+
/** The decode expression for a `t.foreignRef` field: build the shared `ForeignRef<Target>` locator from the
|
|
1943
|
+
* row's raw id (this field's stored string) + the sibling workspace field (the foreign workspace id) +
|
|
1944
|
+
* the target aggregate's `fromJson` decoder. `id` stays the raw stored id. An optional/absent field decodes
|
|
1945
|
+
* `null`. Shared by the aggregate class and the read model (both surface the field as `ForeignRef<Target>`). */
|
|
1946
|
+
function foreignRefExpr(s: AggFieldSpec, aggregate: string): string {
|
|
1947
|
+
const target = s.foreignRefTarget as string;
|
|
1948
|
+
const wsField = s.foreignRefWorkspaceField as string;
|
|
1949
|
+
const idReader = s.optional
|
|
1950
|
+
? `readStrOrNull(data, '${s.wireName}')`
|
|
1951
|
+
: `readStr(data, '${aggregate}', '${s.wireName}')`;
|
|
1952
|
+
// The sibling workspace field may not have folded yet — default it to '' (a locator with an empty
|
|
1953
|
+
// workspace resolves to not-found, never throws).
|
|
1954
|
+
const wsExpr = `(readStrOrNull(data, '${wsField}') ?? '')`;
|
|
1955
|
+
const build = (idVar: string): string =>
|
|
1956
|
+
`ForeignRef<${target}>(id: ${idVar}, workspace: ${wsExpr}, type: '${target}', fromJson: ${target}.fromJson)`;
|
|
1957
|
+
return s.optional
|
|
1958
|
+
? `() { final v = ${idReader}; return v == null ? null : ${build("v")}; }()`
|
|
1959
|
+
: build(idReader);
|
|
1960
|
+
}
|
|
1961
|
+
|
|
1913
1962
|
function parseField(s: AggFieldSpec, aggregate: string): string {
|
|
1914
1963
|
// A `.optional()` scalar reads through the `…OrNull` tolerant reader (returns null
|
|
1915
1964
|
// when the flat `data` lacks it — a freshly-folded aggregate need not have folded
|
|
@@ -1918,6 +1967,9 @@ function parseField(s: AggFieldSpec, aggregate: string): string {
|
|
|
1918
1967
|
const opt = s.optional;
|
|
1919
1968
|
switch (s.kind) {
|
|
1920
1969
|
case "ref": {
|
|
1970
|
+
// A `t.foreignRef` field decodes to the shared `ForeignRef<Target>` locator (raw id + sibling
|
|
1971
|
+
// workspace field + the target `fromJson`), not a typed-id class.
|
|
1972
|
+
if (s.foreignRefTarget !== undefined) return foreignRefExpr(s, aggregate);
|
|
1921
1973
|
// A ref reads its id STRING, then wraps it via `.fromRow` (NOT the public
|
|
1922
1974
|
// `.fromMinted`, which asserts the type tag). A ref may
|
|
1923
1975
|
// legitimately point at a not-yet-minted aggregate whose id carries no
|
|
@@ -2003,6 +2055,13 @@ function parseField(s: AggFieldSpec, aggregate: string): string {
|
|
|
2003
2055
|
function slotDecodeCallback(s: AggFieldSpec, mode: "aggregate" | "readModel"): string {
|
|
2004
2056
|
switch (s.kind) {
|
|
2005
2057
|
case "ref":
|
|
2058
|
+
// A null-admitting `t.foreignRef` (exotic): the sibling workspace field is not reachable from this
|
|
2059
|
+
// single leaf, so the locator carries the raw id with an empty workspace (resolves to not-found until
|
|
2060
|
+
// read through the normal field path). A plain ref keeps its typed-id decode.
|
|
2061
|
+
if (s.foreignRefTarget !== undefined) {
|
|
2062
|
+
const t = s.foreignRefTarget;
|
|
2063
|
+
return `(v) => ForeignRef<${t}>(id: v as String, workspace: '', type: '${t}', fromJson: ${t}.fromJson)`;
|
|
2064
|
+
}
|
|
2006
2065
|
return s.refIdClass !== undefined
|
|
2007
2066
|
? `(v) => ${s.refIdClass}.fromRow(v as String)`
|
|
2008
2067
|
: `(v) => v as String`;
|
|
@@ -2066,6 +2125,9 @@ export function readModelClassName(aggregateType: string): string {
|
|
|
2066
2125
|
function readModelDartType(s: AggFieldSpec): string {
|
|
2067
2126
|
switch (s.kind) {
|
|
2068
2127
|
case "ref":
|
|
2128
|
+
// A `t.foreignRef` field surfaces as the typed cross-workspace `ForeignRef<Target>` locator; a plain
|
|
2129
|
+
// same-workspace ref stays its target's typed-id class (or a bare String when the target is unknown).
|
|
2130
|
+
if (s.foreignRefTarget !== undefined) return `ForeignRef<${s.foreignRefTarget}>`;
|
|
2069
2131
|
return s.refIdClass ?? "String";
|
|
2070
2132
|
case "json":
|
|
2071
2133
|
// A `t.jsonObject()` leaf (`jsonShape:"object"`, e.g. `location`) is the DECODED
|
|
@@ -2128,6 +2190,9 @@ function parseReadModelField(s: AggFieldSpec, aggregate: string): string {
|
|
|
2128
2190
|
const opt = s.optional;
|
|
2129
2191
|
switch (s.kind) {
|
|
2130
2192
|
case "ref": {
|
|
2193
|
+
// A `t.foreignRef` field decodes to the shared `ForeignRef<Target>` locator (same as the aggregate
|
|
2194
|
+
// class); a plain same-workspace ref stays its target's typed-id class.
|
|
2195
|
+
if (s.foreignRefTarget !== undefined) return foreignRefExpr(s, aggregate);
|
|
2131
2196
|
const reader = opt
|
|
2132
2197
|
? `readStrOrNull(data, '${s.wireName}')`
|
|
2133
2198
|
: `readStr(data, '${aggregate}', '${s.wireName}')`;
|
package/src/codegen_ts.ts
CHANGED
|
@@ -496,8 +496,12 @@ function tsBaseTypeOfField(field: Field): string {
|
|
|
496
496
|
} catch {
|
|
497
497
|
return "string";
|
|
498
498
|
}
|
|
499
|
-
case "ref":
|
|
500
|
-
|
|
499
|
+
case "ref": {
|
|
500
|
+
// A `t.foreignRef` field surfaces as a TYPED cross-workspace locator (hydrated by the accessors from
|
|
501
|
+
// the raw id + the declared workspace field); a plain same-workspace `t.ref` stays the id string.
|
|
502
|
+
const fr = (field as { foreignRef?: { targetAggregateId: string } }).foreignRef;
|
|
503
|
+
return fr !== undefined ? `ForeignRef<${fr.targetAggregateId}Data>` : "string";
|
|
504
|
+
}
|
|
501
505
|
case "json":
|
|
502
506
|
// A NAMED VALUE OBJECT field reads as its generated interface (`value_object.ts`);
|
|
503
507
|
// a bare `t.jsonObject()` stays the anonymous object; a plain `t.json()` is unknown.
|
|
@@ -720,6 +724,76 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
|
|
|
720
724
|
: []),
|
|
721
725
|
`/** One projection row: the aggregate's wire type, its id, and the folded fields. */`,
|
|
722
726
|
`export interface Row<T> { type: string; id: string; data: T }`,
|
|
727
|
+
// MULTI-WORKSPACE LOCATORS (t.foreignRef): a TYPED cross-workspace edge — the foreign aggregate's id +
|
|
728
|
+
// the foreign workspace id (from a sibling field). NOT an atomic FK: it does not clone/open the other
|
|
729
|
+
// holon; `resolve`/`watch` open it ON DEMAND over a realm (openRealm's `.workspace(ws)` surface).
|
|
730
|
+
// The reader `resolve`/`watch`/`attest` need: the multi-workspace `.workspace(ws)` surface `openRealm()`
|
|
731
|
+
// returns (a plain `connect()` holon is single-workspace — pass a Realm). `attestForeign` is the attested-
|
|
732
|
+
// read extension (present only when your law declares `trustSource` + `.readsFrom`). ANY object with this
|
|
733
|
+
// shape works — the generated client is import-free and binds structurally to your @githolon/client.
|
|
734
|
+
`export interface NomosForeignReader { workspace(ws: string): { queryById(id: string): Promise<{ type: string; id: string; data: unknown }[]>; watchById?(id: string, cb: (rows: { type: string; id: string; data: unknown }[]) => void, opts?: { intervalMs?: number }): () => void }; attestForeign?(workspace: string, id: string, type: string): Promise<{ id: string; workspace: string; exists: boolean; attestation: string }>; }`,
|
|
735
|
+
// resolve() collapses "not there" to undefined for the 99% lookup; resolveResult() names WHY — a foreign
|
|
736
|
+
// read can come back empty because the target does not exist, you lack read access, or the holon is
|
|
737
|
+
// unreachable, and those are very different. `denied`/`unreachable` are best-effort from the reader's
|
|
738
|
+
// error (an error whose message mentions auth/permission/forbidden ⇒ denied; else unreachable).
|
|
739
|
+
`export type ForeignResolveResult<TData> = { status: "found"; row: Row<TData> } | { status: "not-found" } | { status: "denied"; error: unknown } | { status: "unreachable"; error: unknown };`,
|
|
740
|
+
// A ForeignRef is a TYPED CROSS-WORKSPACE LOCATOR, not an atomic FK. In ~99% of cases you use it as a
|
|
741
|
+
// lookup — `resolve`/`watch` open the foreign holon on demand so you can do more reads. `exists` is the
|
|
742
|
+
// cheap "is the target still there?" check. `attest` is the LAW-GRADE one — signed evidence that the
|
|
743
|
+
// reference resolves — and only works when your law DECLARES the dependency (`trustSource` + a
|
|
744
|
+
// `.readsFrom(...)` on the directive); it is deliberately NOT a free method, because attesting has cost
|
|
745
|
+
// and law meaning. Resolving/existence are informational; attestation is what you reach for when the
|
|
746
|
+
// current workspace's LAW depends on the foreign fact.
|
|
747
|
+
`export interface ForeignRef<TData> {`,
|
|
748
|
+
` /** The foreign aggregate's id (this field's stored value). */`,
|
|
749
|
+
` readonly id: string;`,
|
|
750
|
+
` /** The foreign workspace id (from the declared sibling workspace field). */`,
|
|
751
|
+
` readonly workspace: string;`,
|
|
752
|
+
` /** The foreign aggregate type this locator points at. */`,
|
|
753
|
+
` readonly type: string;`,
|
|
754
|
+
` /** THE LOOKUP (the 99% case): open the foreign workspace + read the target by id — on demand, no clone.`,
|
|
755
|
+
` * Returns undefined for ANY empty outcome; use resolveResult() when you need to know WHY. */`,
|
|
756
|
+
` resolve(nomos: NomosForeignReader): Promise<Row<TData> | undefined>;`,
|
|
757
|
+
` /** The lookup with the OUTCOME named — found / not-found / denied / unreachable (a foreign read can be`,
|
|
758
|
+
` * empty for very different reasons; do not collapse them when it matters). */`,
|
|
759
|
+
` resolveResult(nomos: NomosForeignReader): Promise<ForeignResolveResult<TData>>;`,
|
|
760
|
+
` /** Subscribe to the target's read model across the workspace boundary (returns an unsubscribe fn). */`,
|
|
761
|
+
` watch(nomos: NomosForeignReader, cb: (row: Row<TData> | undefined) => void, opts?: { intervalMs?: number }): () => void;`,
|
|
762
|
+
` /** Cheap existence check — does the referenced target still exist? (found ⇒ true; anything else ⇒ false). */`,
|
|
763
|
+
` exists(nomos: NomosForeignReader): Promise<boolean>;`,
|
|
764
|
+
` /** LAW-GRADE: signed evidence that the reference resolves, for when your law DEPENDS on the foreign fact.`,
|
|
765
|
+
` * Requires the attested-read lane — declare \`trustSource(workspace, ...)\` + \`.readsFrom(...)\` on the`,
|
|
766
|
+
` * directive; without it (no \`attestForeign\` on the reader) this throws, by design (a locator is not an FK). */`,
|
|
767
|
+
` attest(nomos: NomosForeignReader): Promise<{ id: string; workspace: string; exists: boolean; attestation: string }>;`,
|
|
768
|
+
`}`,
|
|
769
|
+
`/** Build a typed foreign-ref locator from a row's id + workspace-id values. The generated \`<domain>ForeignRefs\` factory calls this with the declared fields. */`,
|
|
770
|
+
`export function foreignRef<TData>(type: string, id: string, workspace: string): ForeignRef<TData> {`,
|
|
771
|
+
` return {`,
|
|
772
|
+
` id, workspace, type,`,
|
|
773
|
+
` async resolveResult(nomos) {`,
|
|
774
|
+
` if (!id || !workspace) return { status: "not-found" };`,
|
|
775
|
+
` try {`,
|
|
776
|
+
` const rows = await nomos.workspace(workspace).queryById(id);`,
|
|
777
|
+
` const row = (rows.find((r) => r.id === id) ?? rows[0]) as Row<TData> | undefined;`,
|
|
778
|
+
` return row !== undefined ? { status: "found", row } : { status: "not-found" };`,
|
|
779
|
+
` } catch (error) {`,
|
|
780
|
+
` const m = String((error as { message?: unknown })?.message ?? error).toLowerCase();`,
|
|
781
|
+
` return { status: /forbid|denied|permission|unauthor|not permitted|403|401/.test(m) ? "denied" : "unreachable", error };`,
|
|
782
|
+
` }`,
|
|
783
|
+
` },`,
|
|
784
|
+
` async resolve(nomos) { const r = await this.resolveResult(nomos); return r.status === "found" ? r.row : undefined; },`,
|
|
785
|
+
` watch(nomos, cb, opts) {`,
|
|
786
|
+
` const w = nomos.workspace(workspace).watchById;`,
|
|
787
|
+
` if (!w || !id || !workspace) { cb(undefined); return () => {}; }`,
|
|
788
|
+
` return w(id, (rows) => cb((rows.find((r) => r.id === id) ?? rows[0]) as Row<TData> | undefined), opts);`,
|
|
789
|
+
` },`,
|
|
790
|
+
` async exists(nomos) { return (await this.resolveResult(nomos)).status === "found"; },`,
|
|
791
|
+
` async attest(nomos) {`,
|
|
792
|
+
` if (!nomos.attestForeign) throw new Error("attest() needs the attested-read lane — declare trustSource(" + JSON.stringify(workspace) + ", …) + .readsFrom(…) on the directive whose law depends on this reference (a foreignRef is a locator, not an atomic foreign key)");`,
|
|
793
|
+
` return nomos.attestForeign(workspace, id, type);`,
|
|
794
|
+
` },`,
|
|
795
|
+
` };`,
|
|
796
|
+
`}`,
|
|
723
797
|
``,
|
|
724
798
|
`/** Arbitrary JSON — the type a RECURSIVE \`z.lazy\` payload subtree degrades to (a self-`,
|
|
725
799
|
` * referencing tree/JSON schema can't be a finite named literal, so the engine validates it`,
|
|
@@ -1145,6 +1219,11 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
|
|
|
1145
1219
|
}
|
|
1146
1220
|
|
|
1147
1221
|
out.push(`// ── read models (projection \`data\` shapes; partial-fold optional, EXCEPT .fromBirth()`, `// fields — their presence is gate law, so the type is non-optional) ──`, ``);
|
|
1222
|
+
const foreignRefFields: { agg: string; field: string; workspaceField: string; target: string }[] = [];
|
|
1223
|
+
// wrap a read accessor return expression through the foreignRef row-hydrator (identity + byte-identical
|
|
1224
|
+
// when the package declares no foreignRef, so non-foreignRef packages are unchanged).
|
|
1225
|
+
const wrapP = (expr: string): string => (foreignRefFields.length > 0 ? `_hydrateP(${expr})` : expr);
|
|
1226
|
+
const wrapRows = (expr: string): string => (foreignRefFields.length > 0 ? `_hydrateRows(${expr})` : expr);
|
|
1148
1227
|
for (const [typeId, agg] of [...aggByType].sort(([a], [b]) => a.localeCompare(b))) {
|
|
1149
1228
|
out.push(`export interface ${typeId}Data {`);
|
|
1150
1229
|
for (const [name, field] of Object.entries(agg.fields)) {
|
|
@@ -1152,10 +1231,54 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
|
|
|
1152
1231
|
const doc = nullDocLine(field);
|
|
1153
1232
|
if (doc !== undefined) out.push(` /** ${doc} */`);
|
|
1154
1233
|
out.push(` ${name}${required ? "" : "?"}: ${tsTypeOfField(field as Field)};`);
|
|
1234
|
+
const fr = (field as { foreignRef?: { workspaceField: string; targetAggregateId: string } }).foreignRef;
|
|
1235
|
+
if (fr !== undefined) foreignRefFields.push({ agg: typeId, field: name, workspaceField: fr.workspaceField, target: fr.targetAggregateId });
|
|
1155
1236
|
}
|
|
1156
1237
|
for (const extra of extrasByType.get(typeId) ?? []) out.push(extra);
|
|
1157
1238
|
out.push(`}`, ``);
|
|
1158
1239
|
}
|
|
1240
|
+
// MULTI-WORKSPACE LOCATOR FACTORY (t.foreignRef): per aggregate, a typed builder that turns a projection
|
|
1241
|
+
// row into a `ForeignRef<TargetData>` — reading the foreign id (the field) + the foreign workspace id (the
|
|
1242
|
+
// declared sibling field). The read model stays honest (the field is the raw stored id string); this gives
|
|
1243
|
+
// the TYPED locator with resolve/watch on top, without cloning the other holon.
|
|
1244
|
+
if (foreignRefFields.length > 0) {
|
|
1245
|
+
const byAgg = new Map<string, typeof foreignRefFields>();
|
|
1246
|
+
for (const f of foreignRefFields) { const l = byAgg.get(f.agg) ?? []; l.push(f); byAgg.set(f.agg, l); }
|
|
1247
|
+
out.push(`// ── foreign-ref locators (t.foreignRef): typed cross-workspace edges (resolve/watch on demand) ──`);
|
|
1248
|
+
out.push(`export const ${camel(opts.packageName)}ForeignRefs = {`);
|
|
1249
|
+
for (const [agg, fields] of [...byAgg].sort(([a], [b]) => a.localeCompare(b))) {
|
|
1250
|
+
out.push(` ${agg}: {`);
|
|
1251
|
+
for (const f of fields) {
|
|
1252
|
+
out.push(
|
|
1253
|
+
` /** \`${agg}.${f.field}\` → \`${f.target}\` in the workspace named by \`${f.workspaceField}\`. */`,
|
|
1254
|
+
` ${f.field}: (row: ${agg}Data): ForeignRef<${f.target}Data> => foreignRef(${JSON.stringify(f.target)}, String(row.${f.field} ?? ""), String((row as Record<string, unknown>)[${JSON.stringify(f.workspaceField)}] ?? "")),`,
|
|
1255
|
+
);
|
|
1256
|
+
}
|
|
1257
|
+
out.push(` },`);
|
|
1258
|
+
}
|
|
1259
|
+
out.push(`} as const;`, ``);
|
|
1260
|
+
// ROW HYDRATION: the read model surfaces a foreignRef field as a typed `ForeignRef` locator (so
|
|
1261
|
+
// `row.data.<field>.resolve(nomos)` works directly). The projection stores the raw id string; the read
|
|
1262
|
+
// accessors pipe rows through `_hydrateRows`, which — for aggregates that declare foreignRefs — replaces
|
|
1263
|
+
// each foreignRef field with a locator built from its id + the declared sibling workspace field.
|
|
1264
|
+
// `row.data.<field>.id` is still the raw id. Identity for any aggregate without foreignRefs.
|
|
1265
|
+
const hydrateSpecs: Record<string, { field: string; wsField: string; target: string }[]> = {};
|
|
1266
|
+
for (const f of foreignRefFields) { (hydrateSpecs[f.agg] ??= []).push({ field: f.field, wsField: f.workspaceField, target: f.target }); }
|
|
1267
|
+
out.push(
|
|
1268
|
+
`const _FOREIGN_REFS: Record<string, { field: string; wsField: string; target: string }[]> = ${JSON.stringify(hydrateSpecs)};`,
|
|
1269
|
+
`function _hydrateRows<T>(rows: Row<T>[]): Row<T>[] {`,
|
|
1270
|
+
` return rows.map((row) => {`,
|
|
1271
|
+
` const specs = _FOREIGN_REFS[row.type];`,
|
|
1272
|
+
` if (specs === undefined || row.data == null) return row;`,
|
|
1273
|
+
` const data: Record<string, unknown> = { ...(row.data as Record<string, unknown>) };`,
|
|
1274
|
+
` for (const s of specs) { const v = data[s.field]; if (typeof v === "string") data[s.field] = foreignRef(s.target, v, String(data[s.wsField] ?? "")); }`,
|
|
1275
|
+
` return { ...row, data: data as T };`,
|
|
1276
|
+
` });`,
|
|
1277
|
+
`}`,
|
|
1278
|
+
`const _hydrateP = <T>(p: Promise<Row<T>[]>): Promise<Row<T>[]> => p.then(_hydrateRows);`,
|
|
1279
|
+
``,
|
|
1280
|
+
);
|
|
1281
|
+
}
|
|
1159
1282
|
|
|
1160
1283
|
// ── DERIVED BIRTH LANES (sharding §10 RATIFIED, slice 1) ──
|
|
1161
1284
|
// A `workspaceType(...).hasMany(...)` taxonomy DERIVES typed birth helpers over
|
|
@@ -1494,7 +1617,7 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
|
|
|
1494
1617
|
for (const sp of (mod.spatials ?? []) as { id: string; of: string; on: string }[]) {
|
|
1495
1618
|
methods.push(
|
|
1496
1619
|
` /** Declared spatial index \`${sp.id}\` — rows of \`${sp.of}\` whose \`${sp.on}\` geometry intersects the bbox (R*Tree probe). */`,
|
|
1497
|
-
` ${lcFirst(camel(sp.id))}: (bbox: { minX: number; minY: number; maxX: number; maxY: number } | { minLng: number; minLat: number; maxLng: number; maxLat: number }) => (holon.spatialWithin ?? (() => { throw new Error("this holon predates spatial reads — upgrade @githolon/client"); }))(${JSON.stringify(sp.id)}, bbox) as Promise<Row<${sp.of}Data>[]
|
|
1620
|
+
` ${lcFirst(camel(sp.id))}: (bbox: { minX: number; minY: number; maxX: number; maxY: number } | { minLng: number; minLat: number; maxLng: number; maxLat: number }) => ${wrapP(`(holon.spatialWithin ?? (() => { throw new Error("this holon predates spatial reads — upgrade @githolon/client"); }))(${JSON.stringify(sp.id)}, bbox) as Promise<Row<${sp.of}Data>[]>`)},`,
|
|
1498
1621
|
);
|
|
1499
1622
|
}
|
|
1500
1623
|
for (const q of (mod.queries ?? []) as QueryDecl[]) {
|
|
@@ -1511,8 +1634,8 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
|
|
|
1511
1634
|
methods.push(
|
|
1512
1635
|
` /** Declared query \`${q.id}\` (indexed probe — routed by the read manifest).${qScopes.length ? " Encrypted field(s) decrypt when the bound { keyStore } holds the scope key; else they stay opaque envelopes." : ""} */`,
|
|
1513
1636
|
qScopes.length
|
|
1514
|
-
? ` ${lcFirst(camel(q.id))}: async (params: { ${params} }) => holon.query(${JSON.stringify(q.id)}, params, await _readKeys(keyStore, ${JSON.stringify(qScopes)})) as Promise<Row<${q.returns}Data>[]
|
|
1515
|
-
: ` ${lcFirst(camel(q.id))}: (params: { ${params} }) => holon.query(${JSON.stringify(q.id)}, params) as Promise<Row<${q.returns}Data>[]
|
|
1637
|
+
? ` ${lcFirst(camel(q.id))}: async (params: { ${params} }) => ${wrapP(`holon.query(${JSON.stringify(q.id)}, params, await _readKeys(keyStore, ${JSON.stringify(qScopes)})) as Promise<Row<${q.returns}Data>[]>`)},`
|
|
1638
|
+
: ` ${lcFirst(camel(q.id))}: (params: { ${params} }) => ${wrapP(`holon.query(${JSON.stringify(q.id)}, params) as Promise<Row<${q.returns}Data>[]>`)},`,
|
|
1516
1639
|
);
|
|
1517
1640
|
// THE LOGICAL PAGED ROW SURFACE (sharding §5.3, ruling 2 — slice 4): emitted
|
|
1518
1641
|
// ONLY for packed-taxonomy domains (taxonomy-free clients stay byte-identical).
|
|
@@ -1553,15 +1676,17 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
|
|
|
1553
1676
|
methods.push(
|
|
1554
1677
|
` /** Read one ${agg.id} by aggregate id.${aScopes.length ? " Encrypted field(s) decrypt when the bound { keyStore } holds the scope key." : ""} */`,
|
|
1555
1678
|
aScopes.length
|
|
1556
|
-
? ` ${lcFirst(camel(agg.id))}ById: async (id: string) => holon.queryById(id, await _readKeys(keyStore, ${JSON.stringify(aScopes)})) as Promise<Row<${agg.id}Data>[]
|
|
1557
|
-
: ` ${lcFirst(camel(agg.id))}ById: (id: string) => holon.queryById(id) as Promise<Row<${agg.id}Data>[]
|
|
1679
|
+
? ` ${lcFirst(camel(agg.id))}ById: async (id: string) => ${wrapP(`holon.queryById(id, await _readKeys(keyStore, ${JSON.stringify(aScopes)})) as Promise<Row<${agg.id}Data>[]>`)},`
|
|
1680
|
+
: ` ${lcFirst(camel(agg.id))}ById: (id: string) => ${wrapP(`holon.queryById(id) as Promise<Row<${agg.id}Data>[]>`)},`,
|
|
1558
1681
|
` /** Every ${agg.id} (the type-index probe — O(result), not a scan). */`,
|
|
1559
1682
|
aScopes.length
|
|
1560
|
-
? ` list${agg.id}s: async () => holon.query("aggregatesByType", { __type: ${JSON.stringify(agg.id)} }, await _readKeys(keyStore, ${JSON.stringify(aScopes)})) as Promise<Row<${agg.id}Data>[]
|
|
1561
|
-
: ` list${agg.id}s: () => holon.query("aggregatesByType", { __type: ${JSON.stringify(agg.id)} }) as Promise<Row<${agg.id}Data>[]
|
|
1683
|
+
? ` list${agg.id}s: async () => ${wrapP(`holon.query("aggregatesByType", { __type: ${JSON.stringify(agg.id)} }, await _readKeys(keyStore, ${JSON.stringify(aScopes)})) as Promise<Row<${agg.id}Data>[]>`)},`
|
|
1684
|
+
: ` list${agg.id}s: () => ${wrapP(`holon.query("aggregatesByType", { __type: ${JSON.stringify(agg.id)} }) as Promise<Row<${agg.id}Data>[]>`)},`,
|
|
1562
1685
|
` /** Watch one ${agg.id} by aggregate id (local reactive read; returns stop()). */`,
|
|
1563
1686
|
` watch${agg.id}ById: (id: string, cb: (rows: Row<${agg.id}Data>[]) => void, opts?: { intervalMs?: number }) =>`,
|
|
1564
|
-
|
|
1687
|
+
foreignRefFields.length > 0
|
|
1688
|
+
? ` holon.watchById(id, ((rows: Row<${agg.id}Data>[]) => cb(_hydrateRows(rows))) as (rows: unknown) => void, opts),`
|
|
1689
|
+
: ` holon.watchById(id, cb as (rows: unknown) => void, opts),`,
|
|
1565
1690
|
);
|
|
1566
1691
|
}
|
|
1567
1692
|
|
|
@@ -423,6 +423,9 @@ const DART_SUPPORT_CORE_SRC = [
|
|
|
423
423
|
// `<name>.client.dart` reference these through the barrel. No external pub deps.
|
|
424
424
|
"error_info.dart",
|
|
425
425
|
"errors.dart",
|
|
426
|
+
// The foreign-ref locator runtime (ForeignRef<T>/NomosForeignReader/…, t.foreignRef) — a generated read
|
|
427
|
+
// model with a `t.foreignRef` field references `ForeignRef<Target>` through the barrel, so it must vendor.
|
|
428
|
+
"foreign_ref.dart",
|
|
426
429
|
] as const;
|
|
427
430
|
// The RUNTIME support files, vendored from `nomos_client` (dsl/dart/lib/src): the async WebView
|
|
428
431
|
// bridge the generated `<name>.client.dart` drives, and the provider surface. Their
|
package/src/fields.ts
CHANGED
|
@@ -268,6 +268,21 @@ export interface Field<T = unknown, K extends FieldKind = FieldKind> {
|
|
|
268
268
|
readonly clearRequires?: string;
|
|
269
269
|
/** For `t.ref` cross-workspace edges: the foreign workspace id, if any. */
|
|
270
270
|
readonly refWorkspace?: string;
|
|
271
|
+
/**
|
|
272
|
+
* `t.foreignRef({ workspace, target })` — a TYPED CROSS-WORKSPACE LOCATOR (not an atomic FK). Stored as
|
|
273
|
+
* the foreign aggregate's id STRING (kind `"ref"`, zero kernel change); this meta names WHERE the foreign
|
|
274
|
+
* workspace id lives (`workspaceField` — a sibling field on THIS aggregate, so the target workspace is
|
|
275
|
+
* DYNAMIC) + the TARGET aggregate/domain (typed codegen). The read model surfaces it as a typed locator
|
|
276
|
+
* (`ForeignRef<TargetData>` = `{ id, workspace }`), NEVER auto-cloning the other holon; the generated
|
|
277
|
+
* client's `resolve(nomos)`/`watch(nomos)` open the foreign workspace on demand. Law that DEPENDS on the
|
|
278
|
+
* foreign fact opts into attested evidence separately (`trustSource`/`.readsFrom`). Present ⇒ codegen emits
|
|
279
|
+
* the locator; absent ⇒ a plain `t.ref`. Additive IR (`nomosForeignRefs`), era-safe.
|
|
280
|
+
*/
|
|
281
|
+
readonly foreignRef?: {
|
|
282
|
+
readonly workspaceField: string;
|
|
283
|
+
readonly targetAggregateId: string;
|
|
284
|
+
readonly targetDomain?: string;
|
|
285
|
+
};
|
|
271
286
|
/** For `t.ref` (the target) AND `t.hasMany` (the child type): the related aggregate's wire id. */
|
|
272
287
|
readonly refAggregateId?: string;
|
|
273
288
|
/** For `t.hasMany(Child).via(backref)`: the back-reference field NAME on the child. Virtual. */
|
|
@@ -396,6 +411,7 @@ function makeField<T, K extends FieldKind = FieldKind>(
|
|
|
396
411
|
driver: Driver,
|
|
397
412
|
extra: {
|
|
398
413
|
refWorkspace?: string;
|
|
414
|
+
foreignRef?: { workspaceField: string; targetAggregateId: string; targetDomain?: string };
|
|
399
415
|
refAggregateId?: string;
|
|
400
416
|
/** LAZY target resolution — `t.ref(() => Agg)` / `t.hasMany(() => Agg)`. Breaks a BIDIRECTIONAL
|
|
401
417
|
* declaration cycle (a Parent's `hasMany(Child)` ⇄ a Child's `ref(Parent)`): the thunk is stored and
|
|
@@ -428,6 +444,7 @@ function makeField<T, K extends FieldKind = FieldKind>(
|
|
|
428
444
|
...(extra.facet !== undefined ? { facet: extra.facet } : {}),
|
|
429
445
|
...(extra.refWorkspace !== undefined ? { refWorkspace: extra.refWorkspace } : {}),
|
|
430
446
|
...(extra.refAggregateId !== undefined ? { refAggregateId: extra.refAggregateId } : {}),
|
|
447
|
+
...(extra.foreignRef !== undefined ? { foreignRef: extra.foreignRef } : {}),
|
|
431
448
|
...(extra.viaField !== undefined ? { viaField: extra.viaField } : {}),
|
|
432
449
|
...(extra.mapValueKind !== undefined ? { mapValueKind: extra.mapValueKind } : {}),
|
|
433
450
|
...(extra.jsonShape !== undefined ? { jsonShape: extra.jsonShape } : {}),
|
|
@@ -753,6 +770,30 @@ export const t = {
|
|
|
753
770
|
* routing is the kernel's job.
|
|
754
771
|
*/
|
|
755
772
|
ref: refImpl as RefFn,
|
|
773
|
+
/**
|
|
774
|
+
* `t.foreignRef({ workspace, target })` — a TYPED CROSS-WORKSPACE LOCATOR. Use for an edge into ANOTHER
|
|
775
|
+
* holon (a catalogue listing shared into an estate, a media/blob ref, a home's "accessible estate" card):
|
|
776
|
+
* the field STORES the foreign aggregate's id, `workspace` names a SIBLING field holding the foreign
|
|
777
|
+
* workspace id (so the target workspace is dynamic), and `target` types it to the foreign aggregate. The
|
|
778
|
+
* read model surfaces a typed `ForeignRef` locator ({ id, workspace }); it does NOT clone/open the other
|
|
779
|
+
* holon — the generated client's `resolve(nomos)`/`watch(nomos)` do that on demand. It is a LOCATOR, not a
|
|
780
|
+
* database join: law that depends on the foreign FACT opts into attested evidence via `trustSource` +
|
|
781
|
+
* `.readsFrom(...)`. Same-workspace edges stay `t.ref`/`t.hasMany`; this is the honest cross-workspace twin.
|
|
782
|
+
*/
|
|
783
|
+
foreignRef(opts: {
|
|
784
|
+
workspace: string;
|
|
785
|
+
target: AggregateHandle<string, Record<string, Field>>;
|
|
786
|
+
domain?: string;
|
|
787
|
+
}): Field<string, "ref"> {
|
|
788
|
+
return makeField<string, "ref">("ref", z.string(), Conflict, {
|
|
789
|
+
refAggregateId: opts.target.id,
|
|
790
|
+
foreignRef: {
|
|
791
|
+
workspaceField: opts.workspace,
|
|
792
|
+
targetAggregateId: opts.target.id,
|
|
793
|
+
...(opts.domain !== undefined ? { targetDomain: opts.domain } : {}),
|
|
794
|
+
},
|
|
795
|
+
});
|
|
796
|
+
},
|
|
756
797
|
/**
|
|
757
798
|
* A 1:N INVERSE relation — the read side of a `t.ref` on the child (`aggregate_lifecycle_and_relations.md`).
|
|
758
799
|
* `t.hasMany(Child).via("parent")` on a Parent declares "the Children whose `parent` ref points here".
|
|
@@ -761,9 +802,9 @@ export const t = {
|
|
|
761
802
|
* (one event on the CHILD, parent untouched), and the read engine serves `parent.children` as the
|
|
762
803
|
* managed `Child by parent` index. The dev never builds a write-amplifying parent collection.
|
|
763
804
|
*/
|
|
764
|
-
hasMany
|
|
765
|
-
|
|
766
|
-
} {
|
|
805
|
+
hasMany: (function hasMany(
|
|
806
|
+
child: AggregateHandle<string, Record<string, Field>> | (() => AggregateHandle<string, Record<string, Field>>),
|
|
807
|
+
): { via(backref: string): Field<string[], "hasMany"> } {
|
|
767
808
|
// THUNK form (`t.hasMany(() => Child)`) — the common bidirectional case (a Parent's `hasMany(Child)` ⇄ a
|
|
768
809
|
// Child's `ref(Parent)`): pass a thunk so the Parent needn't wait for the Child's `const` to exist.
|
|
769
810
|
const isThunk = typeof child === "function";
|
|
@@ -775,6 +816,12 @@ export const t = {
|
|
|
775
816
|
});
|
|
776
817
|
},
|
|
777
818
|
};
|
|
819
|
+
}) as {
|
|
820
|
+
/** 1:N inverse by aggregate HANDLE. */
|
|
821
|
+
(child: AggregateHandle<string, Record<string, Field>>): { via(backref: string): Field<string[], "hasMany"> };
|
|
822
|
+
/** 1:N inverse by a THUNK (`t.hasMany(() => Child)`) — a DISTINCT overload so the arrow's return is
|
|
823
|
+
* contextually the WIDE handle, breaking the bidirectional `const` cycle (no circular-`any`). */
|
|
824
|
+
(child: () => AggregateHandle<string, Record<string, Field>>): { via(backref: string): Field<string[], "hasMany"> };
|
|
778
825
|
},
|
|
779
826
|
/**
|
|
780
827
|
* A content-addressed EVIDENCE reference (`evidence.md` §6/§9.2). The authored value is
|
|
@@ -795,8 +842,12 @@ export const t = {
|
|
|
795
842
|
type RefTarget = AggregateHandle<string, Record<string, Field>> | (() => AggregateHandle<string, Record<string, Field>>);
|
|
796
843
|
|
|
797
844
|
interface RefFn {
|
|
798
|
-
/** Same-workspace reference
|
|
799
|
-
(target:
|
|
845
|
+
/** Same-workspace reference by aggregate HANDLE. */
|
|
846
|
+
(target: AggregateHandle<string, Record<string, Field>>): Field<string, "ref">;
|
|
847
|
+
/** Same-workspace reference by a THUNK (`t.ref(() => Agg)`) — breaks a bidirectional declaration cycle.
|
|
848
|
+
* A DISTINCT overload (not a union arm) so TS contextually types the arrow's return as the WIDE handle,
|
|
849
|
+
* which is what keeps `const A = ...ref(() => B)` / `const B = ...ref(() => A)` from a circular-`any`. */
|
|
850
|
+
(target: () => AggregateHandle<string, Record<string, Field>>): Field<string, "ref">;
|
|
800
851
|
(
|
|
801
852
|
workspaceId: string,
|
|
802
853
|
target: AggregateHandle<string, Record<string, Field>>,
|
package/src/usd.ts
CHANGED
|
@@ -585,6 +585,24 @@ export interface UsdLayer {
|
|
|
585
585
|
* (byte-identical hash — the era rule); declaring a VO moves the PACKAGE hash only
|
|
586
586
|
* (the canonical manifest/domainHash is untouched — the VO is not wire law).
|
|
587
587
|
*/
|
|
588
|
+
/**
|
|
589
|
+
* MULTI-WORKSPACE LOCATORS (`t.foreignRef`) — the self-describing cross-workspace edges this domain
|
|
590
|
+
* declares: `{aggregate, field, workspaceField, target, targetDomain?}` sorted by (aggregate, field).
|
|
591
|
+
* Descriptive law metadata (codegen emits a typed `ForeignRef` locator + resolve/watch; proof knows the
|
|
592
|
+
* edge shape without proving the concrete foreign workspace). A LOCATOR, not an atomic FK — law that
|
|
593
|
+
* depends on the foreign fact opts into attested evidence separately (`nomosAttestedReadGate`). OMITTED
|
|
594
|
+
* when the domain declares no foreignRef (era-safe — hash-identical to before this key existed).
|
|
595
|
+
*/
|
|
596
|
+
readonly nomosForeignRefs?: {
|
|
597
|
+
readonly v: 1;
|
|
598
|
+
readonly refs: readonly {
|
|
599
|
+
readonly aggregate: string;
|
|
600
|
+
readonly field: string;
|
|
601
|
+
readonly workspaceField: string;
|
|
602
|
+
readonly target: string;
|
|
603
|
+
readonly targetDomain?: string;
|
|
604
|
+
}[];
|
|
605
|
+
};
|
|
588
606
|
readonly nomosValueObjects?: {
|
|
589
607
|
readonly v: 1;
|
|
590
608
|
readonly valueObjects: readonly import("./fields.js").ValueObjectDescriptor[];
|
|
@@ -1329,6 +1347,17 @@ export function emitUsd(
|
|
|
1329
1347
|
const voUsages = collectValueObjectUsages(
|
|
1330
1348
|
l.module.aggregates as unknown as readonly { id: string; fields: Record<string, import("./fields.js").Field> }[],
|
|
1331
1349
|
);
|
|
1350
|
+
// MULTI-WORKSPACE LOCATORS: collect every `t.foreignRef` field across the module's aggregates.
|
|
1351
|
+
const foreignRefList: { aggregate: string; field: string; workspaceField: string; target: string; targetDomain?: string }[] = [];
|
|
1352
|
+
for (const agg of l.module.aggregates) {
|
|
1353
|
+
for (const [fname, f] of Object.entries((agg as { fields: Record<string, { foreignRef?: { workspaceField: string; targetAggregateId: string; targetDomain?: string } }> }).fields)) {
|
|
1354
|
+
const fr = f.foreignRef;
|
|
1355
|
+
if (fr === undefined) continue;
|
|
1356
|
+
foreignRefList.push({ aggregate: agg.id, field: fname, workspaceField: fr.workspaceField, target: fr.targetAggregateId, ...(fr.targetDomain !== undefined ? { targetDomain: fr.targetDomain } : {}) });
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
foreignRefList.sort((a, b) => (a.aggregate !== b.aggregate ? (a.aggregate < b.aggregate ? -1 : 1) : a.field < b.field ? -1 : a.field > b.field ? 1 : 0));
|
|
1360
|
+
const nomosForeignRefs = foreignRefList.length > 0 ? { v: 1 as const, refs: foreignRefList } : undefined;
|
|
1332
1361
|
const nomosValueObjects =
|
|
1333
1362
|
moduleValueObjects !== undefined && moduleValueObjects.length > 0
|
|
1334
1363
|
? {
|
|
@@ -1388,6 +1417,7 @@ export function emitUsd(
|
|
|
1388
1417
|
...(nomosParentAttestedBirth !== undefined ? { nomosParentAttestedBirth } : {}),
|
|
1389
1418
|
...(nomosVerifiedFirstEnroll !== undefined ? { nomosVerifiedFirstEnroll } : {}),
|
|
1390
1419
|
...(eraCertGate !== undefined ? { nomosEraCertGate: eraCertGate } : {}),
|
|
1420
|
+
...(nomosForeignRefs !== undefined ? { nomosForeignRefs } : {}),
|
|
1391
1421
|
...(nomosValueObjects !== undefined ? { nomosValueObjects } : {}),
|
|
1392
1422
|
...(Object.keys(visAggregates).length > 0
|
|
1393
1423
|
? { nomosReadVisibility: { v: 1 as const, aggregates: visAggregates } }
|
package/src/usd_layers.ts
CHANGED
|
@@ -206,6 +206,9 @@ function domainLayerAttrLines(domain: string, layer: UsdLayer, indent: string):
|
|
|
206
206
|
...(layer.nomosValueObjects !== undefined
|
|
207
207
|
? [`${indent}custom string nomos:valueObjectsHex = ${usdaString(hexUtf8(JSON.stringify(layer.nomosValueObjects)))}`]
|
|
208
208
|
: []),
|
|
209
|
+
...(layer.nomosForeignRefs !== undefined
|
|
210
|
+
? [`${indent}custom string nomos:foreignRefsHex = ${usdaString(hexUtf8(JSON.stringify(layer.nomosForeignRefs)))}`]
|
|
211
|
+
: []),
|
|
209
212
|
];
|
|
210
213
|
}
|
|
211
214
|
|
|
@@ -948,7 +951,9 @@ export function flattenLayeredUsd(layers: readonly UsdLayer[]): UsdDocument {
|
|
|
948
951
|
valueObjects = { v: 1, valueObjects: descs, ...(uses.length > 0 ? { usages: uses } : {}) };
|
|
949
952
|
}
|
|
950
953
|
}
|
|
951
|
-
|
|
954
|
+
let foreignRefs: UsdLayer["nomosForeignRefs"];
|
|
955
|
+
for (const l of stack) { if (l.nomosForeignRefs !== undefined) foreignRefs = l.nomosForeignRefs; }
|
|
956
|
+
return {
|
|
952
957
|
path,
|
|
953
958
|
prims: [...prims.keys()].sort().map((p) => prims.get(p)!),
|
|
954
959
|
...(identityManifest !== undefined ? { nomosIdentityManifest: identityManifest } : {}),
|
|
@@ -973,6 +978,7 @@ export function flattenLayeredUsd(layers: readonly UsdLayer[]): UsdDocument {
|
|
|
973
978
|
...(verifiedFirstEnroll !== undefined ? { nomosVerifiedFirstEnroll: verifiedFirstEnroll } : {}),
|
|
974
979
|
...(eraCertGate !== undefined ? { nomosEraCertGate: eraCertGate } : {}),
|
|
975
980
|
...(valueObjects !== undefined ? { nomosValueObjects: valueObjects } : {}),
|
|
981
|
+
...(foreignRefs !== undefined ? { nomosForeignRefs: foreignRefs } : {}),
|
|
976
982
|
};
|
|
977
983
|
});
|
|
978
984
|
|
|
@@ -1112,6 +1118,7 @@ export function parseUsdaDocument(text: string): UsdDocument {
|
|
|
1112
1118
|
const verifiedFirstEnrollByDomain = new Map<string, UsdLayer["nomosVerifiedFirstEnroll"]>();
|
|
1113
1119
|
const eraCertGateByDomain = new Map<string, UsdLayer["nomosEraCertGate"]>();
|
|
1114
1120
|
const valueObjectsByDomain = new Map<string, UsdLayer["nomosValueObjects"]>();
|
|
1121
|
+
const foreignRefsByDomain = new Map<string, UsdLayer["nomosForeignRefs"]>();
|
|
1115
1122
|
for (const p of prims) {
|
|
1116
1123
|
if (p.attrs.get("nomos:kind") === "domain") {
|
|
1117
1124
|
const domain = attrString(p, "nomos:domain");
|
|
@@ -1207,6 +1214,10 @@ export function parseUsdaDocument(text: string): UsdDocument {
|
|
|
1207
1214
|
if (typeof voHex === "string") {
|
|
1208
1215
|
valueObjectsByDomain.set(domain, JSON.parse(unhexUtf8(voHex)) as UsdLayer["nomosValueObjects"]);
|
|
1209
1216
|
}
|
|
1217
|
+
const frHex = p.attrs.get("nomos:foreignRefsHex");
|
|
1218
|
+
if (typeof frHex === "string") {
|
|
1219
|
+
foreignRefsByDomain.set(domain, JSON.parse(unhexUtf8(frHex)) as UsdLayer["nomosForeignRefs"]);
|
|
1220
|
+
}
|
|
1210
1221
|
}
|
|
1211
1222
|
}
|
|
1212
1223
|
|
|
@@ -1317,6 +1328,7 @@ export function parseUsdaDocument(text: string): UsdDocument {
|
|
|
1317
1328
|
const verifiedFirstEnroll = verifiedFirstEnrollByDomain.get(domain);
|
|
1318
1329
|
const eraCertGate = eraCertGateByDomain.get(domain);
|
|
1319
1330
|
const valueObjects = valueObjectsByDomain.get(domain);
|
|
1331
|
+
const foreignRefs2 = foreignRefsByDomain.get(domain);
|
|
1320
1332
|
return {
|
|
1321
1333
|
path: `/Nomos/${domain}`,
|
|
1322
1334
|
prims: [...b.prims].sort(byPath),
|
|
@@ -1342,6 +1354,7 @@ export function parseUsdaDocument(text: string): UsdDocument {
|
|
|
1342
1354
|
...(verifiedFirstEnroll !== undefined ? { nomosVerifiedFirstEnroll: verifiedFirstEnroll } : {}),
|
|
1343
1355
|
...(eraCertGate !== undefined ? { nomosEraCertGate: eraCertGate } : {}),
|
|
1344
1356
|
...(valueObjects !== undefined ? { nomosValueObjects: valueObjects } : {}),
|
|
1357
|
+
...(foreignRefs2 !== undefined ? { nomosForeignRefs: foreignRefs2 } : {}),
|
|
1345
1358
|
};
|
|
1346
1359
|
})
|
|
1347
1360
|
.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|