@githolon/dsl 0.54.2 → 0.55.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/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 +74 -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
|
@@ -26,6 +26,9 @@ library;
|
|
|
26
26
|
export 'src/wire.dart';
|
|
27
27
|
export 'src/driver.dart';
|
|
28
28
|
export 'src/offer.dart';
|
|
29
|
+
// THE TRI-STATE SLOT — NomosSlot<T> (Omitted / ExplicitNull / Present) for `nullable`/`clearable`
|
|
30
|
+
// declared fields (architecture/null_semantics.md); generated code references it, never redefines it.
|
|
31
|
+
export 'src/null_slot.dart';
|
|
29
32
|
// homeNameOf(subject) — the canonical home-workspace name (parity with @githolon/client + the law side).
|
|
30
33
|
export 'src/home.dart';
|
|
31
34
|
export 'src/subscriptions.dart';
|
|
@@ -0,0 +1,128 @@
|
|
|
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
|
|
4
|
+
// QuickJS engine} on V8 + WASI-shim, byte-identical everywhere. V8 = portability; the one
|
|
5
|
+
// wasm = determinism. No native, no wasmtime, no 2nd artifact, no domain-JS on bare V8.
|
|
6
|
+
// If a file isn't this / hosting this / authoring for this / proving this — it's gone.
|
|
7
|
+
|
|
8
|
+
/// THE TRI-STATE SLOT for a `nullable`-declared field (architecture/null_semantics.md).
|
|
9
|
+
///
|
|
10
|
+
/// JSON's one `null` collapses three DIFFERENT domain meanings the DSL names apart —
|
|
11
|
+
/// OMISSION (not supplied / inherit), EXPLICIT CLEARING/EMPTY (a chosen `null`), and a
|
|
12
|
+
/// PRESENT value. A plain `T?` cannot hold all three (its `null` is ambiguous). A field the
|
|
13
|
+
/// domain declared `t.nullable(...)` / `t.clearable(...)` generates as a `NomosSlot<T>` so the
|
|
14
|
+
/// call site MUST handle each case — the app-edge footgun (collapsing to `Object?`/`any`) is
|
|
15
|
+
/// unrepresentable.
|
|
16
|
+
///
|
|
17
|
+
/// - [NomosSlot.omitted] — the field never folded / was not supplied. On a payload it is
|
|
18
|
+
/// OMITTED from the wire JSON entirely (the plan never sees it).
|
|
19
|
+
/// - [NomosSlot.explicitNull] — an explicit domain `null` (the `explicit-empty` semantic, or a
|
|
20
|
+
/// `clearable` clear). On a payload it writes a real JSON `null`.
|
|
21
|
+
/// - [NomosSlot.of] — a present [value]. On a payload it writes the value.
|
|
22
|
+
///
|
|
23
|
+
/// This is the DECODE contract on read models too: an ABSENT key decodes to [omitted], an
|
|
24
|
+
/// explicit JSON `null` to [explicitNull], a value to `NomosSlot.of(value)` — the three are
|
|
25
|
+
/// NEVER collapsed.
|
|
26
|
+
sealed class NomosSlot<T> {
|
|
27
|
+
const NomosSlot();
|
|
28
|
+
|
|
29
|
+
/// The field was not supplied / has not folded — omitted from the wire entirely.
|
|
30
|
+
const factory NomosSlot.omitted() = _Omitted<T>;
|
|
31
|
+
|
|
32
|
+
/// An explicit domain `null` (the `explicit-empty` value, or a `clearable` clear) — writes a
|
|
33
|
+
/// real JSON `null` on the wire.
|
|
34
|
+
const factory NomosSlot.explicitNull() = _ExplicitNull<T>;
|
|
35
|
+
|
|
36
|
+
/// A present [value].
|
|
37
|
+
const factory NomosSlot.of(T value) = _Present<T>;
|
|
38
|
+
|
|
39
|
+
/// DECODE a projection/read-model json map entry into a slot: the key ABSENT ⇒ [omitted], an
|
|
40
|
+
/// explicit `null` ⇒ [explicitNull], any other value ⇒ `NomosSlot.of(decode(raw))`. The
|
|
41
|
+
/// [decode] callback turns the raw json leaf into the typed [T] (identity for a scalar; a
|
|
42
|
+
/// `VoClass.fromProjected` for a value object).
|
|
43
|
+
static NomosSlot<T> decode<T>(
|
|
44
|
+
Map<String, Object?> data,
|
|
45
|
+
String key,
|
|
46
|
+
T Function(Object value) decode,
|
|
47
|
+
) {
|
|
48
|
+
if (!data.containsKey(key)) return NomosSlot<T>.omitted();
|
|
49
|
+
final raw = data[key];
|
|
50
|
+
if (raw == null) return NomosSlot<T>.explicitNull();
|
|
51
|
+
return NomosSlot<T>.of(decode(raw));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/// True when the field carries a present value.
|
|
55
|
+
bool get isPresent => this is _Present<T>;
|
|
56
|
+
|
|
57
|
+
/// True for the explicit-`null` case (a chosen clear/empty — distinct from omission).
|
|
58
|
+
bool get isExplicitNull => this is _ExplicitNull<T>;
|
|
59
|
+
|
|
60
|
+
/// True when the field was not supplied / has not folded.
|
|
61
|
+
bool get isOmitted => this is _Omitted<T>;
|
|
62
|
+
|
|
63
|
+
/// The present value, or `null` for both omission AND explicit-null. LOSSY on purpose — use
|
|
64
|
+
/// [when] to keep the three cases distinct; reach for this only when omitted≡null is fine.
|
|
65
|
+
T? get valueOrNull => switch (this) {
|
|
66
|
+
_Present<T>(:final value) => value,
|
|
67
|
+
_ => null,
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/// Exhaustive handling of the three cases — the point of the slot (no case can be forgotten).
|
|
71
|
+
R when<R>({
|
|
72
|
+
required R Function() omitted,
|
|
73
|
+
required R Function() explicitNull,
|
|
74
|
+
required R Function(T value) present,
|
|
75
|
+
}) =>
|
|
76
|
+
switch (this) {
|
|
77
|
+
_Omitted<T>() => omitted(),
|
|
78
|
+
_ExplicitNull<T>() => explicitNull(),
|
|
79
|
+
_Present<T>(:final value) => present(value),
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/// Serialise INTO a payload json map at [key]: [omitted] writes NOTHING (the key is absent),
|
|
83
|
+
/// [explicitNull] writes a real `null`, [NomosSlot.of] writes `encode(value)`. The [encode]
|
|
84
|
+
/// callback turns the typed value into its wire json leaf (identity for a scalar; `.toJson()`
|
|
85
|
+
/// for a value object).
|
|
86
|
+
void writeTo(
|
|
87
|
+
Map<String, Object?> data,
|
|
88
|
+
String key,
|
|
89
|
+
Object? Function(T value) encode,
|
|
90
|
+
) {
|
|
91
|
+
switch (this) {
|
|
92
|
+
case _Omitted<T>():
|
|
93
|
+
break; // omitted ⇒ absent from the wire (the plan never sees the field)
|
|
94
|
+
case _ExplicitNull<T>():
|
|
95
|
+
data[key] = null;
|
|
96
|
+
case _Present<T>(:final value):
|
|
97
|
+
data[key] = encode(value);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
@override
|
|
102
|
+
bool operator ==(Object other) =>
|
|
103
|
+
other is NomosSlot<T> &&
|
|
104
|
+
other.runtimeType == runtimeType &&
|
|
105
|
+
other.valueOrNull == valueOrNull;
|
|
106
|
+
|
|
107
|
+
@override
|
|
108
|
+
int get hashCode => Object.hash(runtimeType, valueOrNull);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
final class _Omitted<T> extends NomosSlot<T> {
|
|
112
|
+
const _Omitted();
|
|
113
|
+
@override
|
|
114
|
+
String toString() => 'NomosSlot.omitted()';
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
final class _ExplicitNull<T> extends NomosSlot<T> {
|
|
118
|
+
const _ExplicitNull();
|
|
119
|
+
@override
|
|
120
|
+
String toString() => 'NomosSlot.explicitNull()';
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
final class _Present<T> extends NomosSlot<T> {
|
|
124
|
+
const _Present(this.value);
|
|
125
|
+
final T value;
|
|
126
|
+
@override
|
|
127
|
+
String toString() => 'NomosSlot.of($value)';
|
|
128
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import 'package:nomos_client/nomos_client.dart';
|
|
2
|
+
import 'package:test/test.dart';
|
|
3
|
+
|
|
4
|
+
void main() {
|
|
5
|
+
group('NomosSlot tri-state', () {
|
|
6
|
+
test('decode distinguishes omitted / explicit-null / present', () {
|
|
7
|
+
final present = NomosSlot.decode<String>({'x': 'hi'}, 'x', (v) => v as String);
|
|
8
|
+
final explicit = NomosSlot.decode<String>({'x': null}, 'x', (v) => v as String);
|
|
9
|
+
final omitted = NomosSlot.decode<String>(<String, Object?>{}, 'x', (v) => v as String);
|
|
10
|
+
|
|
11
|
+
expect(present.isPresent, isTrue);
|
|
12
|
+
expect(present.valueOrNull, 'hi');
|
|
13
|
+
expect(explicit.isExplicitNull, isTrue);
|
|
14
|
+
expect(explicit.valueOrNull, isNull);
|
|
15
|
+
expect(omitted.isOmitted, isTrue);
|
|
16
|
+
// The three NEVER collapse: explicit-null and omitted are distinct despite both valueOrNull==null.
|
|
17
|
+
expect(explicit == omitted, isFalse);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('writeTo omits, writes real null, or writes value', () {
|
|
21
|
+
final data = <String, Object?>{};
|
|
22
|
+
const NomosSlot<String>.omitted().writeTo(data, 'a', (v) => v);
|
|
23
|
+
expect(data.containsKey('a'), isFalse); // omitted ⇒ absent
|
|
24
|
+
|
|
25
|
+
const NomosSlot<String>.explicitNull().writeTo(data, 'b', (v) => v);
|
|
26
|
+
expect(data.containsKey('b'), isTrue);
|
|
27
|
+
expect(data['b'], isNull); // explicit ⇒ real json null
|
|
28
|
+
|
|
29
|
+
const NomosSlot.of('v').writeTo(data, 'c', (v) => v);
|
|
30
|
+
expect(data['c'], 'v');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('when() forces exhaustive handling', () {
|
|
34
|
+
String tag(NomosSlot<int> s) => s.when(
|
|
35
|
+
omitted: () => 'omitted',
|
|
36
|
+
explicitNull: () => 'null',
|
|
37
|
+
present: (v) => 'present:$v',
|
|
38
|
+
);
|
|
39
|
+
expect(tag(const NomosSlot.omitted()), 'omitted');
|
|
40
|
+
expect(tag(const NomosSlot.explicitNull()), 'null');
|
|
41
|
+
expect(tag(const NomosSlot.of(7)), 'present:7');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('encode callback lowers a value object on writeTo', () {
|
|
45
|
+
final data = <String, Object?>{};
|
|
46
|
+
NomosSlot.of(const {'lat': 1, 'lng': 2})
|
|
47
|
+
.writeTo(data, 'loc', (v) => v);
|
|
48
|
+
expect(data['loc'], {'lat': 1, 'lng': 2});
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@githolon/dsl",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.55.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Nomos 2 domain-authoring DSL: aggregates + directives in TS, executed and encoded to the Rust kernel's wire shapes.",
|
|
6
6
|
"license": "SEE LICENSE IN LICENSE.md",
|
package/src/authoring.ts
CHANGED
|
@@ -99,6 +99,10 @@ export interface AggregateRef<F extends Record<string, Field> = Record<string, F
|
|
|
99
99
|
key: string,
|
|
100
100
|
value: UntypedFields<F> extends true ? unknown : MapValueOf<F[K]>,
|
|
101
101
|
): this;
|
|
102
|
+
/** EXPLICIT-CLEAR a `t.clearable(...)`/`t.nullable(...)` field (`architecture/null_semantics.md`) —
|
|
103
|
+
* writes json `null` (lowered to a plain `Set`). The fluent sibling of the `clear(handle, "field")`
|
|
104
|
+
* op; never hand-write `.set("field", null)`. */
|
|
105
|
+
clear(field: string): this;
|
|
102
106
|
/** Add to a collection: a `t.hasMany` relation → writes the child's back-ref; a `t.set` → AddToSet on self. */
|
|
103
107
|
add(field: string, value: unknown): this;
|
|
104
108
|
/** Relate to another aggregate by reference (writes the ref field on THIS aggregate). */
|
|
@@ -152,6 +156,21 @@ class Ref implements AggregateRef {
|
|
|
152
156
|
this.emit(rel, "set", { value: target.__id });
|
|
153
157
|
return this;
|
|
154
158
|
}
|
|
159
|
+
clear(field: string): this {
|
|
160
|
+
// EXPLICIT-CLEAR: write json null on a clearable/nullable field. The field must declare a
|
|
161
|
+
// null-admitting semantic (the encode-path guard refuses a null on a forbid field regardless).
|
|
162
|
+
const f = (this.handle.fields as Record<string, Field>)[field] as
|
|
163
|
+
| { isClearable?: true; nullSemantic?: string }
|
|
164
|
+
| undefined;
|
|
165
|
+
if (f !== undefined && f.isClearable !== true && f.nullSemantic !== "nullable") {
|
|
166
|
+
throw new Error(
|
|
167
|
+
`.clear('${field}'): '${field}' is not a clearable/nullable field — declare it t.clearable(...) ` +
|
|
168
|
+
`or t.nullable(..., { reason }) before clearing it.`,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
this.emit(field, "set", { value: null });
|
|
172
|
+
return this;
|
|
173
|
+
}
|
|
155
174
|
add(field: string, value: unknown): this {
|
|
156
175
|
// `.add` resolves the named member across the aggregate's two member kinds: a virtual
|
|
157
176
|
// `t.hasMany` relation (write the child's back-ref) vs a stored `t.set` field (AddToSet).
|
package/src/build_package.ts
CHANGED
|
@@ -60,6 +60,7 @@ import {
|
|
|
60
60
|
synthesizeBirthQuotaInvariants,
|
|
61
61
|
} from "./birth_meter.js";
|
|
62
62
|
import { withBirthMeterExports } from "./birth_meter_ensure.js";
|
|
63
|
+
import { withClearDirectives } from "./clear_directive.js";
|
|
63
64
|
import { withAttestedReadExports, isTrustSourceDecl, type TrustSourceDecl } from "./attested.js";
|
|
64
65
|
import type { RelationTypeDecl } from "./relation_schema.js";
|
|
65
66
|
import { domainHash, emitManifestBytes } from "./manifest.js";
|
|
@@ -162,6 +163,10 @@ export function composeDomainModule(spec: DomainModuleSpec): DomainModule {
|
|
|
162
163
|
// ATTESTED READS (slice B): a foreign-read-declaring domain gains the SourceFrontier
|
|
163
164
|
// aggregate + its sanctioning ensure at the same seam (era-safe: others byte-identical).
|
|
164
165
|
merged = withAttestedReadExports(merged);
|
|
166
|
+
// EXPLICIT-CLEAR LANE (null_semantics.md): every `t.clearable(field)` gains a generated
|
|
167
|
+
// `clear<Agg><Field>` directive (mutates + one clear op; `.requires` when declared) at the same
|
|
168
|
+
// seam — manifest-visible, kernel-gated, proof-covered. Clearable-free domains are byte-identical.
|
|
169
|
+
merged = withClearDirectives(merged);
|
|
165
170
|
|
|
166
171
|
const byId = new Map<string, AggregateHandle>();
|
|
167
172
|
for (const a of [...aggregatesOf(merged), ...(spec.extraAggregates ?? [])]) byId.set(a.id, a);
|
|
@@ -0,0 +1,103 @@
|
|
|
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
|
|
4
|
+
// QuickJS engine}. If a file isn't this / hosting this / authoring for this / proving this — it's gone.
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* THE GENERATED CLEAR DIRECTIVE (architecture/null_semantics.md — the explicit-clear lane).
|
|
8
|
+
*
|
|
9
|
+
* A `t.clearable(field)` field admits an explicit-clear write (a Set of json `null`). So the framework
|
|
10
|
+
* MINTS the write lane for it: a real directive `clear<Agg><Field>` per clearable field, injected at
|
|
11
|
+
* the ONE module-merge seam every plane shares (compose / engine slice / self-describe / compile
|
|
12
|
+
* openapi) — the SAME precedent as `withBirthMeterExports` (the birth-record sanctioning ensure) and
|
|
13
|
+
* the secondary-ensure auto-declaration. Because it is a real directive, it is manifest-visible,
|
|
14
|
+
* kernel-gated, and proof gets the clear leg for free; the generated CLIENT can bind a `clearX()`
|
|
15
|
+
* method to its id.
|
|
16
|
+
*
|
|
17
|
+
* AUTHORITY IS EXPLICIT (the hard requirement): a clear is a WRITE, so a blanket ungated clear on a
|
|
18
|
+
* field whose sibling writes are `.requires`-gated would be an authz hole. `t.clearable(field, {
|
|
19
|
+
* requires })` carries the relation onto the generated directive's `.requires(relation)`; absent it,
|
|
20
|
+
* the lane is ungated BUT `law_check` WARNs when the aggregate has any `.requires`-gated directive
|
|
21
|
+
* touching it (see `law_check.ts` nullClearAuthorityFindings).
|
|
22
|
+
*
|
|
23
|
+
* ERA-SAFE: a domain with NO clearable field gets NO injection (byte-identical). A domain that USES
|
|
24
|
+
* clearable moves its hash — but it is NEW law by definition (the generated directive's
|
|
25
|
+
* `requiresRelationGate`/marker is real, certified law). Existing keys are never overwritten.
|
|
26
|
+
*/
|
|
27
|
+
import { z } from "zod";
|
|
28
|
+
import { directive, type Directive, type RequirableDirective } from "./directive.js";
|
|
29
|
+
import { instance, type AggregateHandle } from "./aggregate.js";
|
|
30
|
+
import { clear } from "./ops.js";
|
|
31
|
+
import type { Field } from "./fields.js";
|
|
32
|
+
|
|
33
|
+
/** Capitalize the first character (the clear-directive id joins `clear` + AggId + Field). */
|
|
34
|
+
function cap(s: string): string {
|
|
35
|
+
return s.length === 0 ? s : s[0]!.toUpperCase() + s.slice(1);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* THE ID CONVENTION (tell the codegen agent — `clearX()` client methods bind to this): a clear
|
|
40
|
+
* directive is `clear${aggregateId}${Capitalized(field)}`. Aggregate ids are PascalCase, so `Screen`
|
|
41
|
+
* + `icon` → `clearScreenIcon`.
|
|
42
|
+
*/
|
|
43
|
+
export function clearDirectiveId(aggregateId: string, field: string): string {
|
|
44
|
+
return `clear${aggregateId}${cap(field)}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Build the generated clear directive for one `(aggregate, clearable field)`: marker `mutates(Agg)`,
|
|
49
|
+
* payload `{ id, clearedAt }` (the timestamp doctrine — `clearedAt` is auto-stamped, so the plan
|
|
50
|
+
* needn't read it), plan = the one `clear` op. `.requires(relation)` iff the field declared
|
|
51
|
+
* `clearRequires`.
|
|
52
|
+
*/
|
|
53
|
+
export function makeClearDirective(agg: AggregateHandle, field: string, requires?: string): Directive<unknown> {
|
|
54
|
+
const built = directive(clearDirectiveId(agg.id, field))
|
|
55
|
+
.mutates(agg)
|
|
56
|
+
.payload(z.object({ id: z.string().min(1), clearedAt: z.string() }))
|
|
57
|
+
.plan((p: { id: string; clearedAt: string }) => [clear(instance(agg, p.id), field as never)])
|
|
58
|
+
.doc(
|
|
59
|
+
`Explicitly clears the '${field}' field of a ${agg.id} (writes an explicit null — the declared clearable lane).`,
|
|
60
|
+
) as unknown as RequirableDirective<unknown>;
|
|
61
|
+
return requires !== undefined ? built.requires(requires) : built;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The clearable `(field, requires?)` entries of one aggregate handle. */
|
|
65
|
+
function clearableFieldsOf(agg: AggregateHandle): { field: string; requires?: string }[] {
|
|
66
|
+
const out: { field: string; requires?: string }[] = [];
|
|
67
|
+
for (const [name, f0] of Object.entries(agg.fields as Record<string, Field>)) {
|
|
68
|
+
if (f0.isClearable === true) out.push({ field: name, ...(f0.clearRequires !== undefined ? { requires: f0.clearRequires } : {}) });
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Inject a generated {@link makeClearDirective} into a merged module-exports bag for EVERY clearable
|
|
75
|
+
* field on any aggregate in the bag. A clearable-free bag is returned UNTOUCHED (the era rule). An
|
|
76
|
+
* existing directive with the same id (a tenant hand-authored clear) is left in place — never
|
|
77
|
+
* overwritten (the `withBirthMeterExports` courtesy).
|
|
78
|
+
*/
|
|
79
|
+
export function withClearDirectives<M extends Record<string, unknown>>(mod: M): M {
|
|
80
|
+
const isAgg = (v: unknown): v is AggregateHandle =>
|
|
81
|
+
v !== null && typeof v === "object" && (v as { __isAggregateHandle?: boolean }).__isAggregateHandle === true;
|
|
82
|
+
const aggs = Object.values(mod).filter(isAgg);
|
|
83
|
+
if (aggs.length === 0) return mod;
|
|
84
|
+
|
|
85
|
+
// The directive ids already present (tenant-authored) — never re-mint over one.
|
|
86
|
+
const existingIds = new Set<string>();
|
|
87
|
+
for (const v of Object.values(mod)) {
|
|
88
|
+
if (v !== null && typeof v === "object" && typeof (v as Directive<unknown>).plan === "function" && typeof (v as Directive<unknown>).id === "string") {
|
|
89
|
+
existingIds.add((v as Directive<unknown>).id);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const additions: Record<string, unknown> = {};
|
|
94
|
+
for (const agg of aggs) {
|
|
95
|
+
for (const { field, requires } of clearableFieldsOf(agg)) {
|
|
96
|
+
const id = clearDirectiveId(agg.id, field);
|
|
97
|
+
if (existingIds.has(id) || id in mod) continue;
|
|
98
|
+
additions[id] = makeClearDirective(agg, field, requires);
|
|
99
|
+
existingIds.add(id);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return Object.keys(additions).length === 0 ? mod : ({ ...mod, ...additions } as M);
|
|
103
|
+
}
|