@githolon/dsl 0.62.0 → 0.64.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/package.json +1 -1
- package/src/aggregate.ts +21 -0
- package/src/codegen_ts.ts +135 -10
- package/src/fields.ts +87 -5
- package/src/usd.ts +30 -0
- package/src/usd_layers.ts +14 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@githolon/dsl",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.64.0",
|
|
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/aggregate.ts
CHANGED
|
@@ -243,6 +243,27 @@ export function aggregate<const Id extends string, F extends Record<string, Fiel
|
|
|
243
243
|
opts?: { invariant?: AggregateInvariantFn; invariantDeclassify?: string; cap?: number; visibility?: ReadVisibility; global?: boolean },
|
|
244
244
|
): AggregateHandle<Id, F> & AudienceMethods<Id, F> {
|
|
245
245
|
const { stored, hasMany } = partitionFields(fields);
|
|
246
|
+
// FOREIGN-REF SIBLING CHECK (pain point #4): a `t.foreignRef({ workspace, target })` field names a
|
|
247
|
+
// SIBLING string field (`workspaceField`) that holds the foreign workspace id — the target workspace is
|
|
248
|
+
// DYNAMIC, read from that sibling at resolve time. That link is STRINGLY-TYPED (a bare field name), so a
|
|
249
|
+
// typo would silently produce a locator pointing at a non-existent workspace field. Catch it HERE, at
|
|
250
|
+
// compile: the named sibling must exist on THIS aggregate and be a plain string-kind field (it holds a
|
|
251
|
+
// workspace id). It must be a real string field — not itself a foreignRef/ref — since it carries the raw id.
|
|
252
|
+
for (const [name, field] of Object.entries(stored)) {
|
|
253
|
+
const fref = (field as Field).foreignRef;
|
|
254
|
+
if (fref === undefined) continue;
|
|
255
|
+
const sibling = stored[fref.workspaceField] as Field | undefined;
|
|
256
|
+
if (sibling === undefined) {
|
|
257
|
+
throw new Error(
|
|
258
|
+
`aggregate '${id}': field '${name}' is a t.foreignRef whose workspace field '${fref.workspaceField}' does not exist on aggregate '${id}' — add a \`${fref.workspaceField}: t.string()\` field to aggregate '${id}', or fix the \`workspace:\` name in the foreignRef`,
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
if (sibling.kind !== "string") {
|
|
262
|
+
throw new Error(
|
|
263
|
+
`aggregate '${id}': field '${name}' is a t.foreignRef whose workspace field '${fref.workspaceField}' is kind '${sibling.kind}', but it must be a plain string-kind field (it holds the foreign workspace id) — declare \`${fref.workspaceField}: t.string()\``,
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
246
267
|
if (opts?.cap !== undefined && (!Number.isInteger(opts.cap) || opts.cap < 1)) {
|
|
247
268
|
throw new Error(`aggregate '${id}': cap must be a positive integer (got ${opts.cap})`);
|
|
248
269
|
}
|
package/src/codegen_ts.ts
CHANGED
|
@@ -496,8 +496,12 @@ function tsBaseTypeOfField(field: Field): string {
|
|
|
496
496
|
} catch {
|
|
497
497
|
return "string";
|
|
498
498
|
}
|
|
499
|
-
case "ref":
|
|
500
|
-
|
|
499
|
+
case "ref": {
|
|
500
|
+
// A `t.foreignRef` field surfaces as a TYPED cross-workspace locator (hydrated by the accessors from
|
|
501
|
+
// the raw id + the declared workspace field); a plain same-workspace `t.ref` stays the id string.
|
|
502
|
+
const fr = (field as { foreignRef?: { targetAggregateId: string } }).foreignRef;
|
|
503
|
+
return fr !== undefined ? `ForeignRef<${fr.targetAggregateId}Data>` : "string";
|
|
504
|
+
}
|
|
501
505
|
case "json":
|
|
502
506
|
// A NAMED VALUE OBJECT field reads as its generated interface (`value_object.ts`);
|
|
503
507
|
// a bare `t.jsonObject()` stays the anonymous object; a plain `t.json()` is unknown.
|
|
@@ -720,6 +724,76 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
|
|
|
720
724
|
: []),
|
|
721
725
|
`/** One projection row: the aggregate's wire type, its id, and the folded fields. */`,
|
|
722
726
|
`export interface Row<T> { type: string; id: string; data: T }`,
|
|
727
|
+
// MULTI-WORKSPACE LOCATORS (t.foreignRef): a TYPED cross-workspace edge — the foreign aggregate's id +
|
|
728
|
+
// the foreign workspace id (from a sibling field). NOT an atomic FK: it does not clone/open the other
|
|
729
|
+
// holon; `resolve`/`watch` open it ON DEMAND over a realm (openRealm's `.workspace(ws)` surface).
|
|
730
|
+
// The reader `resolve`/`watch`/`attest` need: the multi-workspace `.workspace(ws)` surface `openRealm()`
|
|
731
|
+
// returns (a plain `connect()` holon is single-workspace — pass a Realm). `attestForeign` is the attested-
|
|
732
|
+
// read extension (present only when your law declares `trustSource` + `.readsFrom`). ANY object with this
|
|
733
|
+
// shape works — the generated client is import-free and binds structurally to your @githolon/client.
|
|
734
|
+
`export interface NomosForeignReader { workspace(ws: string): { queryById(id: string): Promise<{ type: string; id: string; data: unknown }[]>; watchById?(id: string, cb: (rows: { type: string; id: string; data: unknown }[]) => void, opts?: { intervalMs?: number }): () => void }; attestForeign?(workspace: string, id: string, type: string): Promise<{ id: string; workspace: string; exists: boolean; attestation: string }>; }`,
|
|
735
|
+
// resolve() collapses "not there" to undefined for the 99% lookup; resolveResult() names WHY — a foreign
|
|
736
|
+
// read can come back empty because the target does not exist, you lack read access, or the holon is
|
|
737
|
+
// unreachable, and those are very different. `denied`/`unreachable` are best-effort from the reader's
|
|
738
|
+
// error (an error whose message mentions auth/permission/forbidden ⇒ denied; else unreachable).
|
|
739
|
+
`export type ForeignResolveResult<TData> = { status: "found"; row: Row<TData> } | { status: "not-found" } | { status: "denied"; error: unknown } | { status: "unreachable"; error: unknown };`,
|
|
740
|
+
// A ForeignRef is a TYPED CROSS-WORKSPACE LOCATOR, not an atomic FK. In ~99% of cases you use it as a
|
|
741
|
+
// lookup — `resolve`/`watch` open the foreign holon on demand so you can do more reads. `exists` is the
|
|
742
|
+
// cheap "is the target still there?" check. `attest` is the LAW-GRADE one — signed evidence that the
|
|
743
|
+
// reference resolves — and only works when your law DECLARES the dependency (`trustSource` + a
|
|
744
|
+
// `.readsFrom(...)` on the directive); it is deliberately NOT a free method, because attesting has cost
|
|
745
|
+
// and law meaning. Resolving/existence are informational; attestation is what you reach for when the
|
|
746
|
+
// current workspace's LAW depends on the foreign fact.
|
|
747
|
+
`export interface ForeignRef<TData> {`,
|
|
748
|
+
` /** The foreign aggregate's id (this field's stored value). */`,
|
|
749
|
+
` readonly id: string;`,
|
|
750
|
+
` /** The foreign workspace id (from the declared sibling workspace field). */`,
|
|
751
|
+
` readonly workspace: string;`,
|
|
752
|
+
` /** The foreign aggregate type this locator points at. */`,
|
|
753
|
+
` readonly type: string;`,
|
|
754
|
+
` /** THE LOOKUP (the 99% case): open the foreign workspace + read the target by id — on demand, no clone.`,
|
|
755
|
+
` * Returns undefined for ANY empty outcome; use resolveResult() when you need to know WHY. */`,
|
|
756
|
+
` resolve(nomos: NomosForeignReader): Promise<Row<TData> | undefined>;`,
|
|
757
|
+
` /** The lookup with the OUTCOME named — found / not-found / denied / unreachable (a foreign read can be`,
|
|
758
|
+
` * empty for very different reasons; do not collapse them when it matters). */`,
|
|
759
|
+
` resolveResult(nomos: NomosForeignReader): Promise<ForeignResolveResult<TData>>;`,
|
|
760
|
+
` /** Subscribe to the target's read model across the workspace boundary (returns an unsubscribe fn). */`,
|
|
761
|
+
` watch(nomos: NomosForeignReader, cb: (row: Row<TData> | undefined) => void, opts?: { intervalMs?: number }): () => void;`,
|
|
762
|
+
` /** Cheap existence check — does the referenced target still exist? (found ⇒ true; anything else ⇒ false). */`,
|
|
763
|
+
` exists(nomos: NomosForeignReader): Promise<boolean>;`,
|
|
764
|
+
` /** LAW-GRADE: signed evidence that the reference resolves, for when your law DEPENDS on the foreign fact.`,
|
|
765
|
+
` * Requires the attested-read lane — declare \`trustSource(workspace, ...)\` + \`.readsFrom(...)\` on the`,
|
|
766
|
+
` * directive; without it (no \`attestForeign\` on the reader) this throws, by design (a locator is not an FK). */`,
|
|
767
|
+
` attest(nomos: NomosForeignReader): Promise<{ id: string; workspace: string; exists: boolean; attestation: string }>;`,
|
|
768
|
+
`}`,
|
|
769
|
+
`/** Build a typed foreign-ref locator from a row's id + workspace-id values. The generated \`<domain>ForeignRefs\` factory calls this with the declared fields. */`,
|
|
770
|
+
`export function foreignRef<TData>(type: string, id: string, workspace: string): ForeignRef<TData> {`,
|
|
771
|
+
` return {`,
|
|
772
|
+
` id, workspace, type,`,
|
|
773
|
+
` async resolveResult(nomos) {`,
|
|
774
|
+
` if (!id || !workspace) return { status: "not-found" };`,
|
|
775
|
+
` try {`,
|
|
776
|
+
` const rows = await nomos.workspace(workspace).queryById(id);`,
|
|
777
|
+
` const row = (rows.find((r) => r.id === id) ?? rows[0]) as Row<TData> | undefined;`,
|
|
778
|
+
` return row !== undefined ? { status: "found", row } : { status: "not-found" };`,
|
|
779
|
+
` } catch (error) {`,
|
|
780
|
+
` const m = String((error as { message?: unknown })?.message ?? error).toLowerCase();`,
|
|
781
|
+
` return { status: /forbid|denied|permission|unauthor|not permitted|403|401/.test(m) ? "denied" : "unreachable", error };`,
|
|
782
|
+
` }`,
|
|
783
|
+
` },`,
|
|
784
|
+
` async resolve(nomos) { const r = await this.resolveResult(nomos); return r.status === "found" ? r.row : undefined; },`,
|
|
785
|
+
` watch(nomos, cb, opts) {`,
|
|
786
|
+
` const w = nomos.workspace(workspace).watchById;`,
|
|
787
|
+
` if (!w || !id || !workspace) { cb(undefined); return () => {}; }`,
|
|
788
|
+
` return w(id, (rows) => cb((rows.find((r) => r.id === id) ?? rows[0]) as Row<TData> | undefined), opts);`,
|
|
789
|
+
` },`,
|
|
790
|
+
` async exists(nomos) { return (await this.resolveResult(nomos)).status === "found"; },`,
|
|
791
|
+
` async attest(nomos) {`,
|
|
792
|
+
` if (!nomos.attestForeign) throw new Error("attest() needs the attested-read lane — declare trustSource(" + JSON.stringify(workspace) + ", …) + .readsFrom(…) on the directive whose law depends on this reference (a foreignRef is a locator, not an atomic foreign key)");`,
|
|
793
|
+
` return nomos.attestForeign(workspace, id, type);`,
|
|
794
|
+
` },`,
|
|
795
|
+
` };`,
|
|
796
|
+
`}`,
|
|
723
797
|
``,
|
|
724
798
|
`/** Arbitrary JSON — the type a RECURSIVE \`z.lazy\` payload subtree degrades to (a self-`,
|
|
725
799
|
` * referencing tree/JSON schema can't be a finite named literal, so the engine validates it`,
|
|
@@ -1145,6 +1219,11 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
|
|
|
1145
1219
|
}
|
|
1146
1220
|
|
|
1147
1221
|
out.push(`// ── read models (projection \`data\` shapes; partial-fold optional, EXCEPT .fromBirth()`, `// fields — their presence is gate law, so the type is non-optional) ──`, ``);
|
|
1222
|
+
const foreignRefFields: { agg: string; field: string; workspaceField: string; target: string }[] = [];
|
|
1223
|
+
// wrap a read accessor return expression through the foreignRef row-hydrator (identity + byte-identical
|
|
1224
|
+
// when the package declares no foreignRef, so non-foreignRef packages are unchanged).
|
|
1225
|
+
const wrapP = (expr: string): string => (foreignRefFields.length > 0 ? `_hydrateP(${expr})` : expr);
|
|
1226
|
+
const wrapRows = (expr: string): string => (foreignRefFields.length > 0 ? `_hydrateRows(${expr})` : expr);
|
|
1148
1227
|
for (const [typeId, agg] of [...aggByType].sort(([a], [b]) => a.localeCompare(b))) {
|
|
1149
1228
|
out.push(`export interface ${typeId}Data {`);
|
|
1150
1229
|
for (const [name, field] of Object.entries(agg.fields)) {
|
|
@@ -1152,10 +1231,54 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
|
|
|
1152
1231
|
const doc = nullDocLine(field);
|
|
1153
1232
|
if (doc !== undefined) out.push(` /** ${doc} */`);
|
|
1154
1233
|
out.push(` ${name}${required ? "" : "?"}: ${tsTypeOfField(field as Field)};`);
|
|
1234
|
+
const fr = (field as { foreignRef?: { workspaceField: string; targetAggregateId: string } }).foreignRef;
|
|
1235
|
+
if (fr !== undefined) foreignRefFields.push({ agg: typeId, field: name, workspaceField: fr.workspaceField, target: fr.targetAggregateId });
|
|
1155
1236
|
}
|
|
1156
1237
|
for (const extra of extrasByType.get(typeId) ?? []) out.push(extra);
|
|
1157
1238
|
out.push(`}`, ``);
|
|
1158
1239
|
}
|
|
1240
|
+
// MULTI-WORKSPACE LOCATOR FACTORY (t.foreignRef): per aggregate, a typed builder that turns a projection
|
|
1241
|
+
// row into a `ForeignRef<TargetData>` — reading the foreign id (the field) + the foreign workspace id (the
|
|
1242
|
+
// declared sibling field). The read model stays honest (the field is the raw stored id string); this gives
|
|
1243
|
+
// the TYPED locator with resolve/watch on top, without cloning the other holon.
|
|
1244
|
+
if (foreignRefFields.length > 0) {
|
|
1245
|
+
const byAgg = new Map<string, typeof foreignRefFields>();
|
|
1246
|
+
for (const f of foreignRefFields) { const l = byAgg.get(f.agg) ?? []; l.push(f); byAgg.set(f.agg, l); }
|
|
1247
|
+
out.push(`// ── foreign-ref locators (t.foreignRef): typed cross-workspace edges (resolve/watch on demand) ──`);
|
|
1248
|
+
out.push(`export const ${camel(opts.packageName)}ForeignRefs = {`);
|
|
1249
|
+
for (const [agg, fields] of [...byAgg].sort(([a], [b]) => a.localeCompare(b))) {
|
|
1250
|
+
out.push(` ${agg}: {`);
|
|
1251
|
+
for (const f of fields) {
|
|
1252
|
+
out.push(
|
|
1253
|
+
` /** \`${agg}.${f.field}\` → \`${f.target}\` in the workspace named by \`${f.workspaceField}\`. */`,
|
|
1254
|
+
` ${f.field}: (row: ${agg}Data): ForeignRef<${f.target}Data> => foreignRef(${JSON.stringify(f.target)}, String(row.${f.field} ?? ""), String((row as Record<string, unknown>)[${JSON.stringify(f.workspaceField)}] ?? "")),`,
|
|
1255
|
+
);
|
|
1256
|
+
}
|
|
1257
|
+
out.push(` },`);
|
|
1258
|
+
}
|
|
1259
|
+
out.push(`} as const;`, ``);
|
|
1260
|
+
// ROW HYDRATION: the read model surfaces a foreignRef field as a typed `ForeignRef` locator (so
|
|
1261
|
+
// `row.data.<field>.resolve(nomos)` works directly). The projection stores the raw id string; the read
|
|
1262
|
+
// accessors pipe rows through `_hydrateRows`, which — for aggregates that declare foreignRefs — replaces
|
|
1263
|
+
// each foreignRef field with a locator built from its id + the declared sibling workspace field.
|
|
1264
|
+
// `row.data.<field>.id` is still the raw id. Identity for any aggregate without foreignRefs.
|
|
1265
|
+
const hydrateSpecs: Record<string, { field: string; wsField: string; target: string }[]> = {};
|
|
1266
|
+
for (const f of foreignRefFields) { (hydrateSpecs[f.agg] ??= []).push({ field: f.field, wsField: f.workspaceField, target: f.target }); }
|
|
1267
|
+
out.push(
|
|
1268
|
+
`const _FOREIGN_REFS: Record<string, { field: string; wsField: string; target: string }[]> = ${JSON.stringify(hydrateSpecs)};`,
|
|
1269
|
+
`function _hydrateRows<T>(rows: Row<T>[]): Row<T>[] {`,
|
|
1270
|
+
` return rows.map((row) => {`,
|
|
1271
|
+
` const specs = _FOREIGN_REFS[row.type];`,
|
|
1272
|
+
` if (specs === undefined || row.data == null) return row;`,
|
|
1273
|
+
` const data: Record<string, unknown> = { ...(row.data as Record<string, unknown>) };`,
|
|
1274
|
+
` for (const s of specs) { const v = data[s.field]; if (typeof v === "string") data[s.field] = foreignRef(s.target, v, String(data[s.wsField] ?? "")); }`,
|
|
1275
|
+
` return { ...row, data: data as T };`,
|
|
1276
|
+
` });`,
|
|
1277
|
+
`}`,
|
|
1278
|
+
`const _hydrateP = <T>(p: Promise<Row<T>[]>): Promise<Row<T>[]> => p.then(_hydrateRows);`,
|
|
1279
|
+
``,
|
|
1280
|
+
);
|
|
1281
|
+
}
|
|
1159
1282
|
|
|
1160
1283
|
// ── DERIVED BIRTH LANES (sharding §10 RATIFIED, slice 1) ──
|
|
1161
1284
|
// A `workspaceType(...).hasMany(...)` taxonomy DERIVES typed birth helpers over
|
|
@@ -1494,7 +1617,7 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
|
|
|
1494
1617
|
for (const sp of (mod.spatials ?? []) as { id: string; of: string; on: string }[]) {
|
|
1495
1618
|
methods.push(
|
|
1496
1619
|
` /** Declared spatial index \`${sp.id}\` — rows of \`${sp.of}\` whose \`${sp.on}\` geometry intersects the bbox (R*Tree probe). */`,
|
|
1497
|
-
` ${lcFirst(camel(sp.id))}: (bbox: { minX: number; minY: number; maxX: number; maxY: number } | { minLng: number; minLat: number; maxLng: number; maxLat: number }) => (holon.spatialWithin ?? (() => { throw new Error("this holon predates spatial reads — upgrade @githolon/client"); }))(${JSON.stringify(sp.id)}, bbox) as Promise<Row<${sp.of}Data>[]
|
|
1620
|
+
` ${lcFirst(camel(sp.id))}: (bbox: { minX: number; minY: number; maxX: number; maxY: number } | { minLng: number; minLat: number; maxLng: number; maxLat: number }) => ${wrapP(`(holon.spatialWithin ?? (() => { throw new Error("this holon predates spatial reads — upgrade @githolon/client"); }))(${JSON.stringify(sp.id)}, bbox) as Promise<Row<${sp.of}Data>[]>`)},`,
|
|
1498
1621
|
);
|
|
1499
1622
|
}
|
|
1500
1623
|
for (const q of (mod.queries ?? []) as QueryDecl[]) {
|
|
@@ -1511,8 +1634,8 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
|
|
|
1511
1634
|
methods.push(
|
|
1512
1635
|
` /** Declared query \`${q.id}\` (indexed probe — routed by the read manifest).${qScopes.length ? " Encrypted field(s) decrypt when the bound { keyStore } holds the scope key; else they stay opaque envelopes." : ""} */`,
|
|
1513
1636
|
qScopes.length
|
|
1514
|
-
? ` ${lcFirst(camel(q.id))}: async (params: { ${params} }) => holon.query(${JSON.stringify(q.id)}, params, await _readKeys(keyStore, ${JSON.stringify(qScopes)})) as Promise<Row<${q.returns}Data>[]
|
|
1515
|
-
: ` ${lcFirst(camel(q.id))}: (params: { ${params} }) => holon.query(${JSON.stringify(q.id)}, params) as Promise<Row<${q.returns}Data>[]
|
|
1637
|
+
? ` ${lcFirst(camel(q.id))}: async (params: { ${params} }) => ${wrapP(`holon.query(${JSON.stringify(q.id)}, params, await _readKeys(keyStore, ${JSON.stringify(qScopes)})) as Promise<Row<${q.returns}Data>[]>`)},`
|
|
1638
|
+
: ` ${lcFirst(camel(q.id))}: (params: { ${params} }) => ${wrapP(`holon.query(${JSON.stringify(q.id)}, params) as Promise<Row<${q.returns}Data>[]>`)},`,
|
|
1516
1639
|
);
|
|
1517
1640
|
// THE LOGICAL PAGED ROW SURFACE (sharding §5.3, ruling 2 — slice 4): emitted
|
|
1518
1641
|
// ONLY for packed-taxonomy domains (taxonomy-free clients stay byte-identical).
|
|
@@ -1553,15 +1676,17 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
|
|
|
1553
1676
|
methods.push(
|
|
1554
1677
|
` /** Read one ${agg.id} by aggregate id.${aScopes.length ? " Encrypted field(s) decrypt when the bound { keyStore } holds the scope key." : ""} */`,
|
|
1555
1678
|
aScopes.length
|
|
1556
|
-
? ` ${lcFirst(camel(agg.id))}ById: async (id: string) => holon.queryById(id, await _readKeys(keyStore, ${JSON.stringify(aScopes)})) as Promise<Row<${agg.id}Data>[]
|
|
1557
|
-
: ` ${lcFirst(camel(agg.id))}ById: (id: string) => holon.queryById(id) as Promise<Row<${agg.id}Data>[]
|
|
1679
|
+
? ` ${lcFirst(camel(agg.id))}ById: async (id: string) => ${wrapP(`holon.queryById(id, await _readKeys(keyStore, ${JSON.stringify(aScopes)})) as Promise<Row<${agg.id}Data>[]>`)},`
|
|
1680
|
+
: ` ${lcFirst(camel(agg.id))}ById: (id: string) => ${wrapP(`holon.queryById(id) as Promise<Row<${agg.id}Data>[]>`)},`,
|
|
1558
1681
|
` /** Every ${agg.id} (the type-index probe — O(result), not a scan). */`,
|
|
1559
1682
|
aScopes.length
|
|
1560
|
-
? ` list${agg.id}s: async () => holon.query("aggregatesByType", { __type: ${JSON.stringify(agg.id)} }, await _readKeys(keyStore, ${JSON.stringify(aScopes)})) as Promise<Row<${agg.id}Data>[]
|
|
1561
|
-
: ` list${agg.id}s: () => holon.query("aggregatesByType", { __type: ${JSON.stringify(agg.id)} }) as Promise<Row<${agg.id}Data>[]
|
|
1683
|
+
? ` list${agg.id}s: async () => ${wrapP(`holon.query("aggregatesByType", { __type: ${JSON.stringify(agg.id)} }, await _readKeys(keyStore, ${JSON.stringify(aScopes)})) as Promise<Row<${agg.id}Data>[]>`)},`
|
|
1684
|
+
: ` list${agg.id}s: () => ${wrapP(`holon.query("aggregatesByType", { __type: ${JSON.stringify(agg.id)} }) as Promise<Row<${agg.id}Data>[]>`)},`,
|
|
1562
1685
|
` /** Watch one ${agg.id} by aggregate id (local reactive read; returns stop()). */`,
|
|
1563
1686
|
` watch${agg.id}ById: (id: string, cb: (rows: Row<${agg.id}Data>[]) => void, opts?: { intervalMs?: number }) =>`,
|
|
1564
|
-
|
|
1687
|
+
foreignRefFields.length > 0
|
|
1688
|
+
? ` holon.watchById(id, ((rows: Row<${agg.id}Data>[]) => cb(_hydrateRows(rows))) as (rows: unknown) => void, opts),`
|
|
1689
|
+
: ` holon.watchById(id, cb as (rows: unknown) => void, opts),`,
|
|
1565
1690
|
);
|
|
1566
1691
|
}
|
|
1567
1692
|
|
package/src/fields.ts
CHANGED
|
@@ -268,6 +268,21 @@ export interface Field<T = unknown, K extends FieldKind = FieldKind> {
|
|
|
268
268
|
readonly clearRequires?: string;
|
|
269
269
|
/** For `t.ref` cross-workspace edges: the foreign workspace id, if any. */
|
|
270
270
|
readonly refWorkspace?: string;
|
|
271
|
+
/**
|
|
272
|
+
* `t.foreignRef({ workspace, target })` — a TYPED CROSS-WORKSPACE LOCATOR (not an atomic FK). Stored as
|
|
273
|
+
* the foreign aggregate's id STRING (kind `"ref"`, zero kernel change); this meta names WHERE the foreign
|
|
274
|
+
* workspace id lives (`workspaceField` — a sibling field on THIS aggregate, so the target workspace is
|
|
275
|
+
* DYNAMIC) + the TARGET aggregate/domain (typed codegen). The read model surfaces it as a typed locator
|
|
276
|
+
* (`ForeignRef<TargetData>` = `{ id, workspace }`), NEVER auto-cloning the other holon; the generated
|
|
277
|
+
* client's `resolve(nomos)`/`watch(nomos)` open the foreign workspace on demand. Law that DEPENDS on the
|
|
278
|
+
* foreign fact opts into attested evidence separately (`trustSource`/`.readsFrom`). Present ⇒ codegen emits
|
|
279
|
+
* the locator; absent ⇒ a plain `t.ref`. Additive IR (`nomosForeignRefs`), era-safe.
|
|
280
|
+
*/
|
|
281
|
+
readonly foreignRef?: {
|
|
282
|
+
readonly workspaceField: string;
|
|
283
|
+
readonly targetAggregateId: string;
|
|
284
|
+
readonly targetDomain?: string;
|
|
285
|
+
};
|
|
271
286
|
/** For `t.ref` (the target) AND `t.hasMany` (the child type): the related aggregate's wire id. */
|
|
272
287
|
readonly refAggregateId?: string;
|
|
273
288
|
/** For `t.hasMany(Child).via(backref)`: the back-reference field NAME on the child. Virtual. */
|
|
@@ -396,7 +411,14 @@ function makeField<T, K extends FieldKind = FieldKind>(
|
|
|
396
411
|
driver: Driver,
|
|
397
412
|
extra: {
|
|
398
413
|
refWorkspace?: string;
|
|
414
|
+
foreignRef?: { workspaceField: string; targetAggregateId: string; targetDomain?: string };
|
|
399
415
|
refAggregateId?: string;
|
|
416
|
+
/** LAZY target resolution — `t.ref(() => Agg)` / `t.hasMany(() => Agg)`. Breaks a BIDIRECTIONAL
|
|
417
|
+
* declaration cycle (a Parent's `hasMany(Child)` ⇄ a Child's `ref(Parent)`): the thunk is stored and
|
|
418
|
+
* resolved on FIRST read of `refAggregateId` (at manifest build — after all module consts init), so
|
|
419
|
+
* neither side needs the other to already exist at its own `const`. Consumers read `refAggregateId`
|
|
420
|
+
* transparently (a memoized getter). */
|
|
421
|
+
refAggregateIdThunk?: () => string;
|
|
400
422
|
viaField?: string;
|
|
401
423
|
isOptional?: boolean;
|
|
402
424
|
fromBirth?: true;
|
|
@@ -422,6 +444,7 @@ function makeField<T, K extends FieldKind = FieldKind>(
|
|
|
422
444
|
...(extra.facet !== undefined ? { facet: extra.facet } : {}),
|
|
423
445
|
...(extra.refWorkspace !== undefined ? { refWorkspace: extra.refWorkspace } : {}),
|
|
424
446
|
...(extra.refAggregateId !== undefined ? { refAggregateId: extra.refAggregateId } : {}),
|
|
447
|
+
...(extra.foreignRef !== undefined ? { foreignRef: extra.foreignRef } : {}),
|
|
425
448
|
...(extra.viaField !== undefined ? { viaField: extra.viaField } : {}),
|
|
426
449
|
...(extra.mapValueKind !== undefined ? { mapValueKind: extra.mapValueKind } : {}),
|
|
427
450
|
...(extra.jsonShape !== undefined ? { jsonShape: extra.jsonShape } : {}),
|
|
@@ -496,6 +519,21 @@ function makeField<T, K extends FieldKind = FieldKind>(
|
|
|
496
519
|
return makeField<T, K>(kind, zod, driver, { ...extra, facet }) as Field<T, K> & { readonly facet: Fa };
|
|
497
520
|
},
|
|
498
521
|
};
|
|
522
|
+
// LAZY REF TARGET: when the target was given as a thunk (`t.ref(() => Agg)` / `t.hasMany(() => Agg)` — to
|
|
523
|
+
// break a bidirectional declaration cycle), expose `refAggregateId` as a MEMOIZED getter that resolves the
|
|
524
|
+
// thunk on first read (build time, after all module consts init). Consumers read `refAggregateId` as usual.
|
|
525
|
+
if (extra.refAggregateIdThunk !== undefined) {
|
|
526
|
+
const thunk = extra.refAggregateIdThunk;
|
|
527
|
+
let resolved: string | undefined;
|
|
528
|
+
Object.defineProperty(f, "refAggregateId", {
|
|
529
|
+
configurable: true,
|
|
530
|
+
enumerable: true,
|
|
531
|
+
get() {
|
|
532
|
+
if (resolved === undefined) resolved = thunk();
|
|
533
|
+
return resolved;
|
|
534
|
+
},
|
|
535
|
+
});
|
|
536
|
+
}
|
|
499
537
|
return f;
|
|
500
538
|
}
|
|
501
539
|
|
|
@@ -732,6 +770,30 @@ export const t = {
|
|
|
732
770
|
* routing is the kernel's job.
|
|
733
771
|
*/
|
|
734
772
|
ref: refImpl as RefFn,
|
|
773
|
+
/**
|
|
774
|
+
* `t.foreignRef({ workspace, target })` — a TYPED CROSS-WORKSPACE LOCATOR. Use for an edge into ANOTHER
|
|
775
|
+
* holon (a catalogue listing shared into an estate, a media/blob ref, a home's "accessible estate" card):
|
|
776
|
+
* the field STORES the foreign aggregate's id, `workspace` names a SIBLING field holding the foreign
|
|
777
|
+
* workspace id (so the target workspace is dynamic), and `target` types it to the foreign aggregate. The
|
|
778
|
+
* read model surfaces a typed `ForeignRef` locator ({ id, workspace }); it does NOT clone/open the other
|
|
779
|
+
* holon — the generated client's `resolve(nomos)`/`watch(nomos)` do that on demand. It is a LOCATOR, not a
|
|
780
|
+
* database join: law that depends on the foreign FACT opts into attested evidence via `trustSource` +
|
|
781
|
+
* `.readsFrom(...)`. Same-workspace edges stay `t.ref`/`t.hasMany`; this is the honest cross-workspace twin.
|
|
782
|
+
*/
|
|
783
|
+
foreignRef(opts: {
|
|
784
|
+
workspace: string;
|
|
785
|
+
target: AggregateHandle<string, Record<string, Field>>;
|
|
786
|
+
domain?: string;
|
|
787
|
+
}): Field<string, "ref"> {
|
|
788
|
+
return makeField<string, "ref">("ref", z.string(), Conflict, {
|
|
789
|
+
refAggregateId: opts.target.id,
|
|
790
|
+
foreignRef: {
|
|
791
|
+
workspaceField: opts.workspace,
|
|
792
|
+
targetAggregateId: opts.target.id,
|
|
793
|
+
...(opts.domain !== undefined ? { targetDomain: opts.domain } : {}),
|
|
794
|
+
},
|
|
795
|
+
});
|
|
796
|
+
},
|
|
735
797
|
/**
|
|
736
798
|
* A 1:N INVERSE relation — the read side of a `t.ref` on the child (`aggregate_lifecycle_and_relations.md`).
|
|
737
799
|
* `t.hasMany(Child).via("parent")` on a Parent declares "the Children whose `parent` ref points here".
|
|
@@ -740,17 +802,26 @@ export const t = {
|
|
|
740
802
|
* (one event on the CHILD, parent untouched), and the read engine serves `parent.children` as the
|
|
741
803
|
* managed `Child by parent` index. The dev never builds a write-amplifying parent collection.
|
|
742
804
|
*/
|
|
743
|
-
hasMany
|
|
744
|
-
|
|
745
|
-
} {
|
|
805
|
+
hasMany: (function hasMany(
|
|
806
|
+
child: AggregateHandle<string, Record<string, Field>> | (() => AggregateHandle<string, Record<string, Field>>),
|
|
807
|
+
): { via(backref: string): Field<string[], "hasMany"> } {
|
|
808
|
+
// THUNK form (`t.hasMany(() => Child)`) — the common bidirectional case (a Parent's `hasMany(Child)` ⇄ a
|
|
809
|
+
// Child's `ref(Parent)`): pass a thunk so the Parent needn't wait for the Child's `const` to exist.
|
|
810
|
+
const isThunk = typeof child === "function";
|
|
746
811
|
return {
|
|
747
812
|
via(backref: string): Field<string[], "hasMany"> {
|
|
748
813
|
return makeField<string[], "hasMany">("hasMany", z.array(z.string()), Conflict, {
|
|
749
|
-
refAggregateId: child.id,
|
|
814
|
+
...(isThunk ? { refAggregateIdThunk: () => child().id } : { refAggregateId: child.id }),
|
|
750
815
|
viaField: backref,
|
|
751
816
|
});
|
|
752
817
|
},
|
|
753
818
|
};
|
|
819
|
+
}) as {
|
|
820
|
+
/** 1:N inverse by aggregate HANDLE. */
|
|
821
|
+
(child: AggregateHandle<string, Record<string, Field>>): { via(backref: string): Field<string[], "hasMany"> };
|
|
822
|
+
/** 1:N inverse by a THUNK (`t.hasMany(() => Child)`) — a DISTINCT overload so the arrow's return is
|
|
823
|
+
* contextually the WIDE handle, breaking the bidirectional `const` cycle (no circular-`any`). */
|
|
824
|
+
(child: () => AggregateHandle<string, Record<string, Field>>): { via(backref: string): Field<string[], "hasMany"> };
|
|
754
825
|
},
|
|
755
826
|
/**
|
|
756
827
|
* A content-addressed EVIDENCE reference (`evidence.md` §6/§9.2). The authored value is
|
|
@@ -768,8 +839,15 @@ export const t = {
|
|
|
768
839
|
},
|
|
769
840
|
};
|
|
770
841
|
|
|
842
|
+
type RefTarget = AggregateHandle<string, Record<string, Field>> | (() => AggregateHandle<string, Record<string, Field>>);
|
|
843
|
+
|
|
771
844
|
interface RefFn {
|
|
845
|
+
/** Same-workspace reference by aggregate HANDLE. */
|
|
772
846
|
(target: AggregateHandle<string, Record<string, Field>>): Field<string, "ref">;
|
|
847
|
+
/** Same-workspace reference by a THUNK (`t.ref(() => Agg)`) — breaks a bidirectional declaration cycle.
|
|
848
|
+
* A DISTINCT overload (not a union arm) so TS contextually types the arrow's return as the WIDE handle,
|
|
849
|
+
* which is what keeps `const A = ...ref(() => B)` / `const B = ...ref(() => A)` from a circular-`any`. */
|
|
850
|
+
(target: () => AggregateHandle<string, Record<string, Field>>): Field<string, "ref">;
|
|
773
851
|
(
|
|
774
852
|
workspaceId: string,
|
|
775
853
|
target: AggregateHandle<string, Record<string, Field>>,
|
|
@@ -777,7 +855,7 @@ interface RefFn {
|
|
|
777
855
|
}
|
|
778
856
|
|
|
779
857
|
function refImpl(
|
|
780
|
-
a: string |
|
|
858
|
+
a: string | RefTarget,
|
|
781
859
|
b?: AggregateHandle<string, Record<string, Field>>,
|
|
782
860
|
): Field<string, "ref"> {
|
|
783
861
|
if (typeof a === "string") {
|
|
@@ -787,6 +865,10 @@ function refImpl(
|
|
|
787
865
|
refAggregateId: target.id,
|
|
788
866
|
});
|
|
789
867
|
}
|
|
868
|
+
// THUNK form (`t.ref(() => Agg)`) — resolve lazily to break a bidirectional declaration cycle.
|
|
869
|
+
if (typeof a === "function") {
|
|
870
|
+
return makeField<string, "ref">("ref", z.string(), Conflict, { refAggregateIdThunk: () => a().id });
|
|
871
|
+
}
|
|
790
872
|
return makeField<string, "ref">("ref", z.string(), Conflict, { refAggregateId: a.id });
|
|
791
873
|
}
|
|
792
874
|
|
package/src/usd.ts
CHANGED
|
@@ -585,6 +585,24 @@ export interface UsdLayer {
|
|
|
585
585
|
* (byte-identical hash — the era rule); declaring a VO moves the PACKAGE hash only
|
|
586
586
|
* (the canonical manifest/domainHash is untouched — the VO is not wire law).
|
|
587
587
|
*/
|
|
588
|
+
/**
|
|
589
|
+
* MULTI-WORKSPACE LOCATORS (`t.foreignRef`) — the self-describing cross-workspace edges this domain
|
|
590
|
+
* declares: `{aggregate, field, workspaceField, target, targetDomain?}` sorted by (aggregate, field).
|
|
591
|
+
* Descriptive law metadata (codegen emits a typed `ForeignRef` locator + resolve/watch; proof knows the
|
|
592
|
+
* edge shape without proving the concrete foreign workspace). A LOCATOR, not an atomic FK — law that
|
|
593
|
+
* depends on the foreign fact opts into attested evidence separately (`nomosAttestedReadGate`). OMITTED
|
|
594
|
+
* when the domain declares no foreignRef (era-safe — hash-identical to before this key existed).
|
|
595
|
+
*/
|
|
596
|
+
readonly nomosForeignRefs?: {
|
|
597
|
+
readonly v: 1;
|
|
598
|
+
readonly refs: readonly {
|
|
599
|
+
readonly aggregate: string;
|
|
600
|
+
readonly field: string;
|
|
601
|
+
readonly workspaceField: string;
|
|
602
|
+
readonly target: string;
|
|
603
|
+
readonly targetDomain?: string;
|
|
604
|
+
}[];
|
|
605
|
+
};
|
|
588
606
|
readonly nomosValueObjects?: {
|
|
589
607
|
readonly v: 1;
|
|
590
608
|
readonly valueObjects: readonly import("./fields.js").ValueObjectDescriptor[];
|
|
@@ -1329,6 +1347,17 @@ export function emitUsd(
|
|
|
1329
1347
|
const voUsages = collectValueObjectUsages(
|
|
1330
1348
|
l.module.aggregates as unknown as readonly { id: string; fields: Record<string, import("./fields.js").Field> }[],
|
|
1331
1349
|
);
|
|
1350
|
+
// MULTI-WORKSPACE LOCATORS: collect every `t.foreignRef` field across the module's aggregates.
|
|
1351
|
+
const foreignRefList: { aggregate: string; field: string; workspaceField: string; target: string; targetDomain?: string }[] = [];
|
|
1352
|
+
for (const agg of l.module.aggregates) {
|
|
1353
|
+
for (const [fname, f] of Object.entries((agg as { fields: Record<string, { foreignRef?: { workspaceField: string; targetAggregateId: string; targetDomain?: string } }> }).fields)) {
|
|
1354
|
+
const fr = f.foreignRef;
|
|
1355
|
+
if (fr === undefined) continue;
|
|
1356
|
+
foreignRefList.push({ aggregate: agg.id, field: fname, workspaceField: fr.workspaceField, target: fr.targetAggregateId, ...(fr.targetDomain !== undefined ? { targetDomain: fr.targetDomain } : {}) });
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
foreignRefList.sort((a, b) => (a.aggregate !== b.aggregate ? (a.aggregate < b.aggregate ? -1 : 1) : a.field < b.field ? -1 : a.field > b.field ? 1 : 0));
|
|
1360
|
+
const nomosForeignRefs = foreignRefList.length > 0 ? { v: 1 as const, refs: foreignRefList } : undefined;
|
|
1332
1361
|
const nomosValueObjects =
|
|
1333
1362
|
moduleValueObjects !== undefined && moduleValueObjects.length > 0
|
|
1334
1363
|
? {
|
|
@@ -1388,6 +1417,7 @@ export function emitUsd(
|
|
|
1388
1417
|
...(nomosParentAttestedBirth !== undefined ? { nomosParentAttestedBirth } : {}),
|
|
1389
1418
|
...(nomosVerifiedFirstEnroll !== undefined ? { nomosVerifiedFirstEnroll } : {}),
|
|
1390
1419
|
...(eraCertGate !== undefined ? { nomosEraCertGate: eraCertGate } : {}),
|
|
1420
|
+
...(nomosForeignRefs !== undefined ? { nomosForeignRefs } : {}),
|
|
1391
1421
|
...(nomosValueObjects !== undefined ? { nomosValueObjects } : {}),
|
|
1392
1422
|
...(Object.keys(visAggregates).length > 0
|
|
1393
1423
|
? { nomosReadVisibility: { v: 1 as const, aggregates: visAggregates } }
|
package/src/usd_layers.ts
CHANGED
|
@@ -206,6 +206,9 @@ function domainLayerAttrLines(domain: string, layer: UsdLayer, indent: string):
|
|
|
206
206
|
...(layer.nomosValueObjects !== undefined
|
|
207
207
|
? [`${indent}custom string nomos:valueObjectsHex = ${usdaString(hexUtf8(JSON.stringify(layer.nomosValueObjects)))}`]
|
|
208
208
|
: []),
|
|
209
|
+
...(layer.nomosForeignRefs !== undefined
|
|
210
|
+
? [`${indent}custom string nomos:foreignRefsHex = ${usdaString(hexUtf8(JSON.stringify(layer.nomosForeignRefs)))}`]
|
|
211
|
+
: []),
|
|
209
212
|
];
|
|
210
213
|
}
|
|
211
214
|
|
|
@@ -948,7 +951,9 @@ export function flattenLayeredUsd(layers: readonly UsdLayer[]): UsdDocument {
|
|
|
948
951
|
valueObjects = { v: 1, valueObjects: descs, ...(uses.length > 0 ? { usages: uses } : {}) };
|
|
949
952
|
}
|
|
950
953
|
}
|
|
951
|
-
|
|
954
|
+
let foreignRefs: UsdLayer["nomosForeignRefs"];
|
|
955
|
+
for (const l of stack) { if (l.nomosForeignRefs !== undefined) foreignRefs = l.nomosForeignRefs; }
|
|
956
|
+
return {
|
|
952
957
|
path,
|
|
953
958
|
prims: [...prims.keys()].sort().map((p) => prims.get(p)!),
|
|
954
959
|
...(identityManifest !== undefined ? { nomosIdentityManifest: identityManifest } : {}),
|
|
@@ -973,6 +978,7 @@ export function flattenLayeredUsd(layers: readonly UsdLayer[]): UsdDocument {
|
|
|
973
978
|
...(verifiedFirstEnroll !== undefined ? { nomosVerifiedFirstEnroll: verifiedFirstEnroll } : {}),
|
|
974
979
|
...(eraCertGate !== undefined ? { nomosEraCertGate: eraCertGate } : {}),
|
|
975
980
|
...(valueObjects !== undefined ? { nomosValueObjects: valueObjects } : {}),
|
|
981
|
+
...(foreignRefs !== undefined ? { nomosForeignRefs: foreignRefs } : {}),
|
|
976
982
|
};
|
|
977
983
|
});
|
|
978
984
|
|
|
@@ -1112,6 +1118,7 @@ export function parseUsdaDocument(text: string): UsdDocument {
|
|
|
1112
1118
|
const verifiedFirstEnrollByDomain = new Map<string, UsdLayer["nomosVerifiedFirstEnroll"]>();
|
|
1113
1119
|
const eraCertGateByDomain = new Map<string, UsdLayer["nomosEraCertGate"]>();
|
|
1114
1120
|
const valueObjectsByDomain = new Map<string, UsdLayer["nomosValueObjects"]>();
|
|
1121
|
+
const foreignRefsByDomain = new Map<string, UsdLayer["nomosForeignRefs"]>();
|
|
1115
1122
|
for (const p of prims) {
|
|
1116
1123
|
if (p.attrs.get("nomos:kind") === "domain") {
|
|
1117
1124
|
const domain = attrString(p, "nomos:domain");
|
|
@@ -1207,6 +1214,10 @@ export function parseUsdaDocument(text: string): UsdDocument {
|
|
|
1207
1214
|
if (typeof voHex === "string") {
|
|
1208
1215
|
valueObjectsByDomain.set(domain, JSON.parse(unhexUtf8(voHex)) as UsdLayer["nomosValueObjects"]);
|
|
1209
1216
|
}
|
|
1217
|
+
const frHex = p.attrs.get("nomos:foreignRefsHex");
|
|
1218
|
+
if (typeof frHex === "string") {
|
|
1219
|
+
foreignRefsByDomain.set(domain, JSON.parse(unhexUtf8(frHex)) as UsdLayer["nomosForeignRefs"]);
|
|
1220
|
+
}
|
|
1210
1221
|
}
|
|
1211
1222
|
}
|
|
1212
1223
|
|
|
@@ -1317,6 +1328,7 @@ export function parseUsdaDocument(text: string): UsdDocument {
|
|
|
1317
1328
|
const verifiedFirstEnroll = verifiedFirstEnrollByDomain.get(domain);
|
|
1318
1329
|
const eraCertGate = eraCertGateByDomain.get(domain);
|
|
1319
1330
|
const valueObjects = valueObjectsByDomain.get(domain);
|
|
1331
|
+
const foreignRefs2 = foreignRefsByDomain.get(domain);
|
|
1320
1332
|
return {
|
|
1321
1333
|
path: `/Nomos/${domain}`,
|
|
1322
1334
|
prims: [...b.prims].sort(byPath),
|
|
@@ -1342,6 +1354,7 @@ export function parseUsdaDocument(text: string): UsdDocument {
|
|
|
1342
1354
|
...(verifiedFirstEnroll !== undefined ? { nomosVerifiedFirstEnroll: verifiedFirstEnroll } : {}),
|
|
1343
1355
|
...(eraCertGate !== undefined ? { nomosEraCertGate: eraCertGate } : {}),
|
|
1344
1356
|
...(valueObjects !== undefined ? { nomosValueObjects: valueObjects } : {}),
|
|
1357
|
+
...(foreignRefs2 !== undefined ? { nomosForeignRefs: foreignRefs2 } : {}),
|
|
1345
1358
|
};
|
|
1346
1359
|
})
|
|
1347
1360
|
.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|