@githolon/dsl 0.52.0 → 0.54.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/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 string[],
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 string[] = readonly string[]> {
116
+ export interface Query<K extends readonly KeyArg[] = readonly KeyArg[]> {
111
117
  readonly id: string;
112
- readonly key: readonly [...K];
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 [string, ...string[]]>(...fields: K): Query<K>;
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 [string, ...string[]]>(...fields: K): Query<K> {
148
- // Preserve declared order — this list is the index's column order, not a set.
149
- const keyFields = [...fields] as unknown as readonly [...K];
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,
@@ -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 `[minLng,minLat,maxLng,maxLat]` bbox, and upserts the R\*Tree row.
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
- return { id, of: aggregate.id, on: field };
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
  },