@githolon/dsl 0.53.0 → 0.54.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/compat_package.mjs +46 -0
- package/dart/lib/src/webview_bridge.dart +160 -13
- package/dart/pubspec.yaml +1 -1
- package/dart/test/co2_acceptance_test.dart +102 -0
- package/dart/test/compat_test.dart +61 -0
- package/dart/test/vo_read_typing_test.dart +113 -0
- package/package.json +2 -1
- package/src/authoring.ts +77 -17
- package/src/codegen_dart.ts +94 -12
- package/src/codegen_dart_client.ts +18 -1
- package/src/codegen_openapi.ts +1 -1
- package/src/codegen_proof.ts +113 -16
- package/src/codegen_ts.ts +87 -12
- package/src/compat_package_main.ts +187 -0
- package/src/compile_package_main.ts +11 -1
- package/src/fields.ts +55 -9
- package/src/index.ts +14 -0
- package/src/law_check.ts +132 -1
- package/src/ops.ts +25 -6
- package/src/path.ts +115 -0
- package/src/query.ts +18 -9
- package/src/shape_compat.ts +46 -0
- package/src/spatial.ts +8 -4
- package/src/value_object.ts +119 -18
- package/src/wire_encode.ts +57 -9
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 { Field } from "./fields.js";
|
|
33
|
+
import type { Field, ValueObjectDescriptor, 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";
|
|
@@ -526,6 +526,136 @@ function encryptionGroupingFindings(mod: DomainModule, aggById: ReadonlyMap<stri
|
|
|
526
526
|
return out;
|
|
527
527
|
}
|
|
528
528
|
|
|
529
|
+
// ── TWIN 5 (#30) — DOTTED QUERY KEY resolves in the target VO's descriptor tree ─────
|
|
530
|
+
// A query keyed on a nested path (`location.postalAddress.postalCode`) indexes a string leaf
|
|
531
|
+
// inside a value-object field. The projection walks that path at fold time (kernel-native, era-
|
|
532
|
+
// ruled); if the path names a subfield the VO shape does NOT have, the query would just never
|
|
533
|
+
// match — a silent dead index. So we refuse it at COMPILE, naming the valid dotted leaf paths.
|
|
534
|
+
// PAYLOAD-INDEPENDENT (a pure declaration-vs-shape mismatch) ⇒ a REFUSE, not a warn.
|
|
535
|
+
|
|
536
|
+
/** The scalar VO leaf kinds a dotted key may terminate on (the projection stores STRING keys, so
|
|
537
|
+
* only these produce an index row; an `object`/`array`/`map`/`geometry` is not a queryable leaf). */
|
|
538
|
+
function isScalarLeafDescriptor(d: ValueObjectFieldDescriptor): boolean {
|
|
539
|
+
return d.type === "string" || d.type === "number" || d.type === "boolean" || d.type === "enum" || d.type === "literal";
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/** Enumerate every dotted leaf path reachable through `object`/`geometry` refs under one VO
|
|
543
|
+
* descriptor, `prefix`-rooted. Arrays/maps are not name-addressable, so they are not descended. */
|
|
544
|
+
function voLeafPaths(
|
|
545
|
+
desc: ValueObjectDescriptor,
|
|
546
|
+
byName: ReadonlyMap<string, ValueObjectDescriptor>,
|
|
547
|
+
prefix: string,
|
|
548
|
+
seen: ReadonlySet<string>,
|
|
549
|
+
): string[] {
|
|
550
|
+
if (desc.fields === undefined) return []; // a geo/union VO exposes no named structural subfields here
|
|
551
|
+
if (seen.has(desc.name)) return []; // cycle guard (a VO transitively nesting itself)
|
|
552
|
+
const nextSeen = new Set(seen).add(desc.name);
|
|
553
|
+
const out: string[] = [];
|
|
554
|
+
for (const [name, f] of Object.entries(desc.fields)) {
|
|
555
|
+
const path = `${prefix}.${name}`;
|
|
556
|
+
if (isScalarLeafDescriptor(f)) {
|
|
557
|
+
out.push(path);
|
|
558
|
+
} else if ((f.type === "object" || f.type === "geometry") && byName.has(f.ref)) {
|
|
559
|
+
out.push(...voLeafPaths(byName.get(f.ref)!, byName, path, nextSeen));
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
return out;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/** Resolve a dotted path (segments AFTER the lead VO field) through a VO descriptor tree. Returns
|
|
566
|
+
* `"leaf"` if it lands on a scalar leaf, `"nonleaf"` if it resolves but to a non-scalar node (an
|
|
567
|
+
* object/array/map — not indexable), or `undefined` if a segment is absent. */
|
|
568
|
+
function resolveVoPath(
|
|
569
|
+
rootDesc: ValueObjectDescriptor,
|
|
570
|
+
byName: ReadonlyMap<string, ValueObjectDescriptor>,
|
|
571
|
+
segments: readonly string[],
|
|
572
|
+
): "leaf" | "nonleaf" | undefined {
|
|
573
|
+
let desc: ValueObjectDescriptor | undefined = rootDesc;
|
|
574
|
+
for (let i = 0; i < segments.length; i++) {
|
|
575
|
+
const seg = segments[i]!;
|
|
576
|
+
const f = desc?.fields?.[seg];
|
|
577
|
+
if (f === undefined) return undefined;
|
|
578
|
+
const isLast = i === segments.length - 1;
|
|
579
|
+
if (isLast) return isScalarLeafDescriptor(f) ? "leaf" : "nonleaf";
|
|
580
|
+
// A non-terminal segment must descend into a nested VO by ref.
|
|
581
|
+
if ((f.type === "object" || f.type === "geometry") && byName.has(f.ref)) {
|
|
582
|
+
desc = byName.get(f.ref);
|
|
583
|
+
} else {
|
|
584
|
+
return undefined; // a scalar/array/map mid-path can't be descended by name
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
return undefined;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function dottedQueryKeyFindings(mod: DomainModule, aggById: ReadonlyMap<string, AggregateHandle>): Finding[] {
|
|
591
|
+
const domain = mod.domain ?? mod.name;
|
|
592
|
+
const out: Finding[] = [];
|
|
593
|
+
|
|
594
|
+
// ONE dotted-key checker, shared by query `.key` and count/sum/min/max/exists `.by` (both index
|
|
595
|
+
// or group BY a nested VO leaf and both silently mismatch on a bad path). `noun`/`verb` name the
|
|
596
|
+
// read shape in the message; `aggId` is the target aggregate type; `key` is the (dotted) field.
|
|
597
|
+
const checkDotted = (noun: string, readId: string, verb: string, aggId: string, key: string): void => {
|
|
598
|
+
if (!key.includes(".")) return; // a flat key is judged by kind routing at the gate, not here
|
|
599
|
+
const [lead, ...rest] = key.split(".");
|
|
600
|
+
const agg = aggById.get(aggId);
|
|
601
|
+
if (agg === undefined) return; // foreign/framework target — the runtime gate arbitrates
|
|
602
|
+
const field = agg.fields[lead!] as Field | undefined;
|
|
603
|
+
const vo = field?.valueObject;
|
|
604
|
+
if (field === undefined || vo === undefined) {
|
|
605
|
+
const voFields = Object.entries(agg.fields)
|
|
606
|
+
.filter(([, f]) => (f as Field).valueObject !== undefined)
|
|
607
|
+
.map(([n]) => n)
|
|
608
|
+
.sort();
|
|
609
|
+
out.push({
|
|
610
|
+
domain,
|
|
611
|
+
directiveId: readId,
|
|
612
|
+
level: "refuse",
|
|
613
|
+
what: `${noun} '${readId}' ${verb} the dotted path '${key}', but '${lead}' is ${field === undefined ? `not a field of '${aggId}'` : `not a value-object field on '${aggId}' (a dotted key descends a VO leaf)`}`,
|
|
614
|
+
fix: `${verb} a value-object field's subfield — VO fields on '${aggId}': ${voFields.length ? voFields.join(", ") : "(none)"} — or use a flat top-level key`,
|
|
615
|
+
});
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
const byName = new Map<string, ValueObjectDescriptor>();
|
|
619
|
+
byName.set(vo.descriptor.name, vo.descriptor);
|
|
620
|
+
for (const d of vo.dependencies) byName.set(d.name, d);
|
|
621
|
+
const verdict = resolveVoPath(vo.descriptor, byName, rest);
|
|
622
|
+
if (verdict === "leaf") return; // resolves to an indexable scalar leaf — good
|
|
623
|
+
const valid = voLeafPaths(vo.descriptor, byName, lead!, new Set()).sort();
|
|
624
|
+
out.push({
|
|
625
|
+
domain,
|
|
626
|
+
directiveId: readId,
|
|
627
|
+
level: "refuse",
|
|
628
|
+
what: `${noun} '${readId}' ${verb} '${key}', which ${verdict === "nonleaf" ? `resolves to a non-scalar node in the '${vo.name}' value object (only a scalar leaf is indexable)` : `does not resolve in the '${vo.name}' value object's shape`}`,
|
|
629
|
+
fix: `${verb} a scalar subfield — valid dotted paths under '${lead}': ${valid.length ? valid.join(", ") : "(none)"}`,
|
|
630
|
+
});
|
|
631
|
+
};
|
|
632
|
+
|
|
633
|
+
for (const q of mod.queries ?? []) for (const k of q.key) checkDotted("query", q.id, "is keyed on", q.returns, k);
|
|
634
|
+
// count/sum/min/max/exists `.by` GROUP-BY a nested VO leaf (now maintained on both snapshot sides,
|
|
635
|
+
// #30 follow-up) — a bad dotted path would silently group nothing, so validate it here too.
|
|
636
|
+
for (const c of mod.counts ?? []) {
|
|
637
|
+
const d = finishCount(c);
|
|
638
|
+
if (d.by !== undefined) checkDotted("count", d.id, "groups by", d.of, d.by);
|
|
639
|
+
}
|
|
640
|
+
for (const s of mod.sums ?? []) {
|
|
641
|
+
const d = finishSum(s);
|
|
642
|
+
if (d.by !== undefined) checkDotted("sum", d.id, "groups by", d.of, d.by);
|
|
643
|
+
}
|
|
644
|
+
for (const m of mod.mins ?? []) {
|
|
645
|
+
const d = finishExtremum(m);
|
|
646
|
+
if (d.by !== undefined) checkDotted("min", d.id, "groups by", d.of, d.by);
|
|
647
|
+
}
|
|
648
|
+
for (const m of mod.maxes ?? []) {
|
|
649
|
+
const d = finishExtremum(m);
|
|
650
|
+
if (d.by !== undefined) checkDotted("max", d.id, "groups by", d.of, d.by);
|
|
651
|
+
}
|
|
652
|
+
for (const e of mod.exists_ ?? []) {
|
|
653
|
+
const d = finishExists(e);
|
|
654
|
+
if (d.by !== undefined) checkDotted("exists", d.id, "groups by", d.of, d.by);
|
|
655
|
+
}
|
|
656
|
+
return out;
|
|
657
|
+
}
|
|
658
|
+
|
|
529
659
|
// ── TWIN 3 (slice 2) — INVARIANT SAMPLE-PROBE (aggregate + workspace invariants) ────
|
|
530
660
|
// An aggregate invariant (`aggregate(id, fields, { invariant })`) runs on every touching event; a
|
|
531
661
|
// workspaceInvariant(id).on(dir).reads(...).assert(...) runs at the gate. We probe each like a plan:
|
|
@@ -731,6 +861,7 @@ export function checkDomainModules(
|
|
|
731
861
|
// Slice-2/slice-3 twins over the whole module: invariant sample-probes + encryption grouping.
|
|
732
862
|
findings.push(...invariantFindings(mod, aggById));
|
|
733
863
|
findings.push(...encryptionGroupingFindings(mod, aggById));
|
|
864
|
+
findings.push(...dottedQueryKeyFindings(mod, aggById));
|
|
734
865
|
}
|
|
735
866
|
|
|
736
867
|
// The BIRTH-RECIPE twin (unify): fold the scattered `.births()` recipe diagnostics into the
|
package/src/ops.ts
CHANGED
|
@@ -44,6 +44,16 @@ export interface FieldOp {
|
|
|
44
44
|
*/
|
|
45
45
|
readonly marker?: PlannedEventMarker;
|
|
46
46
|
readonly field: string;
|
|
47
|
+
/**
|
|
48
|
+
* The op TARGET aggregate's declared {@link Field} for `field`, captured from the fluent handle
|
|
49
|
+
* (`instance(Agg,id)` / `existing(Agg,id)` / `ensure(Agg,id)` / a bare handle) at op-construction —
|
|
50
|
+
* the ONE point that KNOWS the target's schema. Wire encoding resolves the field kind + merge driver
|
|
51
|
+
* from THIS (the op's real target), never from the directive's dispatch/primary aggregate: a plan that
|
|
52
|
+
* fans a VO/json (or a RemoveWins/LastPosition) write out to a SIBLING aggregate would otherwise lose
|
|
53
|
+
* its field metadata and mis-encode (a VO object throwing "kernel scalars are Str|Int only"). Internal,
|
|
54
|
+
* transient (never wire-serialized) — so carrying the Field object costs no hash and crosses no boundary.
|
|
55
|
+
*/
|
|
56
|
+
readonly targetField?: Field;
|
|
47
57
|
readonly kind: "set" | "addToSet" | "setEntry" | "increment" | "decrement" | "removeFromSet";
|
|
48
58
|
readonly value?: unknown;
|
|
49
59
|
readonly items?: readonly string[];
|
|
@@ -104,7 +114,7 @@ type Fields<A> = A extends Target<string, infer F> ? F : never;
|
|
|
104
114
|
* PrincipalEvidence conversion — and ONLY that one, so writing un-endorsed identity (or the wrong
|
|
105
115
|
* endorsement) into an authority/standing sink does not typecheck. The read/wire type is unchanged.
|
|
106
116
|
*/
|
|
107
|
-
type AcceptedValue<F> = F extends { readonly facet: "authority" }
|
|
117
|
+
export type AcceptedValue<F> = F extends { readonly facet: "authority" }
|
|
108
118
|
? FieldValue<F> | EndorsedAuthority<FieldValue<F>>
|
|
109
119
|
: F extends { readonly facet: "standing" }
|
|
110
120
|
? FieldValue<F> | EndorsedStanding<FieldValue<F>>
|
|
@@ -121,6 +131,14 @@ function addressOf(agg: Target): { aggregateId: string; aggregateType?: string }
|
|
|
121
131
|
return { aggregateId: (agg as AggregateHandle).id };
|
|
122
132
|
}
|
|
123
133
|
|
|
134
|
+
/** Capture the target aggregate's declared Field for `field` (see {@link FieldOp.targetField}). Both
|
|
135
|
+
* an `AggregateHandle` and a `BoundAggregate` carry `.fields`, so a fanned-out sibling op keeps its
|
|
136
|
+
* target's schema. Returns `{}` (no key) when the field is unknown — byte-identical to before. */
|
|
137
|
+
function targetFieldOf(agg: Target, field: string): { targetField?: Field } {
|
|
138
|
+
const f = (agg as { fields?: Record<string, Field> }).fields?.[field];
|
|
139
|
+
return f ? { targetField: f } : {};
|
|
140
|
+
}
|
|
141
|
+
|
|
124
142
|
/**
|
|
125
143
|
* Set a scalar (or map-whole/ref) field. Field name + value type are both checked.
|
|
126
144
|
*
|
|
@@ -154,13 +172,14 @@ export function set<
|
|
|
154
172
|
if (endorsement) {
|
|
155
173
|
return {
|
|
156
174
|
...addressOf(agg),
|
|
175
|
+
...targetFieldOf(agg, field),
|
|
157
176
|
field,
|
|
158
177
|
kind: "set",
|
|
159
178
|
value: endorsement.principal,
|
|
160
179
|
endorsement: { facet: endorsement.facet, reason: endorsement.reason },
|
|
161
180
|
};
|
|
162
181
|
}
|
|
163
|
-
return { ...addressOf(agg), field, kind: "set", value };
|
|
182
|
+
return { ...addressOf(agg), ...targetFieldOf(agg, field), field, kind: "set", value };
|
|
164
183
|
}
|
|
165
184
|
|
|
166
185
|
/** Return the same field op with an explicit per-aggregate event marker. */
|
|
@@ -185,7 +204,7 @@ export function addToSet<
|
|
|
185
204
|
}[keyof Fields<A>] &
|
|
186
205
|
string,
|
|
187
206
|
>(agg: A, field: K, items: readonly string[]): FieldOp {
|
|
188
|
-
return { ...addressOf(agg), field, kind: "addToSet", items };
|
|
207
|
+
return { ...addressOf(agg), ...targetFieldOf(agg, field), field, kind: "addToSet", items };
|
|
189
208
|
}
|
|
190
209
|
|
|
191
210
|
/**
|
|
@@ -203,7 +222,7 @@ function counterDelta<A extends Target, K extends CounterKey<A>>(
|
|
|
203
222
|
if (!Number.isInteger(amount) || amount < 0) {
|
|
204
223
|
throw new Error(`${kind}('${String(field)}', ${amount}): amount must be a non-negative integer.`);
|
|
205
224
|
}
|
|
206
|
-
return { ...addressOf(agg), field: field as string, kind, value: amount };
|
|
225
|
+
return { ...addressOf(agg), ...targetFieldOf(agg, field as string), field: field as string, kind, value: amount };
|
|
207
226
|
}
|
|
208
227
|
/** A counter field key — `kind:"counter"` only (an `int` is NOT a counter; that would not converge). */
|
|
209
228
|
type CounterKey<A> = {
|
|
@@ -234,7 +253,7 @@ export function removeFromSet<
|
|
|
234
253
|
}[keyof Fields<A>] &
|
|
235
254
|
string,
|
|
236
255
|
>(agg: A, field: K, items: readonly string[]): FieldOp {
|
|
237
|
-
return { ...addressOf(agg), field, kind: "removeFromSet", items };
|
|
256
|
+
return { ...addressOf(agg), ...targetFieldOf(agg, field), field, kind: "removeFromSet", items };
|
|
238
257
|
}
|
|
239
258
|
|
|
240
259
|
/** Set one entry of a map field. The map's value type is checked. */
|
|
@@ -250,7 +269,7 @@ export function setEntry<
|
|
|
250
269
|
key: string,
|
|
251
270
|
value: FieldValue<Fields<A>[K]> extends Record<string, infer V> ? V : never,
|
|
252
271
|
): FieldOp {
|
|
253
|
-
return { ...addressOf(agg), field, kind: "setEntry", entryKey: key, value };
|
|
272
|
+
return { ...addressOf(agg), ...targetFieldOf(agg, field), field, kind: "setEntry", entryKey: key, value };
|
|
254
273
|
}
|
|
255
274
|
|
|
256
275
|
export type { Field };
|
package/src/path.ts
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
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
|
+
/**
|
|
9
|
+
* TYPED PATH HANDLES (#29) — a type-safe way to spell a projection field PATH (a top-level
|
|
10
|
+
* field OR a dotted path into a value object's subfields) as a compile-checked reference
|
|
11
|
+
* instead of a bare string.
|
|
12
|
+
*
|
|
13
|
+
* spatial("sitesByBounds").of(EstateSite).on(path(EstateSite).location.point)
|
|
14
|
+
* query("sitesByCity").key(path(EstateSite).location.address.city).returns(EstateSite)
|
|
15
|
+
*
|
|
16
|
+
* `path(EstateSite)` returns a typed PROXY whose members are the aggregate's declared fields;
|
|
17
|
+
* descending through a value-object field (`.location.point`) is checked against the VO's TS
|
|
18
|
+
* shape at every hop — a typo'd subfield is a COMPILE error. The resulting {@link PathRef}
|
|
19
|
+
* COMPILES TO THE DOTTED STRING (`"location.point"`), so it is PURE SUGAR: `.on(path(...).x.y)`
|
|
20
|
+
* and `.on("x.y")` produce byte-identical declarations (hash-equal — the era-safety contract).
|
|
21
|
+
*
|
|
22
|
+
* ACCEPTANCE: {@link spatial}'s `.on(...)` and {@link query}'s `.key(...)` accept a `PathRef`
|
|
23
|
+
* anywhere they accept the string spelling, and coerce it to the string at build via
|
|
24
|
+
* {@link pathString}. Nothing else in the pipeline sees a `PathRef` — it collapses to a string
|
|
25
|
+
* the moment a declaration is built.
|
|
26
|
+
*/
|
|
27
|
+
import type { AggregateHandle } from "./aggregate.js";
|
|
28
|
+
import type { Field, FieldValue } from "./fields.js";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A typed reference to a projection field PATH — the dotted string it denotes rides `.path`,
|
|
32
|
+
* and it stringifies to that same path (so `String(ref)` / a template literal / `.toString()`
|
|
33
|
+
* all yield `"location.point"`). `Leaf` carries the TS type of the value AT the path (for future
|
|
34
|
+
* leaf-kind constraints, e.g. spatial wanting a geometry); v1 accepts any leaf.
|
|
35
|
+
*/
|
|
36
|
+
export interface PathRef<Leaf = unknown> {
|
|
37
|
+
readonly __isPathRef: true;
|
|
38
|
+
/** The dotted path string this handle denotes (e.g. `"location.point"`). */
|
|
39
|
+
readonly path: string;
|
|
40
|
+
toString(): string;
|
|
41
|
+
/** Phantom to keep `Leaf` load-bearing for inference. */
|
|
42
|
+
readonly _leaf?: Leaf;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** A plain (indexable) OBJECT value — the only leaf kind you may descend INTO. Arrays and
|
|
46
|
+
* functions are terminals (you index an array element by nothing meaningful in a field path). */
|
|
47
|
+
type IsPlainObject<V> = V extends readonly unknown[]
|
|
48
|
+
? false
|
|
49
|
+
: // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
50
|
+
V extends (...args: any[]) => any
|
|
51
|
+
? false
|
|
52
|
+
: V extends object
|
|
53
|
+
? true
|
|
54
|
+
: false;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* A node in a typed path: it IS a {@link PathRef} for the value at this position (so you may
|
|
58
|
+
* STOP here and pass it to `.on(...)`/`.key(...)`), AND — when the value is a plain object —
|
|
59
|
+
* exposes each of its subfields as a further node (so you may DESCEND). `NonNullable` unwraps
|
|
60
|
+
* an optional VO field so `.location.point` descends through `location?: SiteLocation`.
|
|
61
|
+
*/
|
|
62
|
+
export type PathNode<V> = PathRef<V> &
|
|
63
|
+
(IsPlainObject<NonNullable<V>> extends true
|
|
64
|
+
? { readonly [K in keyof NonNullable<V> & string]-?: PathNode<NonNullable<NonNullable<V>[K]>> }
|
|
65
|
+
: unknown);
|
|
66
|
+
|
|
67
|
+
/** The ROOT of a typed path: the aggregate's declared fields, each a descendable {@link PathNode}. */
|
|
68
|
+
export type PathRoot<F extends Record<string, Field>> = {
|
|
69
|
+
readonly [K in keyof F & string]-?: PathNode<FieldValue<F[K]>>;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** Recognize a {@link PathRef} at runtime (so `.on(...)`/`.key(...)` can coerce it). */
|
|
73
|
+
export function isPathRef(v: unknown): v is PathRef {
|
|
74
|
+
return typeof v === "object" && v !== null && (v as { __isPathRef?: boolean }).__isPathRef === true;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Coerce a field spelling — a plain string OR a {@link PathRef} — to its dotted-string form.
|
|
78
|
+
* This is the ONE point a typed path collapses to the string the rest of the pipeline sees. */
|
|
79
|
+
export function pathString(field: string | PathRef): string {
|
|
80
|
+
return isPathRef(field) ? field.path : field;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The proxy node: carries its accumulated dotted path, deepens on any non-reserved property
|
|
84
|
+
* access, and stringifies to the path. Reserved keys (`path`/`toString`/the brand) resolve on
|
|
85
|
+
* the base so a subfield can never be named after them without ambiguity being caught here. */
|
|
86
|
+
function makePathNode(path: string): PathRef {
|
|
87
|
+
const base = {
|
|
88
|
+
__isPathRef: true as const,
|
|
89
|
+
path,
|
|
90
|
+
toString(): string {
|
|
91
|
+
return path;
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
return new Proxy(base, {
|
|
95
|
+
get(target, prop, receiver) {
|
|
96
|
+
if (typeof prop === "symbol" || prop in target) {
|
|
97
|
+
return Reflect.get(target, prop, receiver);
|
|
98
|
+
}
|
|
99
|
+
const next = path === "" ? String(prop) : `${path}.${String(prop)}`;
|
|
100
|
+
return makePathNode(next);
|
|
101
|
+
},
|
|
102
|
+
}) as PathRef;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Begin a typed field PATH rooted at an aggregate. `path(EstateSite).location.point` descends the
|
|
107
|
+
* declared fields + value-object shapes (checked by tsc at every hop) and denotes the dotted string
|
|
108
|
+
* `"location.point"`. Pure sugar — pass the result to {@link spatial}'s `.on(...)` or {@link query}'s
|
|
109
|
+
* `.key(...)`, which coerce it to the identical string a hand-written spelling would produce.
|
|
110
|
+
*/
|
|
111
|
+
export function path<Id extends string, F extends Record<string, Field>>(
|
|
112
|
+
_aggregate: AggregateHandle<Id, F>,
|
|
113
|
+
): PathRoot<F> {
|
|
114
|
+
return makePathNode("") as unknown as PathRoot<F>;
|
|
115
|
+
}
|
package/src/query.ts
CHANGED
|
@@ -29,6 +29,12 @@
|
|
|
29
29
|
*/
|
|
30
30
|
import type { AggregateHandle } from "./aggregate.js";
|
|
31
31
|
import type { Field } from "./fields.js";
|
|
32
|
+
import { pathString, type PathRef } from "./path.js";
|
|
33
|
+
|
|
34
|
+
/** A key argument accepted by `.key(...)`: a plain (dotted) string OR a typed {@link PathRef}
|
|
35
|
+
* (`path(Agg).location.point`). A PathRef is self-validating at construction, so it collapses to
|
|
36
|
+
* its dotted string here and is exempt from the string-literal compatibility check below. */
|
|
37
|
+
type KeyArg = string | PathRef;
|
|
32
38
|
|
|
33
39
|
type StringKeyOf<T> = Extract<keyof T, string>;
|
|
34
40
|
|
|
@@ -47,12 +53,12 @@ export type QueryableFieldPath<F extends Record<string, Field>> =
|
|
|
47
53
|
| `${JsonFieldKeys<F>}.${string}`;
|
|
48
54
|
|
|
49
55
|
type QueryKeyCompatibility<
|
|
50
|
-
K extends readonly
|
|
56
|
+
K extends readonly KeyArg[],
|
|
51
57
|
F extends Record<string, Field>,
|
|
52
|
-
> = Exclude<K[number], QueryableFieldPath<F>> extends never
|
|
58
|
+
> = Exclude<Extract<K[number], string>, QueryableFieldPath<F>> extends never
|
|
53
59
|
? unknown
|
|
54
60
|
: {
|
|
55
|
-
readonly __query_key_error__: `query key '${Exclude<K[number], QueryableFieldPath<F>>}' is not a field on returned aggregate`;
|
|
61
|
+
readonly __query_key_error__: `query key '${Exclude<Extract<K[number], string>, QueryableFieldPath<F>>}' is not a field on returned aggregate`;
|
|
56
62
|
};
|
|
57
63
|
|
|
58
64
|
/**
|
|
@@ -107,9 +113,10 @@ export function hasManyIndexes(agg: AggregateHandle): QueryDecl[] {
|
|
|
107
113
|
* fixes the returned aggregate type and yields the finished {@link QueryDecl}) is
|
|
108
114
|
* available. This is the ONLY shape carrying `.returns` — see {@link KeylessQuery}.
|
|
109
115
|
*/
|
|
110
|
-
export interface Query<K extends readonly
|
|
116
|
+
export interface Query<K extends readonly KeyArg[] = readonly KeyArg[]> {
|
|
111
117
|
readonly id: string;
|
|
112
|
-
|
|
118
|
+
/** The index key fields, coerced to their dotted-string form (a {@link PathRef} collapses to `.path`). */
|
|
119
|
+
readonly key: readonly string[];
|
|
113
120
|
/**
|
|
114
121
|
* Fix the aggregate TYPE this query returns, producing the finished declaration.
|
|
115
122
|
* Takes a typed HANDLE (never the string id) — a typo'd handle is a compile
|
|
@@ -133,7 +140,7 @@ export interface KeylessQuery {
|
|
|
133
140
|
* over. Order is significant (index column order) and is PRESERVED, never sorted.
|
|
134
141
|
* Returns the registerable {@link Query} (the only shape exposing `.returns`).
|
|
135
142
|
*/
|
|
136
|
-
key<const K extends readonly [
|
|
143
|
+
key<const K extends readonly [KeyArg, ...KeyArg[]]>(...fields: K): Query<K>;
|
|
137
144
|
}
|
|
138
145
|
|
|
139
146
|
/**
|
|
@@ -144,9 +151,11 @@ export interface KeylessQuery {
|
|
|
144
151
|
export function query<const Id extends string>(id: Id): KeylessQuery {
|
|
145
152
|
return {
|
|
146
153
|
id,
|
|
147
|
-
key<const K extends readonly [
|
|
148
|
-
// Preserve declared order — this list is the index's column order, not a set.
|
|
149
|
-
|
|
154
|
+
key<const K extends readonly [KeyArg, ...KeyArg[]]>(...fields: K): Query<K> {
|
|
155
|
+
// Preserve declared order — this list is the index's column order, not a set. A typed
|
|
156
|
+
// PathRef (`path(Agg).location.point`) collapses to its dotted string here, so the rest of
|
|
157
|
+
// the pipeline only ever sees strings (era-safe: byte-identical to the hand-written spelling).
|
|
158
|
+
const keyFields: readonly string[] = fields.map((f) => pathString(f as KeyArg));
|
|
150
159
|
return {
|
|
151
160
|
id,
|
|
152
161
|
key: keyFields,
|
package/src/shape_compat.ts
CHANGED
|
@@ -179,6 +179,52 @@ export function domainInterface(mod: DomainModule, stableIds?: StableIds): Domai
|
|
|
179
179
|
};
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
+
/** One member's structural verdict — `ok`, plus the exact break `reason` when not. */
|
|
183
|
+
export interface MemberVerdict {
|
|
184
|
+
readonly ok: boolean;
|
|
185
|
+
readonly reason?: string;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** PER-MEMBER structural verdict (#72b, the compat superpower): the SAME subtype relation as
|
|
189
|
+
* {@link compatible}, decomposed to ONE verdict per callable MEMBER — keyed by directive id
|
|
190
|
+
* (writes) AND by aggregate label (reads). A generated client method (or the CLI gate) consults
|
|
191
|
+
* its own member so a KNOWN-incompatible call refuses CLIENT-SIDE, never as a server 422. */
|
|
192
|
+
export function memberVerdicts(client: DomainInterface, workspace: DomainInterface): Record<string, MemberVerdict> {
|
|
193
|
+
const out: Record<string, MemberVerdict> = {};
|
|
194
|
+
|
|
195
|
+
// WRITES — contravariant, one verdict per directive the client calls.
|
|
196
|
+
for (const [did, cShape] of Object.entries(client.directives)) {
|
|
197
|
+
const wShape = workspace.directives[did];
|
|
198
|
+
if (!wShape) { out[did] = { ok: false, reason: `directive '${did}' is not installed on the workspace` }; continue; }
|
|
199
|
+
const reasons: string[] = [];
|
|
200
|
+
for (const [f, wf] of Object.entries(wShape.fields)) {
|
|
201
|
+
if (wf.optional) continue;
|
|
202
|
+
const cf = cShape.fields[f];
|
|
203
|
+
if (!cf || cf.optional) reasons.push(`workspace requires field '${f}' the client does not always send`);
|
|
204
|
+
}
|
|
205
|
+
for (const [f, cf] of Object.entries(cShape.fields)) {
|
|
206
|
+
const wf = wShape.fields[f];
|
|
207
|
+
if (wf && wf.kind !== cf.kind) reasons.push(`field '${f}' kind ${cf.kind} ≠ workspace ${wf.kind}`);
|
|
208
|
+
}
|
|
209
|
+
out[did] = reasons.length ? { ok: false, reason: reasons.join("; ") } : { ok: true };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// READS — covariant by stable id, one verdict per aggregate the client reads (keyed by LABEL).
|
|
213
|
+
for (const [sid, cAgg] of Object.entries(client.aggregates)) {
|
|
214
|
+
const wAgg = workspace.aggregates[sid];
|
|
215
|
+
if (!wAgg) { out[cAgg.label] = { ok: false, reason: `aggregate '${cAgg.label}' is not served by the workspace` }; continue; }
|
|
216
|
+
const reasons: string[] = [];
|
|
217
|
+
for (const [fsid, cf] of Object.entries(cAgg.fields)) {
|
|
218
|
+
const wf = wAgg.fields[fsid];
|
|
219
|
+
if (!wf) reasons.push(`field '${cAgg.label}.${cf.label}' is not served`);
|
|
220
|
+
else if (wf.kind !== cf.kind) reasons.push(`field '${cAgg.label}.${cf.label}' read kind ${cf.kind} ≠ workspace ${wf.kind}`);
|
|
221
|
+
}
|
|
222
|
+
out[cAgg.label] = reasons.length ? { ok: false, reason: reasons.join("; ") } : { ok: true };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return out;
|
|
226
|
+
}
|
|
227
|
+
|
|
182
228
|
/** Decide whether a CLIENT interface is satisfied by a WORKSPACE interface (the offer).
|
|
183
229
|
* Pure + deterministic — the SAME relation runs at connect() and (mirrored) at the
|
|
184
230
|
* editor/CI. Names the exact directive / aggregate / field at fault, never a hash. */
|
package/src/spatial.ts
CHANGED
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
*/
|
|
37
37
|
import type { AggregateHandle } from "./aggregate.js";
|
|
38
38
|
import type { Field } from "./fields.js";
|
|
39
|
+
import { pathString, type PathRef } from "./path.js";
|
|
39
40
|
|
|
40
41
|
type StringKeyOf<T> = Extract<keyof T, string>;
|
|
41
42
|
|
|
@@ -51,7 +52,8 @@ export interface SpatialDecl {
|
|
|
51
52
|
/**
|
|
52
53
|
* The aggregate's GeoJSON GEOMETRY field name — the SOURCE of the bounding box the
|
|
53
54
|
* R\*Tree indexes (e.g. `geoPosition`). The engine reads this field per aggregate,
|
|
54
|
-
* computes its `[
|
|
55
|
+
* computes its axis-neutral `[minX,minY,maxX,maxY]` bbox (the geographic case being
|
|
56
|
+
* `[minLng,minLat,maxLng,maxLat]`), and upserts the R\*Tree row.
|
|
55
57
|
*/
|
|
56
58
|
readonly on: string;
|
|
57
59
|
}
|
|
@@ -79,7 +81,7 @@ export interface OfSpatial<F extends Record<string, Field> = Record<string, Fiel
|
|
|
79
81
|
* must be a declared field; when it is a declared VO, the sub-path is descriptor-checked
|
|
80
82
|
* at build to land on an RFC 7946 geometry.
|
|
81
83
|
*/
|
|
82
|
-
on(field: StringKeyOf<F> | `${StringKeyOf<F>}.${string}`): SpatialDecl;
|
|
84
|
+
on(field: StringKeyOf<F> | `${StringKeyOf<F>}.${string}` | PathRef): SpatialDecl;
|
|
83
85
|
}
|
|
84
86
|
|
|
85
87
|
/**
|
|
@@ -112,8 +114,10 @@ export function spatial<const Id extends string>(id: Id): TypelessSpatial {
|
|
|
112
114
|
return {
|
|
113
115
|
id,
|
|
114
116
|
of: aggregate.id,
|
|
115
|
-
on(field: StringKeyOf<F>): SpatialDecl {
|
|
116
|
-
|
|
117
|
+
on(field: StringKeyOf<F> | `${StringKeyOf<F>}.${string}` | PathRef): SpatialDecl {
|
|
118
|
+
// A typed PathRef (`path(Agg).location.point`) collapses to its dotted string here — pure
|
|
119
|
+
// sugar, byte-identical to the hand-written spelling (the era-safety contract).
|
|
120
|
+
return { id, of: aggregate.id, on: pathString(field) };
|
|
117
121
|
},
|
|
118
122
|
};
|
|
119
123
|
},
|