@githolon/dsl 0.1.0 → 0.1.2

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.
@@ -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
+ }