@githolon/dsl 0.1.0 → 0.1.1
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/.dart_tool/package_config.json +328 -0
- package/dart/.dart_tool/package_graph.json +485 -0
- package/dart/.dart_tool/pub/bin/test/test.dart-3.11.5.snapshot +0 -0
- package/dart/.dart_tool/test/incremental_kernel.Ly9AZGFydD0zLjU= +0 -0
- package/dart/.dart_tool/version +1 -0
- package/dart/FINDINGS.md +147 -0
- package/dart/build/native_assets/macos/native_assets.json +1 -0
- package/dart/build/test_cache/build/89a6598c8854ed031dfc25d83c80860e.cache.dill.track.dill +0 -0
- package/dart/build/unit_test_assets/AssetManifest.bin +0 -0
- package/dart/build/unit_test_assets/FontManifest.json +1 -0
- package/dart/build/unit_test_assets/NOTICES.Z +0 -0
- package/dart/build/unit_test_assets/NativeAssetsManifest.json +1 -0
- package/dart/build/unit_test_assets/shaders/ink_sparkle.frag +0 -0
- package/dart/build/unit_test_assets/shaders/stretch_effect.frag +0 -0
- package/dart/lib/nomos_dsl.dart +43 -0
- package/dart/lib/src/dispatch.dart +87 -0
- package/dart/lib/src/driver.dart +122 -0
- package/dart/lib/src/provider.dart +139 -0
- package/dart/lib/src/schema_validation.dart +44 -0
- package/dart/lib/src/subscriptions.dart +549 -0
- package/dart/lib/src/wire.dart +439 -0
- package/dart/pubspec.lock +421 -0
- package/dart/pubspec.yaml +23 -0
- package/dart/test/schema_validation_test.dart +66 -0
- package/dart/test/wire_test.dart +162 -0
- package/package.json +2 -1
|
@@ -0,0 +1,549 @@
|
|
|
1
|
+
// NOMOS — Nomos Sovereign: participants act · verify · remember LOCALLY; hosted
|
|
2
|
+
// remotes are replaceable custody/transport, not truth. ⇒ ONE Nomos GitHolon
|
|
3
|
+
// wasm32-wasip1 artifact {kernel · projection · embedded QuickJS engine},
|
|
4
|
+
// byte-identical everywhere. Host JS is only the bridge; domain JS runs inside
|
|
5
|
+
// GitHolon, never as a second execution path.
|
|
6
|
+
// If a file isn't this / hosting this / authoring for this / proving this — it's gone.
|
|
7
|
+
|
|
8
|
+
/// The READ half of the client boundary: typed, reactive subscriptions.
|
|
9
|
+
///
|
|
10
|
+
/// END-STATE (the "dispatch(intent) purge", Jack 2026-06-01): the app's ONLY
|
|
11
|
+
/// Nomos surface is `dispatch(intent)` (write — see `dispatch.dart`) + TYPED
|
|
12
|
+
/// PUB/SUB SUBSCRIPTIONS (read — here + the generated per-domain accessors). The
|
|
13
|
+
/// app reads ONLY through generated typed accessors (`watchSites()`,
|
|
14
|
+
/// `watchSite(id)`, indexed-field queries → typed results); it never hand-writes a
|
|
15
|
+
/// `query_id`, never decodes a `{type,id,data}` row, never folds a workspace.
|
|
16
|
+
///
|
|
17
|
+
/// This file is HAND-WRITTEN and domain-AGNOSTIC (declared ONCE, here): it maps
|
|
18
|
+
/// onto the GitHolon indexed read surface (`query(query_id, params_json)` +
|
|
19
|
+
/// `query_by_id(aggregate_id)`). The generated per-domain code supplies the typed
|
|
20
|
+
/// `fromJson` + the `query_id`; this file turns the synchronous read closure into
|
|
21
|
+
/// the reactive [Stream] the app subscribes to. It invents NO alternate read path.
|
|
22
|
+
///
|
|
23
|
+
/// READ-MODEL ROW SHAPE: both `query()` and `query_by_id()` return a JSON ARRAY of
|
|
24
|
+
/// `{type, id, data}` rows. `data` is the FLAT,
|
|
25
|
+
/// already-decoded field map the projection projects (e.g.
|
|
26
|
+
/// `{"name":"HQ","createdAt":1000,"location":{...}}`) — NOT the `{field:{value,
|
|
27
|
+
/// stamp}}` projection-internal shape. The generated `fromJson` parses THIS flat
|
|
28
|
+
/// map (see `codegen_dart.ts`'s flat-projection realignment).
|
|
29
|
+
library;
|
|
30
|
+
|
|
31
|
+
import 'dart:async';
|
|
32
|
+
import 'dart:convert';
|
|
33
|
+
|
|
34
|
+
// ───────────────────────────── the kernel read seams ─────────────────────────────
|
|
35
|
+
|
|
36
|
+
/// The kernel INDEXED-QUERY read seam: the GitHolon `query` signature, as a
|
|
37
|
+
/// function type. Returns the matching aggregates as a JSON array of `{type, id,
|
|
38
|
+
/// data}` rows (O(matches), never a workspace scan — #140 READ-CLOSURE).
|
|
39
|
+
///
|
|
40
|
+
/// `subscriptions.dart` takes this (rather than importing a host client) so the
|
|
41
|
+
/// generated `nomos_dsl` package stays host-agnostic. The app threads `client.query`
|
|
42
|
+
/// in. `paramsJson` is a JSON object carrying the
|
|
43
|
+
/// key field + value, e.g. `{"estateId":"e-1"}`.
|
|
44
|
+
typedef Queryer = String Function(String queryId, String paramsJson);
|
|
45
|
+
|
|
46
|
+
/// The kernel PRIMARY-KEY read seam: the GitHolon `queryById` signature, as
|
|
47
|
+
/// a function type. Reads ONE aggregate by its kernel aggregate id and returns the
|
|
48
|
+
/// SAME `[{type,id,data}, …]` array shape (carrying the single matched aggregate,
|
|
49
|
+
/// or an EMPTY array when no aggregate with that id has been folded). Resolves both
|
|
50
|
+
/// an unbound singleton (keyed by its type, e.g. `EstateAggregate`) and an
|
|
51
|
+
/// instance-bound aggregate (by its id).
|
|
52
|
+
typedef QueryByIder = String Function(String aggregateId);
|
|
53
|
+
|
|
54
|
+
/// The kernel MAINTAINED-COUNT read seam: the GitHolon `count` signature,
|
|
55
|
+
/// as a function type. Reads ONE maintained counter (O(1) PK lookup in the `counts`
|
|
56
|
+
/// table — never a scan). `countId` is the declared count's canonical id; `groupKey`
|
|
57
|
+
/// is the value of the group-by field for this partition, or `''` for a grand total.
|
|
58
|
+
/// Returns the current count (0 when no entry exists for this group).
|
|
59
|
+
///
|
|
60
|
+
/// Slice 1 — this seam is NEW: the generated per-domain accessors
|
|
61
|
+
/// (`watchPublishedCount`, `readPublishedCount`, …) forward here. The app never
|
|
62
|
+
/// hand-types a `countId` or a `groupKey` — that is the generated accessor's job.
|
|
63
|
+
typedef Counter = int Function(String countId, String groupKey);
|
|
64
|
+
|
|
65
|
+
/// The kernel MAINTAINED-SUM read seam: the GitHolon `sum` signature,
|
|
66
|
+
/// as a function type. Reads ONE maintained sum (O(1) PK lookup in the `sums`
|
|
67
|
+
/// table — never a scan). `sumId` is the declared sum's canonical id; `groupKey`
|
|
68
|
+
/// is the value of the group-by field for this partition, or `''` for a grand total.
|
|
69
|
+
/// Returns the current total (0 when no entry exists for this group).
|
|
70
|
+
///
|
|
71
|
+
/// SUM-OF-0 CAVEAT: a group whose total reaches 0 is pruned; the return value 0
|
|
72
|
+
/// is indistinguishable from absent (acceptable for Slice 1 — Slice 2 adds `exists`).
|
|
73
|
+
typedef Summer = int Function(String sumId, String groupKey);
|
|
74
|
+
|
|
75
|
+
/// A "projection changed" tick: fires (any value) whenever a write may have
|
|
76
|
+
/// advanced the projection, so the subscription re-runs its indexed read. The
|
|
77
|
+
/// `query()`/`query_by_id()` closures are SYNCHRONOUS point-in-time reads; this tick is what
|
|
78
|
+
/// makes a typed accessor REACTIVE. The app threads its existing post-`dispatch`
|
|
79
|
+
/// invalidation signal in here (today the runtime's ~750ms projection poll / the
|
|
80
|
+
/// write-completion broadcast). A read with no live tick is a one-shot current read.
|
|
81
|
+
typedef ReadTick = Stream<void>;
|
|
82
|
+
|
|
83
|
+
// ───────────────────────────── the typed-read engine ─────────────────────────────
|
|
84
|
+
|
|
85
|
+
/// The bundle of kernel read seams + the reactivity tick the app threads in ONCE.
|
|
86
|
+
/// Every generated per-domain accessor takes a [NomosReads] and is a thin typed
|
|
87
|
+
/// wrapper over it — so the app constructs this one object (from its GitHolon port)
|
|
88
|
+
/// and the entire generated read API hangs off it.
|
|
89
|
+
class NomosReads {
|
|
90
|
+
/// The indexed-query seam (`client.query`).
|
|
91
|
+
final Queryer query;
|
|
92
|
+
|
|
93
|
+
/// The primary-key seam (`client.queryById`).
|
|
94
|
+
final QueryByIder queryById;
|
|
95
|
+
|
|
96
|
+
/// The maintained-count seam (`client.count`). Reads ONE maintained counter
|
|
97
|
+
/// by `(countId, groupKey)` — O(1) PK lookup, never a scan. Threads in from the
|
|
98
|
+
/// app's host client (Slice 1). When null, `readCount`/`watchCount` return 0
|
|
99
|
+
/// (no count seam provided — useful in tests that don't exercise counts).
|
|
100
|
+
final Counter? counter;
|
|
101
|
+
|
|
102
|
+
/// The maintained-sum seam (`client.sum`). Reads ONE maintained sum by
|
|
103
|
+
/// `(sumId, groupKey)` — O(1) PK lookup, never a scan. Threads in from the
|
|
104
|
+
/// app's host client (Slice 1). When null, `readSum`/`watchSum` return 0.
|
|
105
|
+
final Summer? summer;
|
|
106
|
+
|
|
107
|
+
/// The "projection changed" tick. When null, every accessor is a ONE-SHOT read
|
|
108
|
+
/// (a single current value, no further updates) — used in tests / pure snapshots.
|
|
109
|
+
final ReadTick? tick;
|
|
110
|
+
|
|
111
|
+
const NomosReads({
|
|
112
|
+
required this.query,
|
|
113
|
+
required this.queryById,
|
|
114
|
+
this.counter,
|
|
115
|
+
this.summer,
|
|
116
|
+
this.tick,
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
/// Decode a `[{type,id,data}, …]` rows JSON array into the typed aggregate list,
|
|
120
|
+
/// applying [fromJson] to each row's FLAT `data` map AND its row `id`. Skips rows
|
|
121
|
+
/// whose `data` is not an object (defensive; the projection always emits an object).
|
|
122
|
+
///
|
|
123
|
+
/// GAP 2: the row `id` (the kernel aggregate id — e.g. the siteId) is threaded as
|
|
124
|
+
/// [fromJson]'s second arg. The projection STRIPS the reserved `__id`/`__type` from
|
|
125
|
+
/// each row's `data`, so the row-level `id` is the SOLE carrier of the aggregate id;
|
|
126
|
+
/// passing it here surfaces it on the generated aggregate from BOTH the indexed
|
|
127
|
+
/// accessors and the by-id accessors (both funnel through this one decoder).
|
|
128
|
+
static List<T> decodeRows<T>(
|
|
129
|
+
String rowsJson,
|
|
130
|
+
T Function(Map<String, dynamic> data, String id) fromJson,
|
|
131
|
+
) {
|
|
132
|
+
final decoded = jsonDecode(rowsJson);
|
|
133
|
+
if (decoded is! List) {
|
|
134
|
+
throw FormatException('read rows must be a JSON array, got ${decoded.runtimeType}');
|
|
135
|
+
}
|
|
136
|
+
final out = <T>[];
|
|
137
|
+
for (final row in decoded) {
|
|
138
|
+
if (row is! Map<String, dynamic>) continue;
|
|
139
|
+
final data = row['data'];
|
|
140
|
+
if (data is! Map<String, dynamic>) continue;
|
|
141
|
+
final rawId = row['id'];
|
|
142
|
+
final id = rawId is String ? rawId : (rawId?.toString() ?? '');
|
|
143
|
+
out.add(fromJson(data, id));
|
|
144
|
+
}
|
|
145
|
+
return out;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ---- one-shot (current-value) reads ----
|
|
149
|
+
|
|
150
|
+
/// Run a registered indexed [queryId] with [params] NOW and return the typed
|
|
151
|
+
/// matches (O(matches)). The generated accessor supplies [queryId]/[params]/
|
|
152
|
+
/// [fromJson]; the app never hand-types them.
|
|
153
|
+
List<T> readQuery<T>(
|
|
154
|
+
String queryId,
|
|
155
|
+
Map<String, Object?> params,
|
|
156
|
+
T Function(Map<String, dynamic> data, String id) fromJson,
|
|
157
|
+
) =>
|
|
158
|
+
decodeRows(query(queryId, jsonEncode(params)), fromJson);
|
|
159
|
+
|
|
160
|
+
/// Read ONE aggregate by its kernel [aggregateId] NOW (primary-key lookup),
|
|
161
|
+
/// returning the typed value or `null` when no such aggregate has been folded.
|
|
162
|
+
T? readById<T>(
|
|
163
|
+
String aggregateId,
|
|
164
|
+
T Function(Map<String, dynamic> data, String id) fromJson,
|
|
165
|
+
) {
|
|
166
|
+
final rows = decodeRows(queryById(aggregateId), fromJson);
|
|
167
|
+
return rows.isEmpty ? null : rows.first;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ---- reactive subscriptions ----
|
|
171
|
+
|
|
172
|
+
/// Subscribe to a registered indexed [queryId]/[params] as a [Stream] of typed
|
|
173
|
+
/// match lists. Emits the current matches immediately, then re-emits on every
|
|
174
|
+
/// [tick] (a write may have changed the matches). When [tick] is null this is a
|
|
175
|
+
/// single-element stream (the current value). Consecutive identical results are
|
|
176
|
+
/// NOT deduped here — the app's selector layer (Riverpod) handles distinctness.
|
|
177
|
+
Stream<List<T>> watchQuery<T>(
|
|
178
|
+
String queryId,
|
|
179
|
+
Map<String, Object?> params,
|
|
180
|
+
T Function(Map<String, dynamic> data, String id) fromJson,
|
|
181
|
+
) =>
|
|
182
|
+
_reactive(() => readQuery(queryId, params, fromJson));
|
|
183
|
+
|
|
184
|
+
/// Subscribe to ONE aggregate by [aggregateId] as a `Stream<T?>` (null until/
|
|
185
|
+
/// unless it has been folded). Emits the current value immediately, then re-emits
|
|
186
|
+
/// on every [tick].
|
|
187
|
+
Stream<T?> watchById<T>(
|
|
188
|
+
String aggregateId,
|
|
189
|
+
T Function(Map<String, dynamic> data, String id) fromJson,
|
|
190
|
+
) =>
|
|
191
|
+
_reactive(() => readById(aggregateId, fromJson));
|
|
192
|
+
|
|
193
|
+
// ---- maintained count/sum seams (Slice 1) ----
|
|
194
|
+
|
|
195
|
+
/// Read the current maintained counter for [countId] + [groupKey] NOW. Returns 0
|
|
196
|
+
/// when no entry exists (either truly zero, or no group for this key). O(1) PK
|
|
197
|
+
/// lookup in the `counts` table — never a scan. Returns 0 when [counter] is null
|
|
198
|
+
/// (no count seam provided — useful in tests).
|
|
199
|
+
int readCount(String countId, String groupKey) => counter?.call(countId, groupKey) ?? 0;
|
|
200
|
+
|
|
201
|
+
/// Subscribe to the maintained counter for [countId] + [groupKey] as a
|
|
202
|
+
/// `Stream<int>`. Emits the current count immediately, then re-emits on every
|
|
203
|
+
/// [tick]. Returns 0 when [counter] is null.
|
|
204
|
+
Stream<int> watchCount(String countId, String groupKey) =>
|
|
205
|
+
_reactive(() => readCount(countId, groupKey));
|
|
206
|
+
|
|
207
|
+
/// Read the current maintained sum for [sumId] + [groupKey] NOW. Returns 0 when
|
|
208
|
+
/// no entry exists (a group pruned at 0 is indistinguishable from absent —
|
|
209
|
+
/// documented Slice-1 caveat). O(1) PK lookup in the `sums` table — never a scan.
|
|
210
|
+
/// Returns 0 when [summer] is null.
|
|
211
|
+
int readSum(String sumId, String groupKey) => summer?.call(sumId, groupKey) ?? 0;
|
|
212
|
+
|
|
213
|
+
/// Subscribe to the maintained sum for [sumId] + [groupKey] as a `Stream<int>`.
|
|
214
|
+
/// Emits the current total immediately, then re-emits on every [tick]. Returns 0
|
|
215
|
+
/// when [summer] is null.
|
|
216
|
+
Stream<int> watchSum(String sumId, String groupKey) =>
|
|
217
|
+
_reactive(() => readSum(sumId, groupKey));
|
|
218
|
+
|
|
219
|
+
/// Turn a synchronous point-in-time read [read] into a reactive [Stream]: emit
|
|
220
|
+
/// once now, then once per [tick]. With no tick, a one-shot single-element stream.
|
|
221
|
+
Stream<R> _reactive<R>(R Function() read) {
|
|
222
|
+
final tick = this.tick;
|
|
223
|
+
if (tick == null) {
|
|
224
|
+
return Stream<R>.value(read());
|
|
225
|
+
}
|
|
226
|
+
late StreamController<R> controller;
|
|
227
|
+
StreamSubscription<void>? sub;
|
|
228
|
+
void emit() {
|
|
229
|
+
try {
|
|
230
|
+
controller.add(read());
|
|
231
|
+
} catch (e, st) {
|
|
232
|
+
controller.addError(e, st);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
controller = StreamController<R>(
|
|
237
|
+
onListen: () {
|
|
238
|
+
emit(); // current value first
|
|
239
|
+
sub = tick.listen((_) => emit());
|
|
240
|
+
},
|
|
241
|
+
onCancel: () async {
|
|
242
|
+
await sub?.cancel();
|
|
243
|
+
sub = null;
|
|
244
|
+
},
|
|
245
|
+
);
|
|
246
|
+
return controller.stream;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ───────────────────────────── flat-projection field readers ─────────────────────────────
|
|
251
|
+
//
|
|
252
|
+
// The projection's `data` map is FLAT and already-decoded (the Rust `decode_field`
|
|
253
|
+
// has parsed each kernel `Value` back to JSON). These tolerant readers are what the
|
|
254
|
+
// GENERATED `fromJson` calls per field — they realign the typed aggregate onto that
|
|
255
|
+
// flat shape and coerce defensively (the same field can arrive as a bare scalar or,
|
|
256
|
+
// for a `json`-kind field, as a decoded container that must be re-stringified to the
|
|
257
|
+
// aggregate's `String` type).
|
|
258
|
+
|
|
259
|
+
/// A field that must be present in the flat `data` map. Throws a [FormatException]
|
|
260
|
+
/// naming the aggregate + field when absent (parity with the old `{field:{value}}`
|
|
261
|
+
/// reader's missing-field throw).
|
|
262
|
+
Object _required(Map<String, dynamic> data, String aggregate, String field) {
|
|
263
|
+
if (!data.containsKey(field) || data[field] == null) {
|
|
264
|
+
throw FormatException('$aggregate: missing field $field');
|
|
265
|
+
}
|
|
266
|
+
return data[field] as Object;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/// Read a `string`/`ref`-kind field as a Dart [String]. The projection emits it as
|
|
270
|
+
/// a bare JSON string; a non-string scalar is `toString`'d defensively.
|
|
271
|
+
String readStr(Map<String, dynamic> data, String aggregate, String field) {
|
|
272
|
+
final v = _required(data, aggregate, field);
|
|
273
|
+
return v is String ? v : v.toString();
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/// Read an `int`-kind field as a Dart [int] (the projection emits a JSON number).
|
|
277
|
+
int readInt(Map<String, dynamic> data, String aggregate, String field) {
|
|
278
|
+
final v = _required(data, aggregate, field);
|
|
279
|
+
if (v is num) return v.toInt();
|
|
280
|
+
if (v is String) {
|
|
281
|
+
final n = int.tryParse(v);
|
|
282
|
+
if (n != null) return n;
|
|
283
|
+
}
|
|
284
|
+
throw FormatException('$aggregate.$field: expected int, got ${v.runtimeType}');
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/// Read a `set`-kind field as a `Set<String>` (the projection emits a JSON array).
|
|
288
|
+
Set<String> readSet(Map<String, dynamic> data, String aggregate, String field) {
|
|
289
|
+
final v = _required(data, aggregate, field);
|
|
290
|
+
if (v is List) return v.map((e) => e.toString()).toSet();
|
|
291
|
+
throw FormatException('$aggregate.$field: expected a set/array, got ${v.runtimeType}');
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/// Read a `map`-kind field as a `Map<String, String>`. The projection may emit it
|
|
295
|
+
/// as a flat `{k:v}` object (the loss-less author path) OR — if it folded through a
|
|
296
|
+
/// kernel `Value::Map` — as the structural `{"Map":{k:{value:{Str:v},…}}}` serde
|
|
297
|
+
/// form; both are reduced to `{k:v}` so the aggregate's typed map is stable.
|
|
298
|
+
Map<String, String> readStrMap(Map<String, dynamic> data, String aggregate, String field) {
|
|
299
|
+
final v = _required(data, aggregate, field);
|
|
300
|
+
if (v is Map) {
|
|
301
|
+
// Structural kernel `Value::Map` form: `{"Map": {k: {value:{Str:..},stamp:..}}}`.
|
|
302
|
+
final inner = v.length == 1 && v.containsKey('Map') ? v['Map'] : v;
|
|
303
|
+
if (inner is Map) {
|
|
304
|
+
final out = <String, String>{};
|
|
305
|
+
inner.forEach((k, val) {
|
|
306
|
+
out[k.toString()] = _flattenMapValue(val);
|
|
307
|
+
});
|
|
308
|
+
return out;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
throw FormatException('$aggregate.$field: expected a map/object, got ${v.runtimeType}');
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/// Reduce one map entry value to a String, peeling the kernel `{value:{Str:..}}`
|
|
315
|
+
/// wrapper when present (structural form) or `toString`-ing a bare scalar.
|
|
316
|
+
String _flattenMapValue(Object? val) {
|
|
317
|
+
if (val is String) return val;
|
|
318
|
+
if (val is Map) {
|
|
319
|
+
final value = val['value'];
|
|
320
|
+
if (value is Map && value.containsKey('Str')) return value['Str'].toString();
|
|
321
|
+
if (value is Map && value.containsKey('Int')) return value['Int'].toString();
|
|
322
|
+
}
|
|
323
|
+
return val.toString();
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/// Read an enum-kind field's wire string from the flat map, applying the generated
|
|
327
|
+
/// enum's [fromWire]. The projection emits the bare wire string.
|
|
328
|
+
E readEnum<E>(
|
|
329
|
+
Map<String, dynamic> data,
|
|
330
|
+
String aggregate,
|
|
331
|
+
String field,
|
|
332
|
+
E Function(String wire) fromWire,
|
|
333
|
+
) =>
|
|
334
|
+
fromWire(readStr(data, aggregate, field));
|
|
335
|
+
|
|
336
|
+
/// Read a `json`-kind field as the Dart [String] the aggregate types it as. The
|
|
337
|
+
/// projection's `decode_field` may have PARSED the stored JSON string back into a
|
|
338
|
+
/// container (object/array); re-stringify so the aggregate keeps a stable `String`
|
|
339
|
+
/// (parity with the kernel-stored `Value::Str(<json>)`). A bare string is returned
|
|
340
|
+
/// as-is.
|
|
341
|
+
String readJsonStr(Map<String, dynamic> data, String aggregate, String field) {
|
|
342
|
+
final v = _required(data, aggregate, field);
|
|
343
|
+
if (v is String) return v;
|
|
344
|
+
return jsonEncode(v);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/// Read a `json`-kind value-object leaf as a structured `Map<String, Object?>` for
|
|
348
|
+
/// generated read models, as opposed to [readJsonStr] which preserves the encoded
|
|
349
|
+
/// string form. The Rust read engine has already decoded the stored
|
|
350
|
+
/// `Value::Str(<json>)` back into structured JSON (`manifest.rs` `decode_json_leaf`), so a
|
|
351
|
+
/// `location` arrives as `{countryCode:…, geoPoint:{…}}` — NOT a JSON string. This reader
|
|
352
|
+
/// surfaces that decoded object directly:
|
|
353
|
+
/// * an already-decoded [Map] (the flat projection) → its `Map<String, Object?>` view;
|
|
354
|
+
/// * a still-encoded JSON string (a path that did not pre-parse) → `jsonDecode`d, and
|
|
355
|
+
/// KEPT only when it decodes to an OBJECT.
|
|
356
|
+
/// A value-object leaf that is NOT an object (a bare scalar / array) is a malformed
|
|
357
|
+
/// value-object for a typed projection field, so it throws a [FormatException] (parity
|
|
358
|
+
/// with the strict-decode contract for a must-be-folded field — never silently coerced).
|
|
359
|
+
Map<String, Object?> readJsonObject(
|
|
360
|
+
Map<String, dynamic> data, String aggregate, String field) {
|
|
361
|
+
final v = _required(data, aggregate, field);
|
|
362
|
+
final obj = _asJsonObject(v);
|
|
363
|
+
if (obj == null) {
|
|
364
|
+
throw FormatException(
|
|
365
|
+
'$aggregate.$field: expected a json object value-object, got ${v.runtimeType}');
|
|
366
|
+
}
|
|
367
|
+
return obj;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/// Read an OPTIONAL `json`-kind value-object leaf as a structured `Map<String, Object?>`,
|
|
371
|
+
/// or `null` when absent (a freshly-folded aggregate need not have folded it). A PRESENT
|
|
372
|
+
/// value is decoded exactly as [readJsonObject] (a present-but-non-object value still
|
|
373
|
+
/// throws — only ABSENCE is tolerated).
|
|
374
|
+
Map<String, Object?>? readJsonObjectOrNull(
|
|
375
|
+
Map<String, dynamic> data, String aggregate, String field) {
|
|
376
|
+
final v = data[field];
|
|
377
|
+
if (v == null) return null;
|
|
378
|
+
final obj = _asJsonObject(v);
|
|
379
|
+
if (obj == null) {
|
|
380
|
+
throw FormatException(
|
|
381
|
+
'$aggregate.$field: expected a json object value-object, got ${v.runtimeType}');
|
|
382
|
+
}
|
|
383
|
+
return obj;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/// Coerce an already-decoded projection value to a `Map<String, Object?>` JSON object,
|
|
387
|
+
/// or `null` when it is not an object. Handles BOTH the decoded-[Map] projection form and
|
|
388
|
+
/// a still-encoded JSON string (decoded, kept only if it is itself an object).
|
|
389
|
+
Map<String, Object?>? _asJsonObject(Object? v) {
|
|
390
|
+
if (v is Map) {
|
|
391
|
+
return v.map((k, val) => MapEntry(k.toString(), val));
|
|
392
|
+
}
|
|
393
|
+
if (v is String) {
|
|
394
|
+
final decoded = _maybeJsonDecode(v);
|
|
395
|
+
if (decoded is Map) {
|
|
396
|
+
return decoded.map((k, val) => MapEntry(k.toString(), val));
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
return null;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/// Read a PERMISSIVE `json`-kind leaf (a plain `t.json()` whose decoded shape is
|
|
403
|
+
/// unknown/non-object — e.g. a double/bool/list authored as a JSON leaf, since the
|
|
404
|
+
/// kernel `Value` is Str|Int only) as the read engine's decoded value AS-IS, or `null`
|
|
405
|
+
/// when absent. The generated projection surface for a plain `t.json()` field
|
|
406
|
+
/// (typed `Object?`), as opposed to [readJsonObject] (the strict `t.jsonObject()` surface,
|
|
407
|
+
/// which THROWS on a non-object). NEVER throws on shape: it surfaces whatever the engine
|
|
408
|
+
/// decoded —
|
|
409
|
+
/// * an already-decoded container/scalar (the flat projection) → returned verbatim;
|
|
410
|
+
/// * a still-encoded JSON string → `jsonDecode`d when it parses, else the string as-is.
|
|
411
|
+
/// This is the non-object/ambiguous counterpart to [readJsonObject]: a `scaleX` double
|
|
412
|
+
/// or an `estimatedCost` value-object surfaces as its real decoded value, never coerced
|
|
413
|
+
/// to a map and never thrown.
|
|
414
|
+
Object? readJsonValueOrNull(Map<String, dynamic> data, String field) {
|
|
415
|
+
final v = data[field];
|
|
416
|
+
if (v == null) return null;
|
|
417
|
+
return _maybeJsonDecode(v);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// ───────────────────────────── optional / empty-defaulting readers ──────────────
|
|
421
|
+
//
|
|
422
|
+
// GAP 1: a freshly-created aggregate folds only the fields its `.creates` directive
|
|
423
|
+
// `.plan()` emits — a `.optional()` SCALAR (e.g. `description`/`siteType`) or a
|
|
424
|
+
// COLLECTION (`set`/`map`) it never folded is simply ABSENT from the flat `data`.
|
|
425
|
+
// These readers decode that minimal shape without throwing: an optional scalar reads
|
|
426
|
+
// `null` when absent; a collection reads EMPTY. The generated `fromJson` picks the
|
|
427
|
+
// strict reader for a must-be-folded scalar and one of these for an optional/collection
|
|
428
|
+
// field, deriving the choice from the DSL field schema.
|
|
429
|
+
|
|
430
|
+
/// Read an OPTIONAL `string`/`ref`-kind field, or `null` when absent. A present
|
|
431
|
+
/// non-string scalar is `toString`'d (parity with [readStr]).
|
|
432
|
+
String? readStrOrNull(Map<String, dynamic> data, String field) {
|
|
433
|
+
final v = data[field];
|
|
434
|
+
if (v == null) return null;
|
|
435
|
+
return v is String ? v : v.toString();
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/// Read an OPTIONAL `int`-kind field, or `null` when absent. A present value is
|
|
439
|
+
/// coerced like [readInt]; a present-but-uncoercible value throws (a malformed
|
|
440
|
+
/// fold is still an error — only ABSENCE is tolerated).
|
|
441
|
+
int? readIntOrNull(Map<String, dynamic> data, String aggregate, String field) {
|
|
442
|
+
final v = data[field];
|
|
443
|
+
if (v == null) return null;
|
|
444
|
+
if (v is num) return v.toInt();
|
|
445
|
+
if (v is String) {
|
|
446
|
+
final n = int.tryParse(v);
|
|
447
|
+
if (n != null) return n;
|
|
448
|
+
}
|
|
449
|
+
throw FormatException('$aggregate.$field: expected int, got ${v.runtimeType}');
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/// Read an OPTIONAL enum-kind field's wire string, or `null` when absent (a present
|
|
453
|
+
/// value still validates against the generated enum via [fromWire]).
|
|
454
|
+
E? readEnumOrNull<E>(
|
|
455
|
+
Map<String, dynamic> data,
|
|
456
|
+
String aggregate,
|
|
457
|
+
String field,
|
|
458
|
+
E Function(String wire) fromWire,
|
|
459
|
+
) {
|
|
460
|
+
final wire = readStrOrNull(data, field);
|
|
461
|
+
return wire == null ? null : fromWire(wire);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/// Read an OPTIONAL `json`-kind field as a [String], or `null` when absent (a present
|
|
465
|
+
/// container is re-stringified like [readJsonStr]).
|
|
466
|
+
String? readJsonStrOrNull(Map<String, dynamic> data, String field) {
|
|
467
|
+
final v = data[field];
|
|
468
|
+
if (v == null) return null;
|
|
469
|
+
if (v is String) return v;
|
|
470
|
+
return jsonEncode(v);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/// Read a `set`-kind field as a `Set<String>`, defaulting to EMPTY when the flat
|
|
474
|
+
/// `data` lacks it (a freshly-created aggregate need not have folded the set). A
|
|
475
|
+
/// PRESENT-but-non-array value still throws (parity with [readSet]).
|
|
476
|
+
Set<String> readSetOrEmpty(Map<String, dynamic> data, String aggregate, String field) {
|
|
477
|
+
final v = data[field];
|
|
478
|
+
if (v == null) return <String>{};
|
|
479
|
+
if (v is List) return v.map((e) => e.toString()).toSet();
|
|
480
|
+
throw FormatException('$aggregate.$field: expected a set/array, got ${v.runtimeType}');
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/// Read a `map`-kind field as a `Map<String, String>`, defaulting to EMPTY when
|
|
484
|
+
/// absent. A PRESENT value is reduced exactly as [readStrMap] (handling both the flat
|
|
485
|
+
/// `{k:v}` form and the structural kernel `Value::Map` form).
|
|
486
|
+
Map<String, String> readStrMapOrEmpty(Map<String, dynamic> data, String aggregate, String field) {
|
|
487
|
+
final v = data[field];
|
|
488
|
+
if (v == null) return <String, String>{};
|
|
489
|
+
return readStrMap(data, aggregate, field);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/// Read a JSON-VALUED `map`-kind field (`t.map(t.json())`) as a `Map<String, Object?>`,
|
|
493
|
+
/// with each entry value DECODED back to its object. Defaults to EMPTY when absent.
|
|
494
|
+
///
|
|
495
|
+
/// A value-object map (e.g. a site's `customFields`, each entry the canonical
|
|
496
|
+
/// `CustomField.toJson()` written as a JSON string) lowers to a kernel `Value::Map`
|
|
497
|
+
/// whose leaves are `Value::Str(<json>)`. This reader recovers each entry's ORIGINAL
|
|
498
|
+
/// object so a VO's own `fromJson` (e.g. `CustomFieldMap.fromJson`) sees its canonical
|
|
499
|
+
/// nested-map form and round-trips losslessly — unlike [readStrMap], which would
|
|
500
|
+
/// flatten each entry to the raw JSON STRING (forcing the VO into a lossy shorthand
|
|
501
|
+
/// branch). Handles BOTH wire forms: the flat `{k:<object|jsonString>}` projection AND
|
|
502
|
+
/// the structural `{"Map":{k:{value:{Str:<json>},stamp}}}` serde form.
|
|
503
|
+
Map<String, Object?> readJsonMapOrEmpty(
|
|
504
|
+
Map<String, dynamic> data, String aggregate, String field) {
|
|
505
|
+
final v = data[field];
|
|
506
|
+
if (v == null) return <String, Object?>{};
|
|
507
|
+
if (v is Map) {
|
|
508
|
+
// Structural kernel `Value::Map` form: `{"Map": {k: {value:{Str:..},stamp:..}}}`.
|
|
509
|
+
final inner = v.length == 1 && v.containsKey('Map') ? v['Map'] : v;
|
|
510
|
+
if (inner is Map) {
|
|
511
|
+
final out = <String, Object?>{};
|
|
512
|
+
inner.forEach((k, val) {
|
|
513
|
+
out[k.toString()] = _decodeJsonMapValue(val);
|
|
514
|
+
});
|
|
515
|
+
return out;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
throw FormatException('$aggregate.$field: expected a map/object, got ${v.runtimeType}');
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/// Decode one JSON-valued map entry to its object: peel the kernel
|
|
522
|
+
/// `{value:{Str:<json>}}` wrapper when present (structural form), then JSON-decode the
|
|
523
|
+
/// resulting string back to its object/list/scalar. A value that is ALREADY a decoded
|
|
524
|
+
/// container (the flat projection re-parsed it) is returned as-is. A bare scalar (int
|
|
525
|
+
/// from a spike path) is returned unchanged.
|
|
526
|
+
Object? _decodeJsonMapValue(Object? val) {
|
|
527
|
+
if (val is Map && val.containsKey('value')) {
|
|
528
|
+
final value = val['value'];
|
|
529
|
+
if (value is Map && value.containsKey('Str')) {
|
|
530
|
+
return _maybeJsonDecode(value['Str']);
|
|
531
|
+
}
|
|
532
|
+
if (value is Map && value.containsKey('Int')) return value['Int'];
|
|
533
|
+
return value;
|
|
534
|
+
}
|
|
535
|
+
return _maybeJsonDecode(val);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/// JSON-decode a String to its object/list; non-strings (already-decoded containers,
|
|
539
|
+
/// numbers) pass through. A String that is not valid JSON is returned verbatim.
|
|
540
|
+
Object? _maybeJsonDecode(Object? v) {
|
|
541
|
+
if (v is String) {
|
|
542
|
+
try {
|
|
543
|
+
return jsonDecode(v);
|
|
544
|
+
} catch (_) {
|
|
545
|
+
return v;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
return v;
|
|
549
|
+
}
|