@githolon/dsl 0.54.2 → 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.
- package/dart/lib/nomos_client.dart +3 -0
- package/dart/lib/src/null_slot.dart +128 -0
- package/dart/test/null_slot_test.dart +51 -0
- package/package.json +1 -1
- package/src/authoring.ts +19 -0
- package/src/build_package.ts +5 -0
- package/src/clear_directive.ts +103 -0
- package/src/codegen_dart.ts +165 -27
- package/src/codegen_openapi.ts +23 -4
- package/src/codegen_proof.ts +40 -0
- package/src/codegen_ts.ts +11 -0
- package/src/compile_package_main.ts +4 -0
- package/src/fields.ts +221 -5
- package/src/index.ts +10 -0
- package/src/law_check.ts +183 -1
- package/src/null_meta.ts +65 -0
- package/src/ops.ts +32 -0
- package/src/wire_encode.ts +42 -2
package/src/fields.ts
CHANGED
|
@@ -35,6 +35,66 @@ export type JsonValue =
|
|
|
35
35
|
| JsonValue[]
|
|
36
36
|
| { readonly [k: string]: JsonValue };
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* NULL SEMANTICS (`architecture/null_semantics.md`). JSON's one `null` collapses three distinct
|
|
40
|
+
* law meanings; a field NAMES which it admits before the law lets a null through:
|
|
41
|
+
* - `"forbid"` — null is refused anywhere (the `t.json()` default; the explicit spelling
|
|
42
|
+
* is `t.json({ null: "forbid" })`).
|
|
43
|
+
* - `"explicit-empty"` — nulls are admitted as a documented domain value (`t.json({ null:
|
|
44
|
+
* "explicit-empty", reason })`); `nullReason` records why.
|
|
45
|
+
* - `"nullable"` — null IS a real domain value on this field (`t.nullable(X, { reason })`);
|
|
46
|
+
* `nullReason` records why (rides docs/openapi).
|
|
47
|
+
* The EXPLICIT-CLEAR semantic is ORTHOGONAL, carried by the separate `isClearable` flag (`t.clearable(X)`
|
|
48
|
+
* + `.clear(field)` / the generated `clearX(...)` lane, lowered to a Set of json `null`) — a field can be
|
|
49
|
+
* BOTH clearable and nullable. FRONTEND/IR-ONLY metadata: neither ever enters the kernel wire `Schema`
|
|
50
|
+
* (driver-only — see `encodeKernelSchema`), so a field's domainHash is UNCHANGED. Codegen reads
|
|
51
|
+
* `nullSemantic`/`nullReason`/`isClearable` to shape the tri-state client surface; the runtime enforcement
|
|
52
|
+
* is the payload zod (no-null refine) + the encode-path null guard (`wire_encode.ts`).
|
|
53
|
+
*/
|
|
54
|
+
export type NullSemantic = "forbid" | "explicit-empty" | "nullable";
|
|
55
|
+
|
|
56
|
+
/** The null semantics that ADMIT a null value on a field (everything else refuses null). NOTE: a
|
|
57
|
+
* `isClearable` field ALSO admits null (a cleared field reads back as json null) — use {@link admitsNull}
|
|
58
|
+
* to combine both axes. */
|
|
59
|
+
export const NULL_ADMITTING: ReadonlySet<NullSemantic> = new Set<NullSemantic>(["explicit-empty", "nullable"]);
|
|
60
|
+
|
|
61
|
+
/** True iff a field admits a null VALUE — either a null-admitting `nullSemantic` OR `isClearable`
|
|
62
|
+
* (a cleared field reads back as json null → ExplicitNull, so the read type must be tri-state). This
|
|
63
|
+
* is the ONE predicate the encode guard, the check twins, and codegen's tri-state trigger share. */
|
|
64
|
+
export function admitsNull(field: { readonly nullSemantic?: NullSemantic; readonly isClearable?: true }): boolean {
|
|
65
|
+
return field.isClearable === true || (field.nullSemantic !== undefined && NULL_ADMITTING.has(field.nullSemantic));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* A recursive JSON schema that REFUSES `null` at every depth (`architecture/null_semantics.md`).
|
|
70
|
+
* The PAYLOAD helper for a `t.json()`-shaped field: declare a directive payload field with it and a
|
|
71
|
+
* null anywhere refuses at `.parse()` (the lump gate). Deterministic (a fixed `z.lazy` union — no
|
|
72
|
+
* clock/rng), so it validates byte-identically on every peer. `null` is simply not a member of any
|
|
73
|
+
* arm. (The aggregate FIELD zod stays `z.string()` — type-level, hash-stable; the no-null LAW on the
|
|
74
|
+
* write path is the encode-path guard in `wire_encode.ts`.) */
|
|
75
|
+
export const jsonNoNullSchema: z.ZodType<JsonValue> = z.lazy(() =>
|
|
76
|
+
z.union([
|
|
77
|
+
z.string(),
|
|
78
|
+
z.number(),
|
|
79
|
+
z.boolean(),
|
|
80
|
+
z.array(jsonNoNullSchema),
|
|
81
|
+
z.record(z.string(), jsonNoNullSchema),
|
|
82
|
+
]),
|
|
83
|
+
) as unknown as z.ZodType<JsonValue>;
|
|
84
|
+
|
|
85
|
+
/** A recursive JSON schema that ADMITS `null` at every depth — the `t.json({ null: "explicit-empty" })`
|
|
86
|
+
* field zod (nulls are a declared domain value). Same deterministic `z.lazy` union, plus a `z.null()` arm. */
|
|
87
|
+
export const jsonWithNullSchema: z.ZodType<JsonValue> = z.lazy(() =>
|
|
88
|
+
z.union([
|
|
89
|
+
z.null(),
|
|
90
|
+
z.string(),
|
|
91
|
+
z.number(),
|
|
92
|
+
z.boolean(),
|
|
93
|
+
z.array(jsonWithNullSchema),
|
|
94
|
+
z.record(z.string(), jsonWithNullSchema),
|
|
95
|
+
]),
|
|
96
|
+
) as unknown as z.ZodType<JsonValue>;
|
|
97
|
+
|
|
38
98
|
export type FieldKind =
|
|
39
99
|
| "string"
|
|
40
100
|
| "int"
|
|
@@ -184,6 +244,28 @@ export interface Field<T = unknown, K extends FieldKind = FieldKind> {
|
|
|
184
244
|
* Scalar Lww-class kinds only; mutually exclusive with `.optional()`. Flag named
|
|
185
245
|
* `isFromBirth` — the `fromBirth()` METHOD sets it (the isOptional/isEncrypted convention). */
|
|
186
246
|
readonly isFromBirth?: true;
|
|
247
|
+
/**
|
|
248
|
+
* NULL SEMANTICS (`architecture/null_semantics.md`): what `null` MEANS on this field. Set by the
|
|
249
|
+
* `t.json(...)` null option and the `t.optional`/`t.clearable`/`t.nullable` wrappers. `undefined`
|
|
250
|
+
* ⇒ the field carries no null declaration (a scalar/collection whose null-ness never arises); a
|
|
251
|
+
* `json` field with no wrapper defaults to `"forbid"` at construction. FRONTEND/IR-ONLY — never in
|
|
252
|
+
* the kernel wire `Schema` (driver-only), so it changes NO hash. See {@link NullSemantic}.
|
|
253
|
+
*/
|
|
254
|
+
readonly nullSemantic?: NullSemantic;
|
|
255
|
+
/** The REQUIRED reason string for `nullable`/`explicit-empty` (law documentation — rides docs/openapi).
|
|
256
|
+
* Present iff `nullSemantic` is `"nullable"` or `"explicit-empty"`. */
|
|
257
|
+
readonly nullReason?: string;
|
|
258
|
+
/** EXPLICIT-CLEAR (`architecture/null_semantics.md`): set by `t.clearable(...)`. The field admits an
|
|
259
|
+
* explicit-clear write (`clear(handle, "field")` / `.clear("field")` / the generated `clearX(...)`
|
|
260
|
+
* lane), lowered to a Set of json `null`. ORTHOGONAL to `nullSemantic` (a field may be both). A
|
|
261
|
+
* cleared field reads back as json null, so codegen surfaces it tri-state (see {@link admitsNull}).
|
|
262
|
+
* FRONTEND/IR-ONLY — no hash change. */
|
|
263
|
+
readonly isClearable?: true;
|
|
264
|
+
/** The AUTHZ relation the GENERATED clear directive requires (`t.clearable(field, { requires })`). When
|
|
265
|
+
* set, the compose-seam-injected `clear<Agg><Field>` directive carries `.requires(relation)`; when
|
|
266
|
+
* absent the clear lane is ungated (and `law_check` WARNs if sibling writes ARE gated). Compose/codegen
|
|
267
|
+
* metadata — the REAL law is the generated directive's `requiresRelationGate` (hashed there, not here). */
|
|
268
|
+
readonly clearRequires?: string;
|
|
187
269
|
/** For `t.ref` cross-workspace edges: the foreign workspace id, if any. */
|
|
188
270
|
readonly refWorkspace?: string;
|
|
189
271
|
/** For `t.ref` (the target) AND `t.hasMany` (the child type): the related aggregate's wire id. */
|
|
@@ -295,6 +377,15 @@ export interface Field<T = unknown, K extends FieldKind = FieldKind> {
|
|
|
295
377
|
/** Declare BIRTH-PRESENCE LAW: aggregate() synthesizes a gate invariant requiring this field
|
|
296
378
|
* present on every event touching the type; read models type it NON-NULLABLE. */
|
|
297
379
|
fromBirth(): Field<T, K>;
|
|
380
|
+
/**
|
|
381
|
+
* INTERNAL (the `t.optional`/`t.clearable`/`t.nullable` wrappers call this): tag this field's null
|
|
382
|
+
* semantic (+ optional reason). Threaded through merge/optional like every other flag. Not for
|
|
383
|
+
* direct authoring — reach it via the wrappers, which validate driver/kind eligibility.
|
|
384
|
+
*/
|
|
385
|
+
withNull(semantic: NullSemantic, reason?: string): Field<T, K>;
|
|
386
|
+
/** INTERNAL (`t.clearable` calls this): mark this field EXPLICIT-CLEARABLE (sets {@link Field.isClearable}
|
|
387
|
+
* + optional {@link Field.clearRequires}). Threaded through merge/optional like every other flag. */
|
|
388
|
+
clearable(requires?: string): Field<T, K> & { readonly isClearable: true };
|
|
298
389
|
/** Phantom to keep T load-bearing for inference. */
|
|
299
390
|
readonly _t?: T;
|
|
300
391
|
}
|
|
@@ -316,6 +407,10 @@ function makeField<T, K extends FieldKind = FieldKind>(
|
|
|
316
407
|
encrypted?: true;
|
|
317
408
|
meta?: true;
|
|
318
409
|
facet?: FieldFacet;
|
|
410
|
+
nullSemantic?: NullSemantic;
|
|
411
|
+
nullReason?: string;
|
|
412
|
+
isClearable?: true;
|
|
413
|
+
clearRequires?: string;
|
|
319
414
|
} = {},
|
|
320
415
|
): Field<T, K> {
|
|
321
416
|
const isOptional = extra.isOptional ?? false;
|
|
@@ -335,6 +430,10 @@ function makeField<T, K extends FieldKind = FieldKind>(
|
|
|
335
430
|
...(extra.encrypted ? { isEncrypted: true as const } : {}),
|
|
336
431
|
...(extra.meta ? { isMeta: true as const } : {}),
|
|
337
432
|
...(extra.fromBirth ? { isFromBirth: true as const } : {}),
|
|
433
|
+
...(extra.nullSemantic !== undefined ? { nullSemantic: extra.nullSemantic } : {}),
|
|
434
|
+
...(extra.nullReason !== undefined ? { nullReason: extra.nullReason } : {}),
|
|
435
|
+
...(extra.isClearable ? { isClearable: true as const } : {}),
|
|
436
|
+
...(extra.clearRequires !== undefined ? { clearRequires: extra.clearRequires } : {}),
|
|
338
437
|
merge(d: Driver) {
|
|
339
438
|
return makeField<T, K>(kind, zod, d, extra);
|
|
340
439
|
},
|
|
@@ -364,6 +463,22 @@ function makeField<T, K extends FieldKind = FieldKind>(
|
|
|
364
463
|
}
|
|
365
464
|
return makeField<T, K>(kind, zod, driver, { ...extra, fromBirth: true });
|
|
366
465
|
},
|
|
466
|
+
withNull(semantic: NullSemantic, reason?: string): Field<T, K> {
|
|
467
|
+
// Threaded through merge/optional like every other flag (it rides `extra`). Reason is carried
|
|
468
|
+
// only for the semantics that document one; the wrappers validate eligibility before calling.
|
|
469
|
+
return makeField<T, K>(kind, zod, driver, {
|
|
470
|
+
...extra,
|
|
471
|
+
nullSemantic: semantic,
|
|
472
|
+
...(reason !== undefined ? { nullReason: reason } : {}),
|
|
473
|
+
});
|
|
474
|
+
},
|
|
475
|
+
clearable(requires?: string): Field<T, K> & { readonly isClearable: true } {
|
|
476
|
+
return makeField<T, K>(kind, zod, driver, {
|
|
477
|
+
...extra,
|
|
478
|
+
isClearable: true,
|
|
479
|
+
...(requires !== undefined ? { clearRequires: requires } : {}),
|
|
480
|
+
}) as Field<T, K> & { readonly isClearable: true };
|
|
481
|
+
},
|
|
367
482
|
encrypted(): Field<T, K> {
|
|
368
483
|
// Carry the flag through merge/optional (extra is threaded), so `.merge(Lww).encrypted()`
|
|
369
484
|
// and `.encrypted().merge(Lww)` agree. The wire schema is UNCHANGED (driver-only) — the
|
|
@@ -411,6 +526,42 @@ export const publicField = <T, K extends FieldKind>(field: Field<T, K>): Field<T
|
|
|
411
526
|
/** Infer the authored value type of a Field. */
|
|
412
527
|
export type FieldValue<F> = F extends Field<infer T> ? T : never;
|
|
413
528
|
|
|
529
|
+
/** The kinds a `clearable`/`nullable` null semantic may ride: VALUE LEAVES only. A collection/CRDT
|
|
530
|
+
* (`set`/`map`/`counter`/`position`/`hasMany`) clears/empties through its own additive op, never a null. */
|
|
531
|
+
const NULL_ELIGIBLE_KINDS: ReadonlySet<FieldKind> = new Set<FieldKind>([
|
|
532
|
+
"string",
|
|
533
|
+
"int",
|
|
534
|
+
"bool",
|
|
535
|
+
"enum",
|
|
536
|
+
"json",
|
|
537
|
+
"ref",
|
|
538
|
+
"evidence",
|
|
539
|
+
]);
|
|
540
|
+
|
|
541
|
+
/** Guard the `t.clearable`/`t.nullable` wrappers: the field must be a value leaf with an Lww-class
|
|
542
|
+
* driver (the value is replaced/cleared WHOLESALE). Throws with the fix inside the message. */
|
|
543
|
+
function assertNullEligible(field: Field<unknown, FieldKind>, wrapper: "clearable" | "nullable"): void {
|
|
544
|
+
if (!NULL_ELIGIBLE_KINDS.has(field.kind)) {
|
|
545
|
+
throw new Error(
|
|
546
|
+
`t.${wrapper}(...) is not available on a '${field.kind}' field — a collection/CRDT clears through ` +
|
|
547
|
+
`its own additive op (removeFromSet / decrement), never a null. Value leaves only ` +
|
|
548
|
+
`(${[...NULL_ELIGIBLE_KINDS].join("/")}).`,
|
|
549
|
+
);
|
|
550
|
+
}
|
|
551
|
+
const dk = (field.driver as { kind?: string }).kind;
|
|
552
|
+
if (dk !== "Lww" && dk !== "LwwCausal" && dk !== "LwwBy") {
|
|
553
|
+
throw new Error(
|
|
554
|
+
`t.${wrapper}(...) needs an Lww-class field (Lww / LwwCausal / LwwBy) — a null/clear replaces the ` +
|
|
555
|
+
`value WHOLESALE, which a '${dk}' merge cannot express. Declare the field Lww-class first.`,
|
|
556
|
+
);
|
|
557
|
+
}
|
|
558
|
+
if (field.isFromBirth === true) {
|
|
559
|
+
throw new Error(
|
|
560
|
+
`t.${wrapper}(...) contradicts .fromBirth() — a birth-present field is NON-NULLABLE by law; drop one.`,
|
|
561
|
+
);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
414
565
|
export const t = {
|
|
415
566
|
string(): Field<string, "string"> {
|
|
416
567
|
return makeField("string", z.string(), Lww);
|
|
@@ -491,11 +642,76 @@ export const t = {
|
|
|
491
642
|
* JSON leaf — the kernel `Value` is Str|Int only), so the v1-free projection surfaces
|
|
492
643
|
* it as a permissive `Object?`. Use `t.jsonObject()` when the leaf is ALWAYS an OBJECT
|
|
493
644
|
* value-object (e.g. a `location`) to get a strict `Map<String,Object?>` read type. */
|
|
494
|
-
json(): Field<JsonValue, "json"> {
|
|
495
|
-
//
|
|
496
|
-
//
|
|
497
|
-
//
|
|
498
|
-
|
|
645
|
+
json(opts?: { null?: "forbid" | "explicit-empty"; reason?: string }): Field<JsonValue, "json"> {
|
|
646
|
+
// NULL SEMANTICS (`architecture/null_semantics.md`): `t.json()` EXCLUDES null at every depth by
|
|
647
|
+
// default. The FIELD zod stays `z.string()` (TYPE-LEVEL only — the wire is a `Value::Json` leaf,
|
|
648
|
+
// identical bytes/hash to before), and the no-null LAW is enforced two ways: the encode-path null
|
|
649
|
+
// guard (`wire_encode.ts`) refuses a plan-injected null in the lump on every peer, and a payload
|
|
650
|
+
// declared with the exported {@link jsonNoNullSchema} refuses a null at `.parse()`. `{ null:
|
|
651
|
+
// "explicit-empty", reason }` admits nulls as a documented domain value (reason REQUIRED — a
|
|
652
|
+
// meaning-free null is the whole footgun); the `nullSemantic` metadata is FRONTEND/IR-ONLY (codegen
|
|
653
|
+
// shapes the tri-state client surface from it) and never enters the hashed IR.
|
|
654
|
+
const mode = opts?.null ?? "forbid";
|
|
655
|
+
if (mode === "explicit-empty") {
|
|
656
|
+
if (typeof opts?.reason !== "string" || opts.reason.trim() === "") {
|
|
657
|
+
throw new Error(
|
|
658
|
+
`t.json({ null: "explicit-empty" }) requires a non-empty \`reason\` — the reason is law ` +
|
|
659
|
+
`documentation (it rides docs/openapi so every consumer knows what a null MEANS here). ` +
|
|
660
|
+
`Give a reason, or use the default t.json() which forbids null.`,
|
|
661
|
+
);
|
|
662
|
+
}
|
|
663
|
+
return makeField<string, "json">("json", z.string(), Lww, {
|
|
664
|
+
nullSemantic: "explicit-empty",
|
|
665
|
+
nullReason: opts.reason,
|
|
666
|
+
}) as unknown as Field<JsonValue, "json">;
|
|
667
|
+
}
|
|
668
|
+
return makeField<string, "json">("json", z.string(), Lww, {
|
|
669
|
+
nullSemantic: "forbid",
|
|
670
|
+
}) as unknown as Field<JsonValue, "json">;
|
|
671
|
+
},
|
|
672
|
+
/**
|
|
673
|
+
* `t.optional(field)` — the OMISSION semantic: a folded aggregate may LACK this field (absent =
|
|
674
|
+
* not supplied / inherit). The wrapper spelling of `field.optional()` (`architecture/null_semantics.md`);
|
|
675
|
+
* null NEVER arrives for an optional field — the law forbids it — so the generated client stays an
|
|
676
|
+
* idiomatic `T?` (absence only). Chainable-equivalent to `.optional()`.
|
|
677
|
+
*/
|
|
678
|
+
optional<T, K extends FieldKind>(field: Field<T, K>): Field<T | undefined, K> {
|
|
679
|
+
return field.optional();
|
|
680
|
+
},
|
|
681
|
+
/**
|
|
682
|
+
* `t.clearable(field)` — the EXPLICIT-CLEAR semantic (`architecture/null_semantics.md`): the field
|
|
683
|
+
* admits an explicit-clear write (`.clear("field")` / the generated `clearX(...)` lane), lowered to a
|
|
684
|
+
* Set of json `null`. Value leaves only (Lww-class scalar / json / value-object — a collection/CRDT
|
|
685
|
+
* clears by its own additive op, never by null), else a compile-time throw. Codegen emits the clear lane.
|
|
686
|
+
*/
|
|
687
|
+
clearable<T, K extends FieldKind>(
|
|
688
|
+
field: Field<T, K>,
|
|
689
|
+
opts?: { requires?: string },
|
|
690
|
+
): Field<T, K> & { readonly isClearable: true } {
|
|
691
|
+
assertNullEligible(field, "clearable");
|
|
692
|
+
return field.clearable(opts?.requires);
|
|
693
|
+
},
|
|
694
|
+
/**
|
|
695
|
+
* `t.nullable(field, { reason })` — null IS a real domain value on this field (`architecture/
|
|
696
|
+
* null_semantics.md`): e.g. external-import fidelity. `reason` is REQUIRED (a meaning-free null is the
|
|
697
|
+
* footgun this feature removes) and rides docs/openapi. Value leaves only (Lww-class), else a throw.
|
|
698
|
+
* The field's zod is widened to admit null; the generated client gets the tri-state `| null` arm.
|
|
699
|
+
*/
|
|
700
|
+
nullable<T, K extends FieldKind>(
|
|
701
|
+
field: Field<T, K>,
|
|
702
|
+
opts: { reason: string },
|
|
703
|
+
): Field<T | null, K> & { readonly nullSemantic: "nullable" } {
|
|
704
|
+
if (typeof opts?.reason !== "string" || opts.reason.trim() === "") {
|
|
705
|
+
throw new Error(
|
|
706
|
+
`t.nullable(field, { reason }) requires a non-empty \`reason\` — null is a real value here, and ` +
|
|
707
|
+
`the reason is law documentation (it rides docs/openapi). Give a reason, or use t.optional(...) ` +
|
|
708
|
+
`for omission / t.clearable(...) for explicit clearing.`,
|
|
709
|
+
);
|
|
710
|
+
}
|
|
711
|
+
assertNullEligible(field, "nullable");
|
|
712
|
+
return field.withNull("nullable", opts.reason) as unknown as Field<T | null, K> & {
|
|
713
|
+
readonly nullSemantic: "nullable";
|
|
714
|
+
};
|
|
499
715
|
},
|
|
500
716
|
/** A JSON-OBJECT value-object leaf: an arbitrary JSON-string leaf (identical wire +
|
|
501
717
|
* driver to `t.json()` — a `Str` at op time, so NO hash change) whose decoded value is
|
package/src/index.ts
CHANGED
|
@@ -31,6 +31,11 @@ export {
|
|
|
31
31
|
type FieldValue,
|
|
32
32
|
type FieldKind,
|
|
33
33
|
type JsonValue,
|
|
34
|
+
type NullSemantic,
|
|
35
|
+
NULL_ADMITTING,
|
|
36
|
+
admitsNull,
|
|
37
|
+
jsonNoNullSchema,
|
|
38
|
+
jsonWithNullSchema,
|
|
34
39
|
type EvidenceRefValue,
|
|
35
40
|
evidenceRefSchema,
|
|
36
41
|
// FIELD FACETS (the endorse model — security_model.md): authority/standing/attribution gate
|
|
@@ -94,6 +99,7 @@ export {
|
|
|
94
99
|
} from "./drivers.js";
|
|
95
100
|
export {
|
|
96
101
|
set,
|
|
102
|
+
clear,
|
|
97
103
|
addToSet,
|
|
98
104
|
setEntry,
|
|
99
105
|
increment,
|
|
@@ -106,6 +112,10 @@ export {
|
|
|
106
112
|
type PlannedEventMarker,
|
|
107
113
|
type StrikeOp,
|
|
108
114
|
} from "./ops.js";
|
|
115
|
+
// GENERATED CLEAR LANE (null_semantics.md): `clearDirectiveId(aggId, field)` is the id convention
|
|
116
|
+
// (`clear<Agg><Field>`) the codegen agent's `clearX()` client methods bind to; the directives
|
|
117
|
+
// themselves are compose-injected (`withClearDirectives`) so they ride every plane's module surface.
|
|
118
|
+
export { clearDirectiveId, makeClearDirective, withClearDirectives } from "./clear_directive.js";
|
|
109
119
|
export {
|
|
110
120
|
directive,
|
|
111
121
|
recordObject,
|
package/src/law_check.ts
CHANGED
|
@@ -30,7 +30,7 @@ import { z } from "zod";
|
|
|
30
30
|
import type { AggregateHandle } from "./aggregate.js";
|
|
31
31
|
import type { Directive } from "./directive.js";
|
|
32
32
|
import type { DomainModule } from "./codegen_dart.js";
|
|
33
|
-
import type
|
|
33
|
+
import { admitsNull, type Field, type ValueObjectDescriptor, type ValueObjectFieldDescriptor } from "./fields.js";
|
|
34
34
|
import type { FieldOp, PlannedOp } from "./ops.js";
|
|
35
35
|
import { deterministicPorts } from "./ctx.js";
|
|
36
36
|
import { synthesizePayload, withBirthProbeGlobals, type SynthesizedPayload } from "./codegen_proof.js";
|
|
@@ -43,6 +43,7 @@ import { finishCount } from "./count.js";
|
|
|
43
43
|
import { finishSum } from "./sum.js";
|
|
44
44
|
import { finishExtremum } from "./extremum.js";
|
|
45
45
|
import { finishExists } from "./exists.js";
|
|
46
|
+
import { clearDirectiveId } from "./clear_directive.js";
|
|
46
47
|
|
|
47
48
|
/** One author-time finding — the gate refusal pre-fired at compile, or a courtesy note. */
|
|
48
49
|
export interface Finding {
|
|
@@ -656,6 +657,183 @@ function dottedQueryKeyFindings(mod: DomainModule, aggById: ReadonlyMap<string,
|
|
|
656
657
|
return out;
|
|
657
658
|
}
|
|
658
659
|
|
|
660
|
+
// ── TWIN N (null_semantics.md) — the NULL-SEMANTICS twins ──────────────────────────
|
|
661
|
+
// JSON's one `null` collapses three law meanings, so the DSL makes the author NAME which a field
|
|
662
|
+
// admits before a null is lawful (t.optional / t.clearable / t.nullable / t.json({null})). These
|
|
663
|
+
// twins pre-fire the three refusals the gate would raise: (a) a payload field that ADMITS null with
|
|
664
|
+
// no null-admitting receiver; (b) a null-admitting field used where a null is structurally illegal
|
|
665
|
+
// (authority gate, identity ref, spatial index, read key); (c) a plan op writing literal null into a
|
|
666
|
+
// field that never declared null a meaning. All PAYLOAD-INDEPENDENT (pure declarations) ⇒ REFUSE.
|
|
667
|
+
|
|
668
|
+
/** The verbatim null-semantics guidance (`null_semantics.md`) — the `fix` on twin N-a/N-c. */
|
|
669
|
+
const NULL_GUIDANCE =
|
|
670
|
+
'Use optional for omission, clearable(...) for explicit clearing, or nullable(..., reason: "...") for an explicit empty/import value.';
|
|
671
|
+
|
|
672
|
+
/** Minimal zod `_def`/`def` accessor (the `_def ?? def` convention, node-builtin-free). */
|
|
673
|
+
function zDef(zt: z.ZodTypeAny): { type?: string; innerType?: z.ZodTypeAny; options?: readonly z.ZodTypeAny[]; value?: unknown } {
|
|
674
|
+
const raw = zt as unknown as { _def?: Record<string, unknown>; def?: Record<string, unknown> };
|
|
675
|
+
return (raw._def ?? raw.def ?? {}) as { type?: string; innerType?: z.ZodTypeAny; options?: readonly z.ZodTypeAny[]; value?: unknown };
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/** True iff a payload schema ADMITS a `null` value (a `.nullable()` wrap, `z.null()`/`z.literal(null)`,
|
|
679
|
+
* or a union with a null arm) — the static signal twin N-a keys on. Bounded recursion. */
|
|
680
|
+
function zodAdmitsNull(schema: z.ZodTypeAny | undefined, depth = 0): boolean {
|
|
681
|
+
if (schema === undefined || depth > 16) return false;
|
|
682
|
+
const k = zodKind(schema);
|
|
683
|
+
if (k === "null" || k === "nullable") return true;
|
|
684
|
+
if (k === "literal") return zDef(schema).value === null;
|
|
685
|
+
if (k === "optional" || k === "default") return zodAdmitsNull(zDef(schema).innerType, depth + 1);
|
|
686
|
+
if (k === "union") return (zDef(schema).options ?? []).some((o) => zodAdmitsNull(o, depth + 1));
|
|
687
|
+
return false;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/** The set of a domain's aggregate fields (by name) whose null semantic ADMITS a null — the receivers
|
|
691
|
+
* that legitimately take a null-bearing payload field. Name-keyed (the `.set("x", p.x)` convention). */
|
|
692
|
+
function nullAdmittingFieldNames(aggById: ReadonlyMap<string, AggregateHandle>): ReadonlySet<string> {
|
|
693
|
+
const out = new Set<string>();
|
|
694
|
+
for (const agg of aggById.values()) {
|
|
695
|
+
for (const [name, f] of Object.entries(agg.fields)) {
|
|
696
|
+
if (admitsNull(f as Field)) out.add(name);
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
return out;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// TWIN N-a — a directive PAYLOAD field admits null with no null-admitting receiver.
|
|
703
|
+
function nullPayloadFindings(
|
|
704
|
+
domain: string,
|
|
705
|
+
dir: Directive<unknown>,
|
|
706
|
+
aggById: ReadonlyMap<string, AggregateHandle>,
|
|
707
|
+
): Finding[] {
|
|
708
|
+
const receivers = nullAdmittingFieldNames(aggById);
|
|
709
|
+
const out: Finding[] = [];
|
|
710
|
+
for (const [name, f] of Object.entries(safeShape(dir.payloadSchema as z.ZodTypeAny))) {
|
|
711
|
+
if (!zodAdmitsNull(f)) continue;
|
|
712
|
+
if (receivers.has(name)) continue; // a field of the same name declared nullable/clearable — lawful
|
|
713
|
+
out.push({
|
|
714
|
+
domain,
|
|
715
|
+
directiveId: dir.id,
|
|
716
|
+
level: "refuse",
|
|
717
|
+
what: `payload field '${name}' admits null, but null has no law-level meaning`,
|
|
718
|
+
fix: NULL_GUIDANCE,
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
return out;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// TWIN N-c — a plan op writes literal null into a field that never declared null a meaning.
|
|
725
|
+
function nullWriteFindings(
|
|
726
|
+
domain: string,
|
|
727
|
+
dir: Directive<unknown>,
|
|
728
|
+
probe: ProbeResult,
|
|
729
|
+
aggById: ReadonlyMap<string, AggregateHandle>,
|
|
730
|
+
): Finding[] {
|
|
731
|
+
if (probe.threw) return [];
|
|
732
|
+
const out: Finding[] = [];
|
|
733
|
+
const seen = new Set<string>();
|
|
734
|
+
for (const op of probe.ops) {
|
|
735
|
+
if (op.kind !== "set" || op.value !== null) continue;
|
|
736
|
+
const field =
|
|
737
|
+
op.targetField ??
|
|
738
|
+
(typeof op.aggregateType === "string"
|
|
739
|
+
? (aggById.get(op.aggregateType)?.fields[op.field] as Field | undefined)
|
|
740
|
+
: (aggById.get(op.aggregateId)?.fields[op.field] as Field | undefined));
|
|
741
|
+
if (field === undefined) continue; // foreign/framework target — runtime gate arbitrates
|
|
742
|
+
if (admitsNull(field)) continue; // clearable/nullable — lawful
|
|
743
|
+
const key = `${op.aggregateType ?? op.aggregateId}.${op.field}`;
|
|
744
|
+
if (seen.has(key)) continue;
|
|
745
|
+
seen.add(key);
|
|
746
|
+
out.push({
|
|
747
|
+
domain,
|
|
748
|
+
directiveId: dir.id,
|
|
749
|
+
level: "refuse",
|
|
750
|
+
what: `the plan writes null into '${op.field}' on '${op.aggregateType ?? op.aggregateId}', which never declared null a meaning`,
|
|
751
|
+
fix: `declare the field t.clearable(...) and use clear(handle, "${op.field}"), or t.nullable(..., { reason }). ${NULL_GUIDANCE}`,
|
|
752
|
+
});
|
|
753
|
+
}
|
|
754
|
+
return out;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// TWIN N-b — a null-admitting field used where a null is structurally ILLEGAL.
|
|
758
|
+
function nullBanFindings(mod: DomainModule, aggById: ReadonlyMap<string, AggregateHandle>): Finding[] {
|
|
759
|
+
const domain = mod.domain ?? mod.name;
|
|
760
|
+
const out: Finding[] = [];
|
|
761
|
+
const seen = new Set<string>();
|
|
762
|
+
const admits = (aggId: string, fieldName: string): Field | undefined => {
|
|
763
|
+
const f = aggById.get(aggId)?.fields[fieldName] as Field | undefined;
|
|
764
|
+
return f !== undefined && admitsNull(f) ? f : undefined;
|
|
765
|
+
};
|
|
766
|
+
const ban = (aggId: string, fieldName: string, rule: string, fix: string): void => {
|
|
767
|
+
if (admits(aggId, fieldName) === undefined) return;
|
|
768
|
+
const key = `${rule}:${aggId}.${fieldName}`;
|
|
769
|
+
if (seen.has(key)) return;
|
|
770
|
+
seen.add(key);
|
|
771
|
+
out.push({
|
|
772
|
+
domain,
|
|
773
|
+
level: "refuse",
|
|
774
|
+
what: `'${aggId}.${fieldName}' is a null-admitting field (nullable/clearable/explicit-empty) used as ${rule} — a null there is structurally illegal`,
|
|
775
|
+
fix,
|
|
776
|
+
});
|
|
777
|
+
};
|
|
778
|
+
|
|
779
|
+
// Facet gates (authority/standing) + identity refs — a null in these breaks the very thing they decide.
|
|
780
|
+
for (const agg of mod.aggregates) {
|
|
781
|
+
for (const [name, f0] of Object.entries(agg.fields)) {
|
|
782
|
+
const f = f0 as Field;
|
|
783
|
+
if (admits(agg.id, name) === undefined) continue;
|
|
784
|
+
if (f.facet === "authority" || f.facet === "standing") {
|
|
785
|
+
ban(agg.id, name, "an authority/standing gate", "drop the null semantic — an authority sink must always resolve to a subject.");
|
|
786
|
+
}
|
|
787
|
+
if (f.kind === "ref") {
|
|
788
|
+
ban(agg.id, name, "an identity reference", "an identity ref must always point somewhere — model absence with a separate presence flag, not a null ref.");
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
// Read keys / group-bys / spatial indexes — a null key row is a silent dead index.
|
|
793
|
+
for (const q of mod.queries ?? []) for (const k of q.key) if (!k.includes(".")) ban(q.returns, k, `the query '${q.id}' key`, "key on a non-null field — a null key never indexes.");
|
|
794
|
+
const banBy = (of: string, by: string | undefined, id: string, noun: string): void => {
|
|
795
|
+
if (by !== undefined && !by.includes(".")) ban(of, by, `the ${noun} '${id}' group-by`, "group by a non-null field — a null group is a silent dead bucket.");
|
|
796
|
+
};
|
|
797
|
+
for (const c of mod.counts ?? []) { const d = finishCount(c); banBy(d.of, d.by, d.id, "count"); }
|
|
798
|
+
for (const s of mod.sums ?? []) { const d = finishSum(s); banBy(d.of, d.by, d.id, "sum"); }
|
|
799
|
+
for (const m of mod.mins ?? []) { const d = finishExtremum(m); banBy(d.of, d.by, d.id, "min"); }
|
|
800
|
+
for (const m of mod.maxes ?? []) { const d = finishExtremum(m); banBy(d.of, d.by, d.id, "max"); }
|
|
801
|
+
for (const e of mod.exists_ ?? []) { const d = finishExists(e); banBy(d.of, d.by, d.id, "exists"); }
|
|
802
|
+
for (const sp of mod.spatials ?? []) if (!sp.on.includes(".")) ban(sp.of, sp.on, `the spatial index '${sp.id}'`, "a spatial field must always carry a geometry — a null point is not indexable.");
|
|
803
|
+
return out;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
// TWIN N-d (null_semantics.md) — an UNGATED generated clear lane beside GATED sibling writes.
|
|
807
|
+
// A `t.clearable(field)` mints a `clear<Agg><Field>` WRITE directive. If it is ungated (no
|
|
808
|
+
// `requires:`) while OTHER writes on the same aggregate ARE `.requires`-gated, the clear lane is an
|
|
809
|
+
// accidental authz hole (you can null a field you can't otherwise write). WARN (not refuse — an
|
|
810
|
+
// ungated clear may be intentional; name the fix). The generated directive carries the real gate;
|
|
811
|
+
// this twin reads the field's `clearRequires` metadata to see whether the author declared one.
|
|
812
|
+
function nullClearAuthorityFindings(mod: DomainModule, aggById: ReadonlyMap<string, AggregateHandle>): Finding[] {
|
|
813
|
+
const domain = mod.domain ?? mod.name;
|
|
814
|
+
// Aggregate ids that carry at least one `.requires`-gated directive (the authz axis).
|
|
815
|
+
const gatedAggs = new Set<string>();
|
|
816
|
+
for (const d of mod.directives as Directive<unknown>[]) {
|
|
817
|
+
if ((d as { requiresRelationGate?: unknown }).requiresRelationGate !== undefined) gatedAggs.add(d.aggregateId);
|
|
818
|
+
}
|
|
819
|
+
const out: Finding[] = [];
|
|
820
|
+
for (const agg of mod.aggregates) {
|
|
821
|
+
if (!gatedAggs.has(agg.id)) continue; // no gated sibling → an ungated clear is not a relative hole
|
|
822
|
+
for (const [name, f0] of Object.entries(agg.fields)) {
|
|
823
|
+
const f = f0 as Field;
|
|
824
|
+
if (f.isClearable !== true || f.clearRequires !== undefined) continue; // gated clear → fine
|
|
825
|
+
out.push({
|
|
826
|
+
domain,
|
|
827
|
+
directiveId: clearDirectiveId(agg.id, name),
|
|
828
|
+
level: "warn",
|
|
829
|
+
what: `the generated clear lane for '${agg.id}.${name}' is UNGATED, but sibling writes on '${agg.id}' are .requires-gated — a clear is a write, so this can null a field you otherwise can't write`,
|
|
830
|
+
fix: `pass an authority to t.clearable("${name}", { requires: "<relation>" }), or gate it deliberately if an open clear is intended`,
|
|
831
|
+
});
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
return out;
|
|
835
|
+
}
|
|
836
|
+
|
|
659
837
|
// ── TWIN 3 (slice 2) — INVARIANT SAMPLE-PROBE (aggregate + workspace invariants) ────
|
|
660
838
|
// An aggregate invariant (`aggregate(id, fields, { invariant })`) runs on every touching event; a
|
|
661
839
|
// workspaceInvariant(id).on(dir).reads(...).assert(...) runs at the gate. We probe each like a plan:
|
|
@@ -856,12 +1034,16 @@ export function checkDomainModules(
|
|
|
856
1034
|
findings.push(...unreadPayloadFindings(domain, dir, probe));
|
|
857
1035
|
findings.push(...opVsSchemaFindings(domain, dir, probe, aggById));
|
|
858
1036
|
findings.push(...deletedAmbientFindings(domain, dir, probe));
|
|
1037
|
+
findings.push(...nullPayloadFindings(domain, dir, aggById));
|
|
1038
|
+
findings.push(...nullWriteFindings(domain, dir, probe, aggById));
|
|
859
1039
|
}
|
|
860
1040
|
|
|
861
1041
|
// Slice-2/slice-3 twins over the whole module: invariant sample-probes + encryption grouping.
|
|
862
1042
|
findings.push(...invariantFindings(mod, aggById));
|
|
863
1043
|
findings.push(...encryptionGroupingFindings(mod, aggById));
|
|
864
1044
|
findings.push(...dottedQueryKeyFindings(mod, aggById));
|
|
1045
|
+
findings.push(...nullBanFindings(mod, aggById));
|
|
1046
|
+
findings.push(...nullClearAuthorityFindings(mod, aggById));
|
|
865
1047
|
}
|
|
866
1048
|
|
|
867
1049
|
// The BIRTH-RECIPE twin (unify): fold the scattered `.births()` recipe diagnostics into the
|
package/src/null_meta.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
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
|
+
// If a file isn't this / hosting this / authoring for this / proving this — it's gone.
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* NULL-SEMANTICS METADATA CONSUMPTION (architecture/null_semantics.md — the codegen half).
|
|
8
|
+
*
|
|
9
|
+
* The DSL half (`fields.ts`/`ops.ts`/`law_check.ts`) TAGS a field with its null semantic:
|
|
10
|
+
* `nullSemantic` (`"forbid"` default · `"explicit-empty"` · `"nullable"`), the required
|
|
11
|
+
* `nullReason` documentation for the null-admitting semantics, and `isClearable` for the
|
|
12
|
+
* explicit-clear lane. This module is the ONE reader of those props for the 5 codegen surfaces
|
|
13
|
+
* (TS/Dart clients, the Dart bridge client, proof synthesis, openapi) — so a shape change lands
|
|
14
|
+
* in one place, and the read is a structural cast (the props are additive/optional on `Field`,
|
|
15
|
+
* so a field carrying none is byte-identical to a pre-feature field).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { admitsNull } from "./fields.js";
|
|
19
|
+
|
|
20
|
+
// THE null-admission predicate is the DSL half's canonical `admitsNull` (fields.ts) — it treats a
|
|
21
|
+
// `nullSemantic` of `explicit-empty`/`nullable` AND `isClearable` alike (a cleared field reads back as
|
|
22
|
+
// json null → ExplicitNull), so the encode guard, the check twins, and codegen's tri-state trigger all
|
|
23
|
+
// share ONE definition. Re-exported so the 5 codegen surfaces import it from here with the doc helpers.
|
|
24
|
+
export { admitsNull };
|
|
25
|
+
|
|
26
|
+
/** The three named null semantics (`forbid` is the default when the field carries none). */
|
|
27
|
+
export type NullSemantic = "forbid" | "explicit-empty" | "nullable";
|
|
28
|
+
|
|
29
|
+
/** The null-semantics view of a field — a structural read of the DSL-tagged props. */
|
|
30
|
+
export interface NullMeta {
|
|
31
|
+
readonly nullSemantic: NullSemantic;
|
|
32
|
+
/** The law-documentation reason (required by the DSL for the null-admitting semantics). */
|
|
33
|
+
readonly nullReason?: string;
|
|
34
|
+
/** The field declares an explicit-CLEAR lane (`t.clearable(...)`). */
|
|
35
|
+
readonly clearable: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The structural shape the DSL half writes onto a null-aware field (all optional/additive). */
|
|
39
|
+
interface NullTaggedField {
|
|
40
|
+
readonly nullSemantic?: NullSemantic;
|
|
41
|
+
readonly nullReason?: string;
|
|
42
|
+
readonly isClearable?: true;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Read a field's (or any tagged value object descriptor's) null-semantics view. A field carrying
|
|
46
|
+
* none reads as the `forbid` default with no reason and non-clearable — the era-safe zero. */
|
|
47
|
+
export function nullMetaOf(field: unknown): NullMeta {
|
|
48
|
+
const f = (field ?? {}) as NullTaggedField;
|
|
49
|
+
const nullSemantic: NullSemantic = f.nullSemantic ?? "forbid";
|
|
50
|
+
return {
|
|
51
|
+
nullSemantic,
|
|
52
|
+
...(f.nullReason !== undefined ? { nullReason: f.nullReason } : {}),
|
|
53
|
+
clearable: f.isClearable === true,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The human doc line for a null-admitting field, e.g. `null semantic: external import fidelity`.
|
|
58
|
+
* Surfaces in generated openapi field descriptions + TS/Dart doc comments. `undefined` when the
|
|
59
|
+
* field forbids null (nothing to say) or carries no reason. */
|
|
60
|
+
export function nullDocLine(field: unknown): string | undefined {
|
|
61
|
+
const m = nullMetaOf(field);
|
|
62
|
+
if (m.nullSemantic === "forbid") return undefined;
|
|
63
|
+
const which = m.nullSemantic === "nullable" ? "nullable" : "explicit-empty";
|
|
64
|
+
return m.nullReason !== undefined ? `null semantic (${which}): ${m.nullReason}` : `null semantic: ${which}`;
|
|
65
|
+
}
|
package/src/ops.ts
CHANGED
|
@@ -182,6 +182,38 @@ export function set<
|
|
|
182
182
|
return { ...addressOf(agg), ...targetFieldOf(agg, field), field, kind: "set", value };
|
|
183
183
|
}
|
|
184
184
|
|
|
185
|
+
/**
|
|
186
|
+
* `clear(handle, "field")` — the EXPLICIT-CLEAR write (`architecture/null_semantics.md`) on a
|
|
187
|
+
* `t.clearable(...)` (or `t.nullable(...)`) field: it writes json `null`, lowering to a plain `Set`
|
|
188
|
+
* of `Value::Json(null)` (no new wire op — the kernel already stores a json null fine; the DSL null
|
|
189
|
+
* semantic is what makes it LAWFUL). NEVER hand-write `.set("field", null)` for this — `clear` names
|
|
190
|
+
* the intent and the encode-path guard admits its null because the target field declared it clearable.
|
|
191
|
+
* REFUSES at author time (and the type level collapses the field name) if the target field is NOT
|
|
192
|
+
* null-admitting.
|
|
193
|
+
*/
|
|
194
|
+
export function clear<
|
|
195
|
+
A extends Target,
|
|
196
|
+
K extends {
|
|
197
|
+
[P in keyof Fields<A>]: Fields<A>[P] extends { readonly isClearable: true }
|
|
198
|
+
? P
|
|
199
|
+
: Fields<A>[P] extends { readonly nullSemantic: "nullable" }
|
|
200
|
+
? P
|
|
201
|
+
: never;
|
|
202
|
+
}[keyof Fields<A>] &
|
|
203
|
+
string,
|
|
204
|
+
>(agg: A, field: K): FieldOp {
|
|
205
|
+
const f = (agg as { fields?: Record<string, Field> }).fields?.[field] as
|
|
206
|
+
| { isClearable?: true; nullSemantic?: string }
|
|
207
|
+
| undefined;
|
|
208
|
+
if (f !== undefined && f.isClearable !== true && f.nullSemantic !== "nullable") {
|
|
209
|
+
throw new Error(
|
|
210
|
+
`clear('${field}'): '${field}' is not a clearable/nullable field — declare it t.clearable(...) ` +
|
|
211
|
+
`(explicit clear) or t.nullable(..., { reason }) (null is a real value) before clearing it.`,
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
return { ...addressOf(agg), ...targetFieldOf(agg, field), field, kind: "set", value: null };
|
|
215
|
+
}
|
|
216
|
+
|
|
185
217
|
/** Return the same field op with an explicit per-aggregate event marker. */
|
|
186
218
|
export function withMarker(op: FieldOp, marker: PlannedEventMarker): FieldOp {
|
|
187
219
|
return { ...op, marker };
|