@githolon/dsl 0.54.1 → 0.55.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.
@@ -31,7 +31,7 @@ import {
31
31
  planBirthRecordOps,
32
32
  } from "./birth_meter.js";
33
33
  import type { AggregateHandle } from "./aggregate.js";
34
- import type { Field, EvidenceRefValue } from "./fields.js";
34
+ import { admitsNull, type Field, type EvidenceRefValue } from "./fields.js";
35
35
  import type { Directive, ReferentialMarker } from "./directive.js";
36
36
  import type { PlannedOp, FieldOp } from "./ops.js";
37
37
  import { deterministicPorts, type Ports } from "./ctx.js";
@@ -77,6 +77,15 @@ export function encodeKernelSchema(agg: AggregateHandle): WireSchema {
77
77
  return out;
78
78
  }
79
79
 
80
+ /** Recursively true iff `value` is (or contains, at any depth) a json `null` — the no-null check the
81
+ * encode-path null gate runs on a forbid `t.json()` field. Bounded by the value's own structure. */
82
+ function jsonHasNull(value: unknown): boolean {
83
+ if (value === null) return true;
84
+ if (Array.isArray(value)) return value.some(jsonHasNull);
85
+ if (typeof value === "object") return Object.values(value as Record<string, unknown>).some(jsonHasNull);
86
+ return false;
87
+ }
88
+
80
89
  /** A shape description for a value that failed to encode (diagnostics only). */
