@githolon/dsl 0.65.0 → 0.66.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dart/CHANGELOG.md +8 -0
- package/dart/pubspec.yaml +2 -2
- package/dart_types/CHANGELOG.md +8 -0
- package/dart_types/pubspec.yaml +1 -1
- package/package.json +1 -1
- package/src/aggregate.ts +21 -3
- package/src/build_package.ts +20 -0
- package/src/codegen_dart.ts +108 -1
- package/src/codegen_ts.ts +26 -2
- package/src/compile_package_main.ts +146 -5
- package/src/contract.ts +110 -0
- package/src/fields.ts +9 -2
- package/src/index.ts +1 -0
- package/src/usd.ts +61 -0
- package/src/usd_layers.ts +40 -0
package/dart/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
## 0.43.0
|
|
2
|
+
|
|
3
|
+
- `NomosBridge.foreignReader({cloud})` — a `NomosForeignReader` backed by the device's multi-workspace bridge
|
|
4
|
+
plane, so a generated `ForeignRef` (`t.foreignRef`) resolves against real data: it mounts the foreign
|
|
5
|
+
workspace on demand (`connect(session: ws, allowUnborn: true)`, idempotent) and reads it
|
|
6
|
+
(`session(ws).queryById(id)`); `watch` re-reads on each sync tick. A locator resolver, not a foreign key —
|
|
7
|
+
the foreign holon is mounted only when you resolve. Requires `nomos_types >= 0.1.1` (the `ForeignRef` types).
|
|
8
|
+
|
|
1
9
|
## 0.41.0
|
|
2
10
|
- `NomosTestHarness` emits named phase-progress diagnostics (install → spawn runner → await ready → connect, plus per-session progress in `cluster`) and each `connect()` carries a `connectTimeout` (default 120s) that throws a NAMED remedy instead of hanging — so a multi-test AppSim run can tell install vs runner-wait vs session-block vs deadlock (co2 feedback).
|
|
3
11
|
|
package/dart/pubspec.yaml
CHANGED
|
@@ -11,7 +11,7 @@ description: >-
|
|
|
11
11
|
Nomos 2 Dart frontend types — the hand-written kernel wire types plus the
|
|
12
12
|
code-generated per-domain aggregate + directive-payload types. (De)serializes
|
|
13
13
|
the EXACT serde JSON the Rust kernel + projection produce/consume.
|
|
14
|
-
version: 0.
|
|
14
|
+
version: 0.43.0
|
|
15
15
|
|
|
16
16
|
environment:
|
|
17
17
|
sdk: ^3.5.0
|
|
@@ -20,7 +20,7 @@ dependencies:
|
|
|
20
20
|
# The pure-data core (wire types, offer codec, NomosReads, schema validation, error
|
|
21
21
|
# info). nomos_client depends on and re-exports it, so existing imports are unchanged.
|
|
22
22
|
# For local monorepo dev, pubspec_overrides.yaml points this at ../dart_types.
|
|
23
|
-
nomos_types: ^0.1.
|
|
23
|
+
nomos_types: ^0.1.1
|
|
24
24
|
json_schema: ^5.2.2
|
|
25
25
|
crypto: ^3.0.0
|
|
26
26
|
|
package/dart_types/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
## 0.1.1
|
|
2
|
+
|
|
3
|
+
- Add `t.foreignRef` runtime types: `ForeignRef<T>` (a typed cross-workspace locator), `NomosForeignReader` /
|
|
4
|
+
`NomosForeignWorkspaceReader`, the sealed `ForeignResolveResult<T>` (`ForeignFound`/`ForeignNotFound`/
|
|
5
|
+
`ForeignDenied`/`ForeignUnreachable`), and `ForeignAttestation`. Generated read models type a `foreignRef`
|
|
6
|
+
field as `ForeignRef<Target>`; `.resolve/.resolveResult/.watch/.exists` are the informational lookup,
|
|
7
|
+
`.attest` is gated on the attested-read lane. Purely additive.
|
|
8
|
+
|
|
1
9
|
## 0.1.0
|
|
2
10
|
- Initial release. The pure-data core split out of `nomos_client`: kernel wire types,
|
|
3
11
|
the offer codec, `NomosReads`, the tri-state `NomosSlot`, aggregate metadata, schema
|
package/dart_types/pubspec.yaml
CHANGED
|
@@ -11,7 +11,7 @@ description: >-
|
|
|
11
11
|
Pure kernel wire/data types for Nomos Dart frontends — no transport, no bridge,
|
|
12
12
|
no runtime. The hand-written serde-JSON-compatible types plus the offer codec,
|
|
13
13
|
reactive read engine, schema validation, and data-only error/trace types.
|
|
14
|
-
version: 0.1.
|
|
14
|
+
version: 0.1.1
|
|
15
15
|
|
|
16
16
|
environment:
|
|
17
17
|
sdk: ^3.5.0
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@githolon/dsl",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.66.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
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* string: a typo'd handle is a compile error.
|
|
15
15
|
*/
|
|
16
16
|
import type { Field } from "./fields.js";
|
|
17
|
+
import { satisfiesContract, type ContractHandle } from "./contract.js";
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* A pure, replay-stable predicate over one aggregate's PROJECTED snapshot —
|
|
@@ -155,6 +156,10 @@ export interface AggregateHandle<
|
|
|
155
156
|
readonly hasMany: HasManyFields<F>;
|
|
156
157
|
/** Brand so a plain string is never accepted where a handle is required. */
|
|
157
158
|
readonly __isAggregateHandle: true;
|
|
159
|
+
/** DOMAIN CONTRACTS this aggregate `.implements(...)` — the stable public shapes it satisfies (compile-
|
|
160
|
+
* checked). Codegen marks the aggregate as implementing them so cross-domain refs can target the CONTRACT
|
|
161
|
+
* instead of this concrete type. Additive metadata (not hashed law). OMITTED when it implements none. */
|
|
162
|
+
readonly implementsContracts?: readonly string[];
|
|
158
163
|
/**
|
|
159
164
|
* FRAMEWORK-SHARED brand (Dart-codegen hygiene). Set (by `framework/framework_aggregates.ts`)
|
|
160
165
|
* ONLY on the framework-owned aggregate singletons declared in `dsl/src/framework/` — most
|
|
@@ -235,6 +240,9 @@ export interface AudienceMethods<Id extends string, F extends Record<string, Fie
|
|
|
235
240
|
/** The owner (+ per-record grantees) read; owner = the verified author, stamped into `ownerField`
|
|
236
241
|
* (default `__owner`); operator-blind. NOTE: full owned read-scoping + E2E land in the next slice. */
|
|
237
242
|
owned(ownerField?: string): AggregateHandle<Id, F>;
|
|
243
|
+
/** DOMAIN CONTRACTS: assert this aggregate satisfies `contract`'s public shape (compile-checked — a
|
|
244
|
+
* mismatch throws naming the field). Chainable; records the contract so cross-domain refs can target it. */
|
|
245
|
+
implements(contract: ContractHandle): AggregateHandle<Id, F> & AudienceMethods<Id, F>;
|
|
238
246
|
}
|
|
239
247
|
|
|
240
248
|
export function aggregate<const Id extends string, F extends Record<string, Field>>(
|
|
@@ -354,16 +362,26 @@ export function aggregate<const Id extends string, F extends Record<string, Fiel
|
|
|
354
362
|
const build = (
|
|
355
363
|
audience: Audience | undefined,
|
|
356
364
|
visibility: ReadVisibility | undefined,
|
|
365
|
+
contracts: readonly string[] = [],
|
|
357
366
|
): AggregateHandle<Id, F> & AudienceMethods<Id, F> => ({
|
|
358
367
|
...base,
|
|
359
368
|
...(audience !== undefined ? { audience } : {}),
|
|
360
369
|
...(visibility !== undefined ? { visibility } : {}),
|
|
361
|
-
|
|
370
|
+
...(contracts.length > 0 ? { implementsContracts: contracts } : {}),
|
|
371
|
+
public: () => build({ kind: "public" }, undefined, contracts),
|
|
362
372
|
readableBy: (cap: string) => {
|
|
363
373
|
const v = requires(cap); // validates the capability name
|
|
364
|
-
return build({ kind: "readableBy", cap: v.requires }, v);
|
|
374
|
+
return build({ kind: "readableBy", cap: v.requires }, v, contracts);
|
|
375
|
+
},
|
|
376
|
+
owned: (ownerField: string = OWNER_FIELD) => build({ kind: "owned", ownerField }, undefined, contracts),
|
|
377
|
+
// DOMAIN CONTRACTS: assert this aggregate satisfies `contractHandle`'s public shape — compile-checked
|
|
378
|
+
// NOW (a mismatch throws a typed error naming the field). Records the contract so cross-domain refs may
|
|
379
|
+
// target it. Chainable (`.implements(A).implements(B)`); the aggregate keeps its concrete fields.
|
|
380
|
+
implements: (contractHandle: ContractHandle) => {
|
|
381
|
+
const verdict = satisfiesContract(id, stored as Record<string, Field>, contractHandle);
|
|
382
|
+
if (!verdict.ok) throw new Error(verdict.reason);
|
|
383
|
+
return build(audience, visibility, contracts.includes(contractHandle.id) ? contracts : [...contracts, contractHandle.id]);
|
|
365
384
|
},
|
|
366
|
-
owned: (ownerField: string = OWNER_FIELD) => build({ kind: "owned", ownerField }, undefined),
|
|
367
385
|
});
|
|
368
386
|
|
|
369
387
|
// Legacy `{ visibility: requires(cap) }` IS `readableBy(cap)` — carry both so existing law is unchanged.
|
package/src/build_package.ts
CHANGED
|
@@ -99,6 +99,17 @@ export function aggregatesOf(mod: Mod): AggregateHandle[] {
|
|
|
99
99
|
return out;
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
+
/** Collect every exported `contract(...)` handle (`__isContract` duck-type). */
|
|
103
|
+
export function contractsOf(mod: Mod): import("./contract.js").ContractHandle[] {
|
|
104
|
+
const out: import("./contract.js").ContractHandle[] = [];
|
|
105
|
+
for (const v of Object.values(mod)) {
|
|
106
|
+
if (v && typeof v === "object" && (v as { __isContract?: boolean }).__isContract === true) {
|
|
107
|
+
out.push(v as import("./contract.js").ContractHandle);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
|
|
102
113
|
/** Collect every exported `Directive` ({id, plan, aggregateId} duck-type). */
|
|
103
114
|
export function directivesOf(mod: Mod): Directive<any>[] {
|
|
104
115
|
const out: Directive<any>[] = [];
|
|
@@ -262,6 +273,12 @@ export function composeDomainModule(spec: DomainModuleSpec): DomainModule {
|
|
|
262
273
|
// the additive `nomosValueObjects` USD key. Omit-when-empty (era rule): a VO-free domain's
|
|
263
274
|
// module — and thus its USD/package bytes — is byte-identical to before VOs existed.
|
|
264
275
|
const valueObjects = collectValueObjectDescriptors(aggregates);
|
|
276
|
+
// DOMAIN CONTRACTS (domain_contracts.md): the public DTO shapes this domain declares, discovered by
|
|
277
|
+
// shape across the merged module list. Deduped by id (later-wins), sorted for byte-determinism. Omit-
|
|
278
|
+
// when-empty (era rule): a contract-free domain's module/USD/package bytes are byte-identical.
|
|
279
|
+
const contractById = new Map<string, import("./contract.js").ContractHandle>();
|
|
280
|
+
for (const c of contractsOf(merged)) contractById.set(c.id, c);
|
|
281
|
+
const contracts = [...contractById.values()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
265
282
|
// SPATIAL × VO PAIRING (fail at build, not at fold): a spatial index over a VO-typed field
|
|
266
283
|
// must index a GEO value object (an RFC 7946 geometry — the R*Tree's bbox source). A
|
|
267
284
|
// structural VO (e.g. a PostalAddress) has no geometry to index. Non-VO fields (a
|
|
@@ -394,6 +411,9 @@ export function composeDomainModule(spec: DomainModuleSpec): DomainModule {
|
|
|
394
411
|
// Omit-when-empty — the `nomosValueObjects` era rule: a VO-free domain's USD/package
|
|
395
412
|
// bytes are byte-identical to before value objects existed.
|
|
396
413
|
...(valueObjects.length > 0 ? { valueObjects } : {}),
|
|
414
|
+
// Omit-when-empty — the `nomosContracts` era rule: a contract-free domain's USD/package bytes
|
|
415
|
+
// are byte-identical to before domain contracts existed (pure additive typing metadata).
|
|
416
|
+
...(contracts.length > 0 ? { contracts } : {}),
|
|
397
417
|
// THE PEER-SOVEREIGN ROOT-OF-TRUST: omit-when-false — a law that doesn't require parent-attested
|
|
398
418
|
// birth is byte-identical in the canonical manifest/USD (the nomosParentAttestedBirth era rule).
|
|
399
419
|
...(parentAttestedBirth ? { parentAttestedBirth } : {}),
|
package/src/codegen_dart.ts
CHANGED
|
@@ -744,7 +744,11 @@ function aggFieldSpec(
|
|
|
744
744
|
// workspace field give the app `resolve`/`watch`/`attest` on demand, without cloning the other holon.
|
|
745
745
|
// This takes precedence over the same-workspace typed-id path below.
|
|
746
746
|
if (field.foreignRef !== undefined) {
|
|
747
|
-
|
|
747
|
+
// A contract target names the shared DTO class `<ContractId>Contract` (emitted into the shared
|
|
748
|
+
// `contracts.dart`); the aggregate class is `<ContractId>` and must not collide, hence the suffix.
|
|
749
|
+
const target = field.foreignRef.targetIsContract === true
|
|
750
|
+
? `${field.foreignRef.targetAggregateId}Contract`
|
|
751
|
+
: field.foreignRef.targetAggregateId;
|
|
748
752
|
return {
|
|
749
753
|
name,
|
|
750
754
|
wireName,
|
|
@@ -1904,6 +1908,100 @@ function emitValueObjectClass(d: ValueObjectDescriptor): string {
|
|
|
1904
1908
|
].join("\n");
|
|
1905
1909
|
}
|
|
1906
1910
|
|
|
1911
|
+
/** The Dart type a CONTRACT field decodes to (domain_contracts.md) — a public DTO leaf. Contracts are
|
|
1912
|
+
* plain shapes (no merge state), so map by kind: scalars to their Dart primitive, a named VO to its class,
|
|
1913
|
+
* a ref/foreignRef to the raw id String. Everything is nullable (a projected DTO is a partial fold). */
|
|
1914
|
+
function dartTypeOfContractField(field: Field): string {
|
|
1915
|
+
switch (field.kind) {
|
|
1916
|
+
case "int":
|
|
1917
|
+
case "counter":
|
|
1918
|
+
return "int";
|
|
1919
|
+
case "bool":
|
|
1920
|
+
return "bool";
|
|
1921
|
+
case "json":
|
|
1922
|
+
return field.valueObject !== undefined ? field.valueObject.name : "Map<String, dynamic>";
|
|
1923
|
+
default:
|
|
1924
|
+
// string / enum / position / ref / foreignRef — all carried as a String on the wire.
|
|
1925
|
+
return "String";
|
|
1926
|
+
}
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1929
|
+
/** The fromJson decode expression for a CONTRACT field, reading `data['<wire>']`. */
|
|
1930
|
+
function contractFromExpr(field: Field, wire: string): string {
|
|
1931
|
+
const acc = `data['${wire}']`;
|
|
1932
|
+
switch (field.kind) {
|
|
1933
|
+
case "int":
|
|
1934
|
+
case "counter":
|
|
1935
|
+
return `(${acc} as num?)?.toInt()`;
|
|
1936
|
+
case "bool":
|
|
1937
|
+
return `${acc} as bool?`;
|
|
1938
|
+
case "json":
|
|
1939
|
+
if (field.valueObject !== undefined) {
|
|
1940
|
+
return `${acc} == null ? null : ${field.valueObject.name}.fromProjected((${acc} as Map).cast<String, Object?>())`;
|
|
1941
|
+
}
|
|
1942
|
+
return `${acc} == null ? null : (${acc} as Map).cast<String, dynamic>()`;
|
|
1943
|
+
default:
|
|
1944
|
+
return `${acc} as String?`;
|
|
1945
|
+
}
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
/**
|
|
1949
|
+
* Emit an immutable Dart class for ONE domain contract (a public DTO — architecture/domain_contracts.md).
|
|
1950
|
+
* The class is `<ContractId>Contract` (the suffix keeps it distinct from an identically-named aggregate's
|
|
1951
|
+
* `<ContractId>` read-model class). `final` fields + a `const` ctor + a `fromJson(Map, [id])` factory. Pure —
|
|
1952
|
+
* references only nomos_types primitives (+ any VO class, which is shared). Emitted into `contracts.dart`.
|
|
1953
|
+
*/
|
|
1954
|
+
function emitDartContractClass(c: import("./contract.js").ContractHandle): string {
|
|
1955
|
+
const cls = `${c.id}Contract`;
|
|
1956
|
+
const fields = Object.entries(c.fields).map(([wireName, f]) => ({
|
|
1957
|
+
wireName,
|
|
1958
|
+
member: dartMemberName(wireName),
|
|
1959
|
+
dartType: dartTypeOfContractField(f as Field),
|
|
1960
|
+
from: contractFromExpr(f as Field, wireName),
|
|
1961
|
+
}));
|
|
1962
|
+
return [
|
|
1963
|
+
`/// Generated domain CONTRACT '${c.id}' (architecture/domain_contracts.md) — a public DTO: a named,`,
|
|
1964
|
+
`/// stable shape other domains reference WITHOUT importing the concrete aggregate. Immutable; decoded`,
|
|
1965
|
+
`/// from a projection row via [${cls}.fromJson]. Fields are nullable (a projected DTO is a partial fold).`,
|
|
1966
|
+
`class ${cls} {`,
|
|
1967
|
+
` final String id;`,
|
|
1968
|
+
...fields.map((x) => ` final ${x.dartType}? ${x.member};`),
|
|
1969
|
+
``,
|
|
1970
|
+
` const ${cls}({`,
|
|
1971
|
+
` this.id = '',`,
|
|
1972
|
+
...fields.map((x) => ` this.${x.member},`),
|
|
1973
|
+
` });`,
|
|
1974
|
+
``,
|
|
1975
|
+
` factory ${cls}.fromJson(Map<String, dynamic> data, [String id = '']) => ${cls}(`,
|
|
1976
|
+
` id: id,`,
|
|
1977
|
+
...fields.map((x) => ` ${x.member}: ${x.from},`),
|
|
1978
|
+
` );`,
|
|
1979
|
+
`}`,
|
|
1980
|
+
].join("\n");
|
|
1981
|
+
}
|
|
1982
|
+
|
|
1983
|
+
/**
|
|
1984
|
+
* The full source of the shared `contracts.dart` file — every domain contract's DTO class, emitted ONCE
|
|
1985
|
+
* per package into the TYPES package (both the flat and the layered emit). Carries the standard support
|
|
1986
|
+
* import header so `compile_package_main`'s uniform rewrite (→ vendored relative / → nomos_types) applies;
|
|
1987
|
+
* the classes themselves reference only nomos_types primitives (+ shared VO classes), so the file is PURE.
|
|
1988
|
+
*/
|
|
1989
|
+
export function generateDartContracts(contracts: readonly import("./contract.js").ContractHandle[]): string {
|
|
1990
|
+
const sorted = [...contracts].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
1991
|
+
return [
|
|
1992
|
+
`// GENERATED by nomos-compile — the package's shared domain CONTRACTS (public DTOs). DO NOT EDIT.`,
|
|
1993
|
+
`// Every domain depends on this shared layer; no domain imports another's concrete types (the acyclic graph).`,
|
|
1994
|
+
`//`,
|
|
1995
|
+
`// ignore_for_file: unused_import`,
|
|
1996
|
+
`library;`,
|
|
1997
|
+
``,
|
|
1998
|
+
`import 'package:nomos_client/nomos_client.dart';`,
|
|
1999
|
+
``,
|
|
2000
|
+
...sorted.map(emitDartContractClass),
|
|
2001
|
+
``,
|
|
2002
|
+
].join("\n");
|
|
2003
|
+
}
|
|
2004
|
+
|
|
1907
2005
|
/** True when ANY aggregate field in the module is `t.evidence()` — gates emitting the
|
|
1908
2006
|
* shared [emitEvidenceRefClass] once. */
|
|
1909
2007
|
function moduleUsesEvidence(aggregates: readonly AggregateHandle[]): boolean {
|
|
@@ -3578,6 +3676,15 @@ export interface DomainModule {
|
|
|
3578
3676
|
* additive `nomosValueObjects` USD key (`usd.ts`).
|
|
3579
3677
|
*/
|
|
3580
3678
|
valueObjects?: readonly ValueObjectDescriptor[];
|
|
3679
|
+
/**
|
|
3680
|
+
* DOMAIN CONTRACTS (`architecture/domain_contracts.md`) — the public DTO shapes this domain declares
|
|
3681
|
+
* (`contract(id, fields)`), discovered by shape and composed across the domain's modules. ADDITIVE +
|
|
3682
|
+
* OPTIONAL: a contract-free domain omits this entirely and is byte-identical to before contracts existed.
|
|
3683
|
+
* Contracts are PURE typing/codegen metadata (NOT hashed law); they drive the additive `nomosContracts`
|
|
3684
|
+
* IR key + the shared `contracts.dart` DTO classes + the TS `<Id>Contract` interfaces. `implementedBy`
|
|
3685
|
+
* lists the concrete aggregate ids that `.implements(...)` this contract (from `implementsContracts`).
|
|
3686
|
+
*/
|
|
3687
|
+
contracts?: readonly import("./contract.js").ContractHandle[];
|
|
3581
3688
|
/**
|
|
3582
3689
|
* The domain's FIRST-CLASS WORKSPACE TYPES (`workspace_type.ts`; sharding §10
|
|
3583
3690
|
* RATIFIED, slice 1) — the law-declared workspace taxonomy
|
package/src/codegen_ts.ts
CHANGED
|
@@ -499,8 +499,14 @@ function tsBaseTypeOfField(field: Field): string {
|
|
|
499
499
|
case "ref": {
|
|
500
500
|
// A `t.foreignRef` field surfaces as a TYPED cross-workspace locator (hydrated by the accessors from
|
|
501
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
|
-
|
|
502
|
+
const fr = (field as { foreignRef?: { targetAggregateId: string; targetIsContract?: boolean } }).foreignRef;
|
|
503
|
+
// A contract target types against the shared `<ContractId>Contract` interface (the public DTO),
|
|
504
|
+
// not the concrete `<Agg>Data` — that is how the reference stays acyclic.
|
|
505
|
+
return fr !== undefined
|
|
506
|
+
? fr.targetIsContract === true
|
|
507
|
+
? `ForeignRef<${fr.targetAggregateId}Contract>`
|
|
508
|
+
: `ForeignRef<${fr.targetAggregateId}Data>`
|
|
509
|
+
: "string";
|
|
504
510
|
}
|
|
505
511
|
case "json":
|
|
506
512
|
// A NAMED VALUE OBJECT field reads as its generated interface (`value_object.ts`);
|
|
@@ -1218,6 +1224,24 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
|
|
|
1218
1224
|
}
|
|
1219
1225
|
}
|
|
1220
1226
|
|
|
1227
|
+
// ── domain contracts: one `<Id>Contract` interface per public DTO shape, deduped across domains ──
|
|
1228
|
+
const contractByIdTs = new Map<string, import("./contract.js").ContractHandle>();
|
|
1229
|
+
for (const mod of modules) {
|
|
1230
|
+
for (const c of ((mod as { contracts?: readonly import("./contract.js").ContractHandle[] }).contracts ?? [])) {
|
|
1231
|
+
contractByIdTs.set(c.id, c);
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
if (contractByIdTs.size > 0) {
|
|
1235
|
+
out.push(`// ── domain contracts (public DTO shapes — architecture/domain_contracts.md) ──`, ``);
|
|
1236
|
+
for (const [, c] of [...contractByIdTs].sort(([a], [b]) => a.localeCompare(b))) {
|
|
1237
|
+
out.push(`export interface ${c.id}Contract {`);
|
|
1238
|
+
for (const [name, field] of Object.entries(c.fields)) {
|
|
1239
|
+
out.push(` ${name}?: ${tsTypeOfField(field as Field)};`);
|
|
1240
|
+
}
|
|
1241
|
+
out.push(`}`, ``);
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1221
1245
|
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
1246
|
const foreignRefFields: { agg: string; field: string; workspaceField: string; target: string }[] = [];
|
|
1223
1247
|
// wrap a read accessor return expression through the foreignRef row-hydrator (identity + byte-identical
|
|
@@ -55,11 +55,13 @@ import { fileURLToPath, pathToFileURL } from "node:url";
|
|
|
55
55
|
import type { AggregateHandle } from "./aggregate.js";
|
|
56
56
|
import {
|
|
57
57
|
generateDartDomain,
|
|
58
|
+
generateDartContracts,
|
|
58
59
|
type DartImport,
|
|
59
60
|
type DartRefImport,
|
|
60
61
|
type DomainModule,
|
|
61
62
|
type PermissionVocabulary,
|
|
62
63
|
} from "./codegen_dart.js";
|
|
64
|
+
import type { ContractHandle } from "./contract.js";
|
|
63
65
|
import { generateDartBridgeClient } from "./codegen_dart_client.js";
|
|
64
66
|
import {
|
|
65
67
|
isFrameworkAggregate,
|
|
@@ -445,6 +447,55 @@ const DART_GENERATED_SUBDIR = path.join("src", "generated");
|
|
|
445
447
|
/** The shared basename of the framework-dedup file emitted once per package. */
|
|
446
448
|
const FRAMEWORK_FILE = "nomos_framework";
|
|
447
449
|
|
|
450
|
+
/** The shared basename of the domain-contracts DTO file emitted once per package (domain_contracts.md). */
|
|
451
|
+
const CONTRACTS_FILE = "contracts";
|
|
452
|
+
|
|
453
|
+
/** Collect every domain contract across the package's modules, deduped by id (later-wins). Sorted by id.
|
|
454
|
+
* Empty when no domain declares a contract (contract-free packages emit no `contracts.dart`). */
|
|
455
|
+
function collectPackageContracts(modules: readonly DomainModule[]): ContractHandle[] {
|
|
456
|
+
const byId = new Map<string, ContractHandle>();
|
|
457
|
+
for (const m of modules) for (const c of (m.contracts ?? [])) byId.set(c.id, c);
|
|
458
|
+
return [...byId.values()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* CIRCULAR-DEPENDENCY GUARD (domain_contracts.md). A `t.foreignRef` targeting a CONCRETE aggregate makes
|
|
463
|
+
* the referencing domain's generated file import the target domain's file. Two domains that concretely
|
|
464
|
+
* reference each other (A→B and B→A) form an import cycle — the exact anti-pattern domain contracts exist
|
|
465
|
+
* to break. Build the concrete domain→domain edge set and FAIL on any 2-cycle, pointing at contracts as the
|
|
466
|
+
* fix. Contract-mediated refs go through `contracts.dart`, so they create NO concrete edge and never trip this.
|
|
467
|
+
*/
|
|
468
|
+
function guardNoConcreteImportCycle(
|
|
469
|
+
modules: readonly DomainModule[],
|
|
470
|
+
aggIdToDartFile: ReadonlyMap<string, string>,
|
|
471
|
+
): void {
|
|
472
|
+
const edges = new Map<string, Set<string>>(); // fromDomainFile → { toDomainFile }
|
|
473
|
+
for (const mod of modules) {
|
|
474
|
+
for (const agg of mod.aggregates) {
|
|
475
|
+
for (const f of Object.values(agg.fields as Record<string, { foreignRef?: { targetAggregateId: string; targetIsContract?: boolean } }>)) {
|
|
476
|
+
const fr = f.foreignRef;
|
|
477
|
+
if (fr === undefined || fr.targetIsContract === true) continue; // contract edges route via contracts.dart
|
|
478
|
+
const to = aggIdToDartFile.get(fr.targetAggregateId);
|
|
479
|
+
if (to === undefined || to === mod.name) continue;
|
|
480
|
+
(edges.get(mod.name) ?? edges.set(mod.name, new Set()).get(mod.name)!).add(to);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
for (const [from, tos] of edges) {
|
|
485
|
+
for (const to of tos) {
|
|
486
|
+
if (edges.get(to)?.has(from) && from < to) {
|
|
487
|
+
fail(
|
|
488
|
+
`domain contracts: concrete cross-domain import cycle between '${from}' and '${to}' — their ` +
|
|
489
|
+
`generated Dart files would import each other (a t.foreignRef in each targets a concrete aggregate ` +
|
|
490
|
+
`owned by the other). Break it with a domain CONTRACT: declare a contract(...) for the shared shape, ` +
|
|
491
|
+
`.implements(...) it on the owning aggregate, and point the foreignRef at the CONTRACT (it resolves ` +
|
|
492
|
+
`through the shared contracts.dart, keeping the domain graph acyclic).`,
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
448
499
|
/** FRAMEWORK-SHARED symbol dedup, shared by the flat emit and the layered-packages emit (Dart-codegen
|
|
449
500
|
* hygiene): framework-owned symbols fold into MANY domains, so their generated Dart would be DEFINED
|
|
450
501
|
* once per domain — a package barrel re-exporting ≥2 domains then COLLIDES. THREE kinds are deduped,
|
|
@@ -673,6 +724,25 @@ function emitDart(
|
|
|
673
724
|
if (shared.frameworkMod !== undefined) {
|
|
674
725
|
writeGenerated(FRAMEWORK_FILE, generateDartDomain(shared.frameworkMod));
|
|
675
726
|
}
|
|
727
|
+
// 2a-bis. the shared DOMAIN CONTRACTS file (`contracts.dart`) — every public DTO class, ONCE. Pure
|
|
728
|
+
// (nomos_types + VOs). A foreignRef targeting a contract imports THIS, never the concrete domain.
|
|
729
|
+
const packageContracts = collectPackageContracts(modules);
|
|
730
|
+
const contractsImportUri = packageContracts.length > 0 ? `${CONTRACTS_FILE}.dart` : undefined;
|
|
731
|
+
if (packageContracts.length > 0) {
|
|
732
|
+
writeGenerated(CONTRACTS_FILE, generateDartContracts(packageContracts));
|
|
733
|
+
}
|
|
734
|
+
// CROSS-DOMAIN `t.foreignRef` (auto import). A foreignRef field typed `ForeignRef<CatalogueListing>` +
|
|
735
|
+
// `CatalogueListing.fromJson` needs the TARGET aggregate's Dart class — which lives in the target DOMAIN's
|
|
736
|
+
// generated file. Within ONE package the compiler knows which domain owns each aggregate, so we auto-emit
|
|
737
|
+
// the import (no manual `dartImports` config). Map every aggregate id → its domain's generated file base.
|
|
738
|
+
const aggIdToDartFile = new Map<string, string>();
|
|
739
|
+
for (const m of modules) {
|
|
740
|
+
for (const agg of (m.aggregates as { id: string }[])) aggIdToDartFile.set(agg.id, m.name);
|
|
741
|
+
}
|
|
742
|
+
// CIRCULAR-DEP GUARD (domain_contracts.md): concrete-domain → concrete-domain import edges (from
|
|
743
|
+
// concrete-aggregate-target foreignRefs). A 2-cycle A→B & B→A is the anti-pattern contracts exist to
|
|
744
|
+
// break — fail with a clear diagnostic. Contract-mediated refs go through contracts.dart (no concrete edge).
|
|
745
|
+
guardNoConcreteImportCycle(modules, aggIdToDartFile);
|
|
676
746
|
|
|
677
747
|
// 2b. one generated file per composed domain — the TYPES (`<name>.dart`) + the async bridge
|
|
678
748
|
// CLIENT (`<name>.client.dart`, the Flutter sibling of `<name>.client.ts`).
|
|
@@ -684,7 +754,35 @@ function emitDart(
|
|
|
684
754
|
// framework import. Only pass the import when this domain actually folds a framework symbol.
|
|
685
755
|
const { modForDart, skips, foldsFramework } = domainDedupOpts(mod, shared);
|
|
686
756
|
const domainFrameworkImport = foldsFramework ? frameworkImportUri : undefined;
|
|
687
|
-
|
|
757
|
+
// AUTO cross-domain foreignRef imports: for each foreignRef whose target aggregate lives in ANOTHER
|
|
758
|
+
// tenant domain, import that domain's generated file so `ForeignRef<Target>` + `Target.fromJson` resolve.
|
|
759
|
+
// A target that is framework-shared resolves via the frameworkImportUri (not in the tenant map), so it is
|
|
760
|
+
// skipped here. Merged with any manual `dartImports`; deduped by uri.
|
|
761
|
+
const foreignRefImportUris = new Set<string>();
|
|
762
|
+
for (const agg of (modForDart.aggregates as { fields: Record<string, { foreignRef?: { targetAggregateId: string; targetIsContract?: boolean } }> }[])) {
|
|
763
|
+
for (const f of Object.values(agg.fields)) {
|
|
764
|
+
const fr = f.foreignRef;
|
|
765
|
+
if (fr === undefined) continue;
|
|
766
|
+
// A CONTRACT target resolves through the shared `contracts.dart` (the acyclic edge), NEVER the
|
|
767
|
+
// concrete target domain — so estate never imports catalogue. (The contract id may EQUAL an
|
|
768
|
+
// aggregate id, so this branch MUST precede the concrete-file lookup.)
|
|
769
|
+
if (fr.targetIsContract === true) {
|
|
770
|
+
if (contractsImportUri !== undefined) foreignRefImportUris.add(contractsImportUri);
|
|
771
|
+
continue;
|
|
772
|
+
}
|
|
773
|
+
const targetFile = aggIdToDartFile.get(fr.targetAggregateId);
|
|
774
|
+
if (targetFile !== undefined && targetFile !== modForDart.name) foreignRefImportUris.add(`${targetFile}.dart`);
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
const mergedDartImports = [
|
|
778
|
+
...(modForDart.dartImports ?? []),
|
|
779
|
+
...[...foreignRefImportUris]
|
|
780
|
+
.filter((uri) => !(modForDart.dartImports ?? []).some((i) => i.uri === uri))
|
|
781
|
+
.sort()
|
|
782
|
+
.map((uri) => ({ uri })),
|
|
783
|
+
];
|
|
784
|
+
const modForDartWithImports = mergedDartImports.length > 0 ? { ...modForDart, dartImports: mergedDartImports } : modForDart;
|
|
785
|
+
const src = generateDartDomain(modForDartWithImports, {
|
|
688
786
|
...skips,
|
|
689
787
|
...(domainFrameworkImport !== undefined ? { frameworkImportUri: domainFrameworkImport } : {}),
|
|
690
788
|
});
|
|
@@ -721,6 +819,7 @@ function emitDart(
|
|
|
721
819
|
? `export '${genRel}/nomos_client.dart';`
|
|
722
820
|
: `export 'package:nomos_client/nomos_client.dart';`,
|
|
723
821
|
...(hasFrameworkSymbols ? [`export '${genRel}/${FRAMEWORK_FILE}.dart';`] : []),
|
|
822
|
+
...(contractsImportUri !== undefined ? [`export '${genRel}/${CONTRACTS_FILE}.dart';`] : []),
|
|
724
823
|
...modules.flatMap((m) => [
|
|
725
824
|
`export '${genRel}/${m.name}.dart';`,
|
|
726
825
|
`export '${genRel}/${m.name}.client.dart';`,
|
|
@@ -857,18 +956,29 @@ function emitDartPackages(
|
|
|
857
956
|
// ── the TYPES package — pure data, `nomos_types` only ──
|
|
858
957
|
const typesGen = freshGenDir(packages.types);
|
|
859
958
|
const typesSources = new Map<string, string>(); // fileBase → src (for alias validation + purity)
|
|
959
|
+
// The SIBLING generated type files a types-package file MAY import (a cross-domain `t.foreignRef` types its
|
|
960
|
+
// field as the target aggregate's class, which lives in the target domain's file). A sibling types file is
|
|
961
|
+
// still PURE (no runtime/bridge surface — the purity gate exists to keep nomos_client out, not to isolate
|
|
962
|
+
// domain files from each other; Dart allows type-import cycles for mutual refs).
|
|
963
|
+
const siblingTypeFiles = new Set<string>([...modules.map((m) => `${m.name}.dart`), `${FRAMEWORK_FILE}.dart`, `${CONTRACTS_FILE}.dart`]);
|
|
860
964
|
const writeTypes = (fileBase: string, src: string): void => {
|
|
861
965
|
if (!src.includes(DART_PACKAGE_IMPORT)) {
|
|
862
966
|
fail(`generated Dart for '${fileBase}' does not carry the expected support import — codegen_dart header drifted`);
|
|
863
967
|
}
|
|
864
968
|
const pure = src.replace(DART_PACKAGE_IMPORT, DART_TYPES_IMPORT);
|
|
865
|
-
// PURITY, fail-closed: only the nomos_types barrel
|
|
969
|
+
// PURITY, fail-closed: only the nomos_types barrel, the framework file, and SIBLING generated type files
|
|
970
|
+
// (cross-domain foreignRef targets) may be imported. Runtime/bridge surfaces (nomos_client) stay out.
|
|
866
971
|
for (const line of pure.split("\n")) {
|
|
867
972
|
const m = /^import\s+'([^']+)'/.exec(line.trim());
|
|
868
|
-
if (
|
|
973
|
+
if (
|
|
974
|
+
m !== null &&
|
|
975
|
+
m[1] !== "package:nomos_types/nomos_types.dart" &&
|
|
976
|
+
m[1] !== `${FRAMEWORK_FILE}.dart` &&
|
|
977
|
+
!siblingTypeFiles.has(m[1]!)
|
|
978
|
+
) {
|
|
869
979
|
fail(
|
|
870
980
|
`types-package purity: generated '${fileBase}.dart' imports '${m[1]}' — a types file may only ` +
|
|
871
|
-
`import the nomos_types core
|
|
981
|
+
`import the nomos_types core, the framework file, or a sibling generated domain file`,
|
|
872
982
|
);
|
|
873
983
|
}
|
|
874
984
|
}
|
|
@@ -879,11 +989,42 @@ function emitDartPackages(
|
|
|
879
989
|
};
|
|
880
990
|
|
|
881
991
|
if (shared.frameworkMod !== undefined) writeTypes(FRAMEWORK_FILE, generateDartDomain(shared.frameworkMod));
|
|
992
|
+
// DOMAIN CONTRACTS (layered): the shared `contracts.dart` DTO file in the TYPES package (pure —
|
|
993
|
+
// nomos_types + VOs). A contract-target foreignRef imports THIS sibling, never the concrete domain.
|
|
994
|
+
const packageContracts = collectPackageContracts(modules);
|
|
995
|
+
const contractsImportUri = packageContracts.length > 0 ? `${CONTRACTS_FILE}.dart` : undefined;
|
|
996
|
+
if (packageContracts.length > 0) writeTypes(CONTRACTS_FILE, generateDartContracts(packageContracts));
|
|
997
|
+
// CROSS-DOMAIN foreignRef (layered): map aggregate id → its domain's type file, so a foreignRef field's
|
|
998
|
+
// target class resolves via a sibling import (the purity gate now permits sibling type files).
|
|
999
|
+
const aggIdToTypeFile = new Map<string, string>();
|
|
1000
|
+
for (const m of modules) for (const agg of (m.aggregates as { id: string }[])) aggIdToTypeFile.set(agg.id, m.name);
|
|
1001
|
+
// CIRCULAR-DEP GUARD (domain_contracts.md) — the layered path emits per-domain type files too, so the
|
|
1002
|
+
// same concrete-import-cycle guard applies (contract-mediated refs route via contracts.dart, no cycle).
|
|
1003
|
+
guardNoConcreteImportCycle(modules, aggIdToTypeFile);
|
|
882
1004
|
const perDomain = modules.map((mod) => ({ mod, dd: domainDedupOpts(mod, shared) }));
|
|
883
1005
|
for (const { mod, dd } of perDomain) {
|
|
1006
|
+
const frImports = new Set<string>();
|
|
1007
|
+
for (const agg of (dd.modForDart.aggregates as { fields: Record<string, { foreignRef?: { targetAggregateId: string; targetIsContract?: boolean } }> }[])) {
|
|
1008
|
+
for (const f of Object.values(agg.fields)) {
|
|
1009
|
+
const fr = f.foreignRef;
|
|
1010
|
+
if (fr === undefined) continue;
|
|
1011
|
+
// A contract target resolves via contracts.dart (the acyclic edge), not the concrete domain file.
|
|
1012
|
+
if (fr.targetIsContract === true) {
|
|
1013
|
+
if (contractsImportUri !== undefined) frImports.add(contractsImportUri);
|
|
1014
|
+
continue;
|
|
1015
|
+
}
|
|
1016
|
+
const tf = aggIdToTypeFile.get(fr.targetAggregateId);
|
|
1017
|
+
if (tf !== undefined && tf !== dd.modForDart.name) frImports.add(`${tf}.dart`);
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
const merged = [
|
|
1021
|
+
...(dd.modForDart.dartImports ?? []),
|
|
1022
|
+
...[...frImports].filter((uri) => !(dd.modForDart.dartImports ?? []).some((i) => i.uri === uri)).sort().map((uri) => ({ uri })),
|
|
1023
|
+
];
|
|
1024
|
+
const modForTypes = merged.length > 0 ? { ...dd.modForDart, dartImports: merged } : dd.modForDart;
|
|
884
1025
|
writeTypes(
|
|
885
1026
|
mod.name,
|
|
886
|
-
generateDartDomain(
|
|
1027
|
+
generateDartDomain(modForTypes, {
|
|
887
1028
|
...dd.skips,
|
|
888
1029
|
...(dd.foldsFramework ? { frameworkImportUri: `${FRAMEWORK_FILE}.dart` } : {}),
|
|
889
1030
|
}),
|
package/src/contract.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// NOMOS — DOMAIN CONTRACTS (architecture/domain_contracts.md). A CONTRACT is a named, stable PUBLIC SHAPE a
|
|
2
|
+
// domain exports so OTHER domains reference it WITHOUT importing the concrete aggregate implementation — the
|
|
3
|
+
// Nomos2 equivalent of Nomos1 "contracts". It preserves a layered, acyclic domain graph: everything depends
|
|
4
|
+
// on the shared contracts layer, never on each other's private generated types.
|
|
5
|
+
//
|
|
6
|
+
// A contract is PURE TYPING/CODEGEN metadata — NOT hashed law. The kernel folds aggregates, not contracts; an
|
|
7
|
+
// aggregate's domainHash moves only when its OWN fields change. Contracts ride the IR as ADDITIVE metadata
|
|
8
|
+
// (like value objects — era-safe, omit-when-empty) and are stable-id tracked for evolution.
|
|
9
|
+
//
|
|
10
|
+
// const CatalogueListingContract = contract("CatalogueListing", { listingId: t.string(), name: t.string() });
|
|
11
|
+
// const CatalogueListing = aggregate("CatalogueListing", { listingId: t.string().merge(Lww), … })
|
|
12
|
+
// .implements(CatalogueListingContract); // ← compile-checked: the aggregate SATISFIES the shape
|
|
13
|
+
// const EstateAsset = aggregate("EstateAsset", {
|
|
14
|
+
// listing: t.foreignRef({ workspace: "catalogueWorkspaceName", target: CatalogueListingContract, domain: "catalogue" }),
|
|
15
|
+
// }); // ← references the CONTRACT, not the concrete aggregate
|
|
16
|
+
import type { Field } from "./fields.js";
|
|
17
|
+
|
|
18
|
+
/** A contract's declared field — the SAME `t.*` field builders aggregates use (the merge DRIVER is ignored;
|
|
19
|
+
* a contract is a shape, not stored state). So a contract field can be a scalar, an enum, a value object, or
|
|
20
|
+
* a ref, exactly like an aggregate field. */
|
|
21
|
+
export type ContractField = Field;
|
|
22
|
+
|
|
23
|
+
/** A named public shape a domain exports (and other domains reference). Structurally comparable to an
|
|
24
|
+
* aggregate for the `.implements` satisfaction check. */
|
|
25
|
+
export interface ContractHandle<
|
|
26
|
+
Id extends string = string,
|
|
27
|
+
F extends Record<string, ContractField> = Record<string, ContractField>,
|
|
28
|
+
> {
|
|
29
|
+
/** The wire id — the literal you passed in (the STABLE public name). */
|
|
30
|
+
readonly id: Id;
|
|
31
|
+
/** The contract's PUBLIC fields (name → typed shape). */
|
|
32
|
+
readonly fields: F;
|
|
33
|
+
/** Brand so a plain string / an aggregate is never accepted where a contract is required. */
|
|
34
|
+
readonly __isContract: true;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** True iff `x` is a contract handle (not an aggregate handle or a string). */
|
|
38
|
+
export function isContractHandle(x: unknown): x is ContractHandle {
|
|
39
|
+
return typeof x === "object" && x !== null && (x as { __isContract?: unknown }).__isContract === true;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Declare a domain CONTRACT — a named public shape. Reuse the `t.*` field builders for the fields (the merge
|
|
44
|
+
* driver is irrelevant — a contract is a shape). Export it; a concrete aggregate `.implements(...)` it, and
|
|
45
|
+
* other domains reference it via `t.ref` / `t.foreignRef`. Purely additive: a package with no contract emits
|
|
46
|
+
* byte-identically to before.
|
|
47
|
+
*/
|
|
48
|
+
export function contract<const Id extends string, const F extends Record<string, ContractField>>(
|
|
49
|
+
id: Id,
|
|
50
|
+
fields: F,
|
|
51
|
+
): ContractHandle<Id, F> {
|
|
52
|
+
if (typeof id !== "string" || id.length === 0) {
|
|
53
|
+
throw new Error("contract(id, fields): the id must be a non-empty string (the stable public name).");
|
|
54
|
+
}
|
|
55
|
+
if (typeof fields !== "object" || fields === null || Object.keys(fields).length === 0) {
|
|
56
|
+
throw new Error(`contract('${id}'): declare at least one field (a contract is a public shape).`);
|
|
57
|
+
}
|
|
58
|
+
return { id, fields, __isContract: true };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The result of checking whether an aggregate satisfies a contract's shape. */
|
|
62
|
+
export type ContractSatisfaction = { ok: true } | { ok: false; reason: string };
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Structural satisfaction check (`.implements`): the aggregate must carry EVERY contract field, each with a
|
|
66
|
+
* compatible KIND (and, for refs/VOs, a compatible target/VO name). EXTRA aggregate fields are fine — that is
|
|
67
|
+
* the whole point (private implementation stays behind the boundary). Pure — no IO. The compiler calls this
|
|
68
|
+
* at `aggregate(...).implements(contract)` and throws a typed error naming the field on a mismatch.
|
|
69
|
+
*/
|
|
70
|
+
export function satisfiesContract(
|
|
71
|
+
aggregateId: string,
|
|
72
|
+
aggregateFields: Record<string, Field>,
|
|
73
|
+
contractHandle: ContractHandle,
|
|
74
|
+
): ContractSatisfaction {
|
|
75
|
+
for (const [name, cField] of Object.entries(contractHandle.fields)) {
|
|
76
|
+
const aField = aggregateFields[name];
|
|
77
|
+
if (aField === undefined) {
|
|
78
|
+
return {
|
|
79
|
+
ok: false,
|
|
80
|
+
reason:
|
|
81
|
+
`aggregate '${aggregateId}' does not satisfy contract '${contractHandle.id}': missing field '${name}' ` +
|
|
82
|
+
`(the contract requires a '${cField.kind}' field named '${name}'). Add it, or drop it from the contract.`,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
// KIND compatibility. A contract's `ref`/`foreignRef` matches the aggregate's `ref`/`foreignRef` (both
|
|
86
|
+
// store an id string); the counter/position kinds read-collapse to int/string, so a contract may declare
|
|
87
|
+
// the collapsed kind. Everything else must match exactly.
|
|
88
|
+
const norm = (k: string): string => (k === "counter" ? "int" : k === "position" ? "string" : k);
|
|
89
|
+
if (norm(aField.kind) !== norm(cField.kind)) {
|
|
90
|
+
return {
|
|
91
|
+
ok: false,
|
|
92
|
+
reason:
|
|
93
|
+
`aggregate '${aggregateId}' does not satisfy contract '${contractHandle.id}': field '${name}' is ` +
|
|
94
|
+
`'${aField.kind}' but the contract requires '${cField.kind}'. Align the field type.`,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
// For a value-object field, the VO NAME must match (same public structured shape).
|
|
98
|
+
const aVo = aField.valueObject?.name;
|
|
99
|
+
const cVo = cField.valueObject?.name;
|
|
100
|
+
if (cVo !== undefined && aVo !== cVo) {
|
|
101
|
+
return {
|
|
102
|
+
ok: false,
|
|
103
|
+
reason:
|
|
104
|
+
`aggregate '${aggregateId}' does not satisfy contract '${contractHandle.id}': field '${name}' uses ` +
|
|
105
|
+
`value object '${aVo ?? "(none)"}' but the contract requires '${cVo}'.`,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return { ok: true };
|
|
110
|
+
}
|
package/src/fields.ts
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
import { z } from "zod";
|
|
18
18
|
import { Conflict, Counter, LastPosition, Lww, type Driver } from "./drivers.js";
|
|
19
19
|
import type { AggregateHandle } from "./aggregate.js";
|
|
20
|
+
import type { ContractHandle } from "./contract.js";
|
|
20
21
|
import type { FieldFacet } from "./principal_evidence.js";
|
|
21
22
|
|
|
22
23
|
/**
|
|
@@ -282,6 +283,10 @@ export interface Field<T = unknown, K extends FieldKind = FieldKind> {
|
|
|
282
283
|
readonly workspaceField: string;
|
|
283
284
|
readonly targetAggregateId: string;
|
|
284
285
|
readonly targetDomain?: string;
|
|
286
|
+
/** TRUE iff the target is a domain CONTRACT (a public shape), not a concrete aggregate — codegen then
|
|
287
|
+
* references the contract type (in the shared contracts layer) so the referring domain does not import
|
|
288
|
+
* the target's concrete implementation. */
|
|
289
|
+
readonly targetIsContract?: boolean;
|
|
285
290
|
};
|
|
286
291
|
/** For `t.ref` (the target) AND `t.hasMany` (the child type): the related aggregate's wire id. */
|
|
287
292
|
readonly refAggregateId?: string;
|
|
@@ -411,7 +416,7 @@ function makeField<T, K extends FieldKind = FieldKind>(
|
|
|
411
416
|
driver: Driver,
|
|
412
417
|
extra: {
|
|
413
418
|
refWorkspace?: string;
|
|
414
|
-
foreignRef?: { workspaceField: string; targetAggregateId: string; targetDomain?: string };
|
|
419
|
+
foreignRef?: { workspaceField: string; targetAggregateId: string; targetDomain?: string; targetIsContract?: boolean };
|
|
415
420
|
refAggregateId?: string;
|
|
416
421
|
/** LAZY target resolution — `t.ref(() => Agg)` / `t.hasMany(() => Agg)`. Breaks a BIDIRECTIONAL
|
|
417
422
|
* declaration cycle (a Parent's `hasMany(Child)` ⇄ a Child's `ref(Parent)`): the thunk is stored and
|
|
@@ -782,15 +787,17 @@ export const t = {
|
|
|
782
787
|
*/
|
|
783
788
|
foreignRef(opts: {
|
|
784
789
|
workspace: string;
|
|
785
|
-
target: AggregateHandle<string, Record<string, Field
|
|
790
|
+
target: AggregateHandle<string, Record<string, Field>> | ContractHandle,
|
|
786
791
|
domain?: string;
|
|
787
792
|
}): Field<string, "ref"> {
|
|
793
|
+
const isContract = (opts.target as { __isContract?: unknown }).__isContract === true;
|
|
788
794
|
return makeField<string, "ref">("ref", z.string(), Conflict, {
|
|
789
795
|
refAggregateId: opts.target.id,
|
|
790
796
|
foreignRef: {
|
|
791
797
|
workspaceField: opts.workspace,
|
|
792
798
|
targetAggregateId: opts.target.id,
|
|
793
799
|
...(opts.domain !== undefined ? { targetDomain: opts.domain } : {}),
|
|
800
|
+
...(isContract ? { targetIsContract: true } : {}),
|
|
794
801
|
},
|
|
795
802
|
});
|
|
796
803
|
},
|
package/src/index.ts
CHANGED
|
@@ -135,6 +135,7 @@ export {
|
|
|
135
135
|
// — naming no id. A directive's `.plan` calls these and returns `[]` (the recorded graph is the write
|
|
136
136
|
// surface). The dev never sees a "prim" — that is the GitHolon's internal OpenUSD IR.
|
|
137
137
|
export { create, existing, ensure, type AggregateRef } from "./authoring.js";
|
|
138
|
+
export { contract, isContractHandle, satisfiesContract, type ContractHandle, type ContractField, type ContractSatisfaction } from "./contract.js";
|
|
138
139
|
// THE CAPTURED READ — a plan reads O(1) DSL queries via `read(query, args)`; the result is committed as
|
|
139
140
|
// the intent's read footprint (deterministic replay + stale-premise detection). The dev calls typed DSL
|
|
140
141
|
// queries; the library routes them here. The host capability is `nomos.read` (twin of `nomos.rng`).
|
package/src/usd.ts
CHANGED
|
@@ -615,6 +615,64 @@ export interface UsdLayer {
|
|
|
615
615
|
*/
|
|
616
616
|
readonly usages?: readonly import("./value_object.js").ValueObjectUsage[];
|
|
617
617
|
};
|
|
618
|
+
/**
|
|
619
|
+
* DOMAIN CONTRACTS (`architecture/domain_contracts.md`) — the public DTO shapes the domain declares.
|
|
620
|
+
* ADDITIVE + hash-inert (like `nomosValueObjects`): omit-when-empty, so a contract-free domain is
|
|
621
|
+
* byte-identical. Each contract carries its public field shapes (name/kind, VO name, ref target) and
|
|
622
|
+
* the concrete aggregate ids that `.implements(...)` it. Drives the shared `contracts.dart` + TS interfaces.
|
|
623
|
+
*/
|
|
624
|
+
readonly nomosContracts?: {
|
|
625
|
+
readonly v: 1;
|
|
626
|
+
readonly contracts: readonly {
|
|
627
|
+
readonly id: string;
|
|
628
|
+
readonly fields: readonly { readonly name: string; readonly kind: string; readonly vo?: string; readonly refTarget?: string }[];
|
|
629
|
+
readonly implementedBy: readonly string[];
|
|
630
|
+
}[];
|
|
631
|
+
};
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/** The `nomosContracts` descriptor type — a domain's public DTO shapes (era-safe, hash-inert). */
|
|
635
|
+
export type NomosContracts = NonNullable<UsdLayer["nomosContracts"]>;
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* Build the additive `nomosContracts` descriptor for a module: each declared contract's public field
|
|
639
|
+
* shapes + the concrete aggregate ids that `.implements(...)` it. Returns undefined when the module
|
|
640
|
+
* declares no contract (omit-when-empty — a contract-free domain is byte-identical). SHARED by the
|
|
641
|
+
* whole-doc emit (`usd.ts`) and the layered emit (`usd_layers.ts`) so both produce identical bytes.
|
|
642
|
+
*/
|
|
643
|
+
export function buildNomosContracts(module: {
|
|
644
|
+
readonly aggregates: readonly { id: string; implementsContracts?: readonly string[] }[];
|
|
645
|
+
readonly contracts?: readonly import("./contract.js").ContractHandle[];
|
|
646
|
+
}): NomosContracts | undefined {
|
|
647
|
+
const declared = module.contracts;
|
|
648
|
+
if (declared === undefined || declared.length === 0) return undefined;
|
|
649
|
+
// implementedBy: aggregate ids per contract id (from `.implements(...)`), sorted for determinism.
|
|
650
|
+
const implByContract = new Map<string, string[]>();
|
|
651
|
+
for (const agg of module.aggregates) {
|
|
652
|
+
for (const cid of agg.implementsContracts ?? []) {
|
|
653
|
+
const l = implByContract.get(cid) ?? [];
|
|
654
|
+
l.push(agg.id);
|
|
655
|
+
implByContract.set(cid, l);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
const contracts = [...declared]
|
|
659
|
+
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
|
|
660
|
+
.map((c) => ({
|
|
661
|
+
id: c.id,
|
|
662
|
+
fields: Object.entries(c.fields).map(([name, f]) => {
|
|
663
|
+
const field = f as import("./fields.js").Field;
|
|
664
|
+
const vo = field.valueObject?.name;
|
|
665
|
+
const refTarget = field.foreignRef?.targetAggregateId ?? field.refAggregateId;
|
|
666
|
+
return {
|
|
667
|
+
name,
|
|
668
|
+
kind: field.kind,
|
|
669
|
+
...(vo !== undefined ? { vo } : {}),
|
|
670
|
+
...(refTarget !== undefined ? { refTarget } : {}),
|
|
671
|
+
};
|
|
672
|
+
}),
|
|
673
|
+
implementedBy: (implByContract.get(c.id) ?? []).slice().sort(),
|
|
674
|
+
}));
|
|
675
|
+
return { v: 1 as const, contracts };
|
|
618
676
|
}
|
|
619
677
|
|
|
620
678
|
/** The reserved variant name declaring a set's DEFAULT (USD has a default variant). */
|
|
@@ -1366,6 +1424,8 @@ export function emitUsd(
|
|
|
1366
1424
|
...(voUsages.length > 0 ? { usages: voUsages } : {}),
|
|
1367
1425
|
}
|
|
1368
1426
|
: undefined;
|
|
1427
|
+
// DOMAIN CONTRACTS: the additive `nomosContracts` descriptor (hash-inert, omit-when-empty).
|
|
1428
|
+
const nomosContracts = buildNomosContracts(l.module);
|
|
1369
1429
|
const flowEndorse = [...l.module.directives]
|
|
1370
1430
|
.flatMap((d) => harvestPrincipalFlows(d as never).map((f) => ({ directiveId: d.id, sink: f.sink, facet: f.facet, reason: f.reason })))
|
|
1371
1431
|
.sort((a, b) =>
|
|
@@ -1419,6 +1479,7 @@ export function emitUsd(
|
|
|
1419
1479
|
...(eraCertGate !== undefined ? { nomosEraCertGate: eraCertGate } : {}),
|
|
1420
1480
|
...(nomosForeignRefs !== undefined ? { nomosForeignRefs } : {}),
|
|
1421
1481
|
...(nomosValueObjects !== undefined ? { nomosValueObjects } : {}),
|
|
1482
|
+
...(nomosContracts !== undefined ? { nomosContracts } : {}),
|
|
1422
1483
|
...(Object.keys(visAggregates).length > 0
|
|
1423
1484
|
? { nomosReadVisibility: { v: 1 as const, aggregates: visAggregates } }
|
|
1424
1485
|
: {}),
|
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.nomosContracts !== undefined
|
|
210
|
+
? [`${indent}custom string nomos:contractsHex = ${usdaString(hexUtf8(JSON.stringify(layer.nomosContracts)))}`]
|
|
211
|
+
: []),
|
|
209
212
|
...(layer.nomosForeignRefs !== undefined
|
|
210
213
|
? [`${indent}custom string nomos:foreignRefsHex = ${usdaString(hexUtf8(JSON.stringify(layer.nomosForeignRefs)))}`]
|
|
211
214
|
: []),
|
|
@@ -953,6 +956,34 @@ export function flattenLayeredUsd(layers: readonly UsdLayer[]): UsdDocument {
|
|
|
953
956
|
}
|
|
954
957
|
let foreignRefs: UsdLayer["nomosForeignRefs"];
|
|
955
958
|
for (const l of stack) { if (l.nomosForeignRefs !== undefined) foreignRefs = l.nomosForeignRefs; }
|
|
959
|
+
// DOMAIN CONTRACTS: union the per-layer contract descriptors by id (later-wins on the shape),
|
|
960
|
+
// sorted by id — byte-identical to the composed emit (which sorts contracts by id). implementedBy
|
|
961
|
+
// is unioned across layers so a contract declared in one module and implemented in a same-layer
|
|
962
|
+
// aggregate stays intact. Omit-when-empty.
|
|
963
|
+
let contracts: UsdLayer["nomosContracts"];
|
|
964
|
+
{
|
|
965
|
+
type C = NonNullable<UsdLayer["nomosContracts"]>["contracts"][number];
|
|
966
|
+
const byId = new Map<string, C>();
|
|
967
|
+
const implById = new Map<string, Set<string>>();
|
|
968
|
+
for (const l of stack) {
|
|
969
|
+
if (l.nomosContracts === undefined) continue;
|
|
970
|
+
for (const c of l.nomosContracts.contracts) {
|
|
971
|
+
byId.set(c.id, c);
|
|
972
|
+
const s = implById.get(c.id) ?? new Set<string>();
|
|
973
|
+
for (const a of c.implementedBy) s.add(a);
|
|
974
|
+
implById.set(c.id, s);
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
if (byId.size > 0) {
|
|
978
|
+
contracts = {
|
|
979
|
+
v: 1,
|
|
980
|
+
contracts: [...byId.keys()].sort().map((id) => ({
|
|
981
|
+
...byId.get(id)!,
|
|
982
|
+
implementedBy: [...(implById.get(id) ?? [])].sort(),
|
|
983
|
+
})),
|
|
984
|
+
};
|
|
985
|
+
}
|
|
986
|
+
}
|
|
956
987
|
return {
|
|
957
988
|
path,
|
|
958
989
|
prims: [...prims.keys()].sort().map((p) => prims.get(p)!),
|
|
@@ -979,6 +1010,7 @@ return {
|
|
|
979
1010
|
...(eraCertGate !== undefined ? { nomosEraCertGate: eraCertGate } : {}),
|
|
980
1011
|
...(valueObjects !== undefined ? { nomosValueObjects: valueObjects } : {}),
|
|
981
1012
|
...(foreignRefs !== undefined ? { nomosForeignRefs: foreignRefs } : {}),
|
|
1013
|
+
...(contracts !== undefined ? { nomosContracts: contracts } : {}),
|
|
982
1014
|
};
|
|
983
1015
|
});
|
|
984
1016
|
|
|
@@ -1119,6 +1151,7 @@ export function parseUsdaDocument(text: string): UsdDocument {
|
|
|
1119
1151
|
const eraCertGateByDomain = new Map<string, UsdLayer["nomosEraCertGate"]>();
|
|
1120
1152
|
const valueObjectsByDomain = new Map<string, UsdLayer["nomosValueObjects"]>();
|
|
1121
1153
|
const foreignRefsByDomain = new Map<string, UsdLayer["nomosForeignRefs"]>();
|
|
1154
|
+
const contractsByDomain = new Map<string, UsdLayer["nomosContracts"]>();
|
|
1122
1155
|
for (const p of prims) {
|
|
1123
1156
|
if (p.attrs.get("nomos:kind") === "domain") {
|
|
1124
1157
|
const domain = attrString(p, "nomos:domain");
|
|
@@ -1218,6 +1251,11 @@ export function parseUsdaDocument(text: string): UsdDocument {
|
|
|
1218
1251
|
if (typeof frHex === "string") {
|
|
1219
1252
|
foreignRefsByDomain.set(domain, JSON.parse(unhexUtf8(frHex)) as UsdLayer["nomosForeignRefs"]);
|
|
1220
1253
|
}
|
|
1254
|
+
// DOMAIN CONTRACTS: the hex-carried public DTO descriptor set (descriptive, not a gate).
|
|
1255
|
+
const ctHex = p.attrs.get("nomos:contractsHex");
|
|
1256
|
+
if (typeof ctHex === "string") {
|
|
1257
|
+
contractsByDomain.set(domain, JSON.parse(unhexUtf8(ctHex)) as UsdLayer["nomosContracts"]);
|
|
1258
|
+
}
|
|
1221
1259
|
}
|
|
1222
1260
|
}
|
|
1223
1261
|
|
|
@@ -1329,6 +1367,7 @@ export function parseUsdaDocument(text: string): UsdDocument {
|
|
|
1329
1367
|
const eraCertGate = eraCertGateByDomain.get(domain);
|
|
1330
1368
|
const valueObjects = valueObjectsByDomain.get(domain);
|
|
1331
1369
|
const foreignRefs2 = foreignRefsByDomain.get(domain);
|
|
1370
|
+
const contracts2 = contractsByDomain.get(domain);
|
|
1332
1371
|
return {
|
|
1333
1372
|
path: `/Nomos/${domain}`,
|
|
1334
1373
|
prims: [...b.prims].sort(byPath),
|
|
@@ -1355,6 +1394,7 @@ export function parseUsdaDocument(text: string): UsdDocument {
|
|
|
1355
1394
|
...(eraCertGate !== undefined ? { nomosEraCertGate: eraCertGate } : {}),
|
|
1356
1395
|
...(valueObjects !== undefined ? { nomosValueObjects: valueObjects } : {}),
|
|
1357
1396
|
...(foreignRefs2 !== undefined ? { nomosForeignRefs: foreignRefs2 } : {}),
|
|
1397
|
+
...(contracts2 !== undefined ? { nomosContracts: contracts2 } : {}),
|
|
1358
1398
|
};
|
|
1359
1399
|
})
|
|
1360
1400
|
.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|