81
90
  function describeShape(value: unknown): string {
82
91
  if (value === null) return "null";
@@ -93,6 +102,21 @@ function encodeKernelValue(field: Field | undefined, value: unknown, op?: FieldO
93
102
  // string): the authored {hash, mediaType, byteLength, storageClass} maps to the
94
103
  // kernel's snake_case serde shape + the `Git`/`External` unit-variant. Checked BEFORE
95
104
  // the scalar fallthroughs (an evidence value is an object, never a Str/Int).
105
+ // NULL SEMANTICS (`architecture/null_semantics.md`) — the LUMP null gate, top-level null. A null
106
+ // value is lawful ONLY on a field that declared a null-admitting semantic (nullable / explicit-empty
107
+ // / clearable via `clear(...)`); it lowers to `Value::Json(null)` regardless of the field's kind (a
108
+ // cleared string clears to json null). A null on a forbid field is a typed refusal HERE (the encode
109
+ // runs in the sealed engine on every peer) — the runtime twin of the check-time refusal. Era-safe: a
110
+ // pre-null-semantics package froze its old lump (no `nullSemantic`), so this only tightens new law.
111
+ if (value === null && field !== undefined) {
112
+ if (admitsNull(field)) return { Json: null };
113
+ const aggName = op?.aggregateType ?? op?.aggregateId ?? "?";
114
+ throw new Error(
115
+ `${aggName}.${op?.field ?? field.kind}: writes null, but null has no law-level meaning here. ` +
116
+ `Use t.clearable(...) + clear(...) for explicit clearing, or t.nullable(..., { reason }) / ` +
117
+ `t.json({ null: "explicit-empty", reason }) for a real empty value.`,
118
+ );
119
+ }
96
120
  if (field?.kind === "evidence") {
97
121
  const v = value as EvidenceRefValue;
98
122
  return {
@@ -109,7 +133,23 @@ function encodeKernelValue(field: Field | undefined, value: unknown, op?: FieldO
109
133
  // value — object, array, string, number, boolean, null — commits as one consistent value kind
110
134
  // (the kernel canonicalises it at the boundary). This is the new-era encoding; a package compiled
111
135
  // before this change froze the old `{ Str: JSON.stringify(value) }` lump and replays unchanged.
112
- if (field?.kind === "json") return { Json: value };
136
+ if (field?.kind === "json") {
137
+ // NULL SEMANTICS (`architecture/null_semantics.md`) — the LUMP null gate. A `t.json()` field forbids
138
+ // null at every depth unless it declared a null-admitting semantic (nullable / explicit-empty /
139
+ // clearable via `clear(...)`). A plan-injected null on a forbid field is a typed refusal HERE (the
140
+ // encode runs inside the sealed engine on every peer), the runtime twin of the check-time refusal.
141
+ // Era-safe: a pre-null-semantics package froze its old lump and never carries a `nullSemantic`, so
142
+ // this branch only tightens NEWLY compiled law (the field defaults to "forbid" at construction).
143
+ if (!admitsNull(field) && jsonHasNull(value)) {
144
+ const aggName = op?.aggregateType ?? op?.aggregateId ?? "?";
145
+ throw new Error(
146
+ `${aggName}.${op?.field ?? field.kind}: json field carries a null, but null has no law-level ` +
147
+ `meaning here. Use t.clearable(...) + clear(...) for explicit clearing, or ` +
148
+ `t.nullable(..., { reason }) / t.json({ null: "explicit-empty", reason }) for a real empty value.`,
149
+ );
150
+ }
151
+ return { Json: value };
152
+ }
113
153
  // A `t.map(t.json())` / `t.map(VO.field())` ENTRY value is a json leaf too. Its wire DRIVER is
114
154
  // `MapOf(Lww)` (whose leaves are ordinary kernel `Value`s), so a json-valued entry lowers to the SAME
115
155
  // first-class `Value::Json` a top-level json field does — the map entry round-trips as its OBJECT, not a
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Dependency-free helpers for RECURSIVE `z.lazy` payload schemas, shared by BOTH codegen walkers
3
+ * (codegen_ts + codegen_dart) WITHOUT coupling them. Keeping these here (rather than in codegen_ts)
4
+ * avoids a codegen_ts↔codegen_dart import cycle that would otherwise drag codegen_ts's heavy
5
+ * transitive deps (workspace_routing → node:crypto) into the tenant ENGINE bundle (esbuild
6
+ * neutral-platform, no node builtins). This module imports NOTHING but zod's type.
7
+ */
8
+ import type { z } from "zod";
9
+
10
+ function lazyDef(zt: z.ZodTypeAny): { type?: unknown; getter?: () => z.ZodTypeAny } {
11
+ const raw = zt as unknown as { _def?: { type?: unknown; getter?: () => z.ZodTypeAny }; def?: { type?: unknown; getter?: () => z.ZodTypeAny } };
12
+ return raw._def ?? raw.def ?? {};
13
+ }
14
+
15
+ /**
16
+ * Resolve a `z.lazy(() => …)` ONE level to its inner schema (zod v4 exposes the deferred getter on
17
+ * `_def.getter`). A non-lazy schema is returned unchanged. Recursive `z.lazy` schemas (a `TreeNode`
18
+ * that references itself) are common for tree/JSON payloads; every schema WALKER unwraps here and
19
+ * carries a VISITED / registry keyed on the lazy object's IDENTITY so a self-reference is a
20
+ * detectable knot, never an infinite descent.
21
+ */
22
+ export function zodUnwrapLazy(zt: z.ZodTypeAny): z.ZodTypeAny {
23
+ const def = lazyDef(zt);
24
+ if (def.type === "lazy" && typeof def.getter === "function") return def.getter();
25
+ return zt;
26
+ }
27
+
28
+ const capFirst = (s: string): string => (s.length === 0 ? s : s[0]!.toUpperCase() + s.slice(1));
29
+ const camelize = (s: string): string => s.replace(/[_\s-]+(\w)/g, (_m, c: string) => c.toUpperCase());
30
+
31
+ /**
32
+ * A PascalCase type name from a payload path (`saveTree.root` → `SaveTreeRoot`), array markers
33
+ * dropped. The SAME base the TS and Dart recursive-type synthesizers use so a recursive type's
34
+ * TS alias and Dart class agree in the common case.
35
+ */
36
+ export function recursiveTypeBaseName(path: string): string {
37
+ return (
38
+ path
39
+ .replace(/\[\]/g, "")
40
+ .split(/[.]/)
41
+ .filter(Boolean)
42
+ .map((s) => capFirst(camelize(s)))
43
+ .join("") || "Recursive"
44
+ );
45
+ }