@githolon/dsl 0.1.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/LICENSE.md +36 -0
- package/compile_package.mjs +50 -0
- package/package.json +59 -0
- package/src/aggregate.ts +167 -0
- package/src/authoring.ts +119 -0
- package/src/build_package.ts +636 -0
- package/src/certified_read.ts +313 -0
- package/src/codegen_dart.ts +2732 -0
- package/src/codegen_dot.ts +466 -0
- package/src/codegen_provider_dart.ts +358 -0
- package/src/codegen_ts.ts +365 -0
- package/src/codegen_usda.ts +388 -0
- package/src/combined.ts +195 -0
- package/src/compile_engine.ts +567 -0
- package/src/compile_package_main.ts +496 -0
- package/src/compose.ts +317 -0
- package/src/count.ts +218 -0
- package/src/ctx.ts +57 -0
- package/src/derived.ts +138 -0
- package/src/directive.ts +306 -0
- package/src/drivers.ts +95 -0
- package/src/emits_guard.ts +123 -0
- package/src/engine_entry.ts +449 -0
- package/src/exists.ts +170 -0
- package/src/extremum.ts +227 -0
- package/src/fields.ts +291 -0
- package/src/framework/bootstrap.ts +22 -0
- package/src/framework/disclosure.ts +108 -0
- package/src/framework/domain_lifecycle.ts +108 -0
- package/src/framework/identity.ts +537 -0
- package/src/framework/impure_capability.ts +643 -0
- package/src/framework/rbac.ts +418 -0
- package/src/framework/repair.ts +150 -0
- package/src/framework/sync_lifecycle.ts +125 -0
- package/src/framework/workspace_invariant.ts +128 -0
- package/src/framework/workspaces.ts +817 -0
- package/src/index.ts +317 -0
- package/src/manifest.ts +947 -0
- package/src/ops.ts +145 -0
- package/src/ordered_read.ts +228 -0
- package/src/predicate.ts +203 -0
- package/src/query/compile.ts +0 -0
- package/src/query/relations.ts +144 -0
- package/src/query.ts +151 -0
- package/src/read.ts +54 -0
- package/src/relation.ts +189 -0
- package/src/report/csv.ts +54 -0
- package/src/report.ts +401 -0
- package/src/spatial.ts +115 -0
- package/src/sum.ts +194 -0
- package/src/usd.ts +563 -0
- package/src/wire.ts +149 -0
- package/src/wire_encode.ts +250 -0
package/LICENSE.md
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Nomos Pre-Release License (v1)
|
|
2
|
+
|
|
3
|
+
Copyright © 2026 Captain App Ltd. All rights reserved.
|
|
4
|
+
|
|
5
|
+
This is a pre-release of Nomos. This license gives you what you need to BUILD
|
|
6
|
+
with it; we keep the rest for now.
|
|
7
|
+
|
|
8
|
+
## You may
|
|
9
|
+
|
|
10
|
+
- install and use these packages to author Nomos domains, compile them, and
|
|
11
|
+
deploy them to Nomos Cloud or any Nomos instance Captain App operates or
|
|
12
|
+
authorizes;
|
|
13
|
+
- build, run, and ship applications on top of them — including commercial ones;
|
|
14
|
+
- keep everything that's yours: code you write, and everything these tools
|
|
15
|
+
generate FOR you (scaffolds from `create-holon` / `holon generate`, generated
|
|
16
|
+
clients, compiled domain packages) carries NO restriction from us — it is
|
|
17
|
+
yours outright.
|
|
18
|
+
|
|
19
|
+
## You may not
|
|
20
|
+
|
|
21
|
+
- redistribute these packages or their source, in whole or in part, outside
|
|
22
|
+
your team;
|
|
23
|
+
- modify them or build derivative tools, SDKs, or runtimes from their source;
|
|
24
|
+
- offer the Nomos runtime, or anything materially similar, as a hosted service;
|
|
25
|
+
- reverse-engineer the holon wasm runtime.
|
|
26
|
+
|
|
27
|
+
## The rest
|
|
28
|
+
|
|
29
|
+
Provided **as is**, with no warranty of any kind; to the maximum extent
|
|
30
|
+
permitted by law, Captain App Ltd accepts no liability arising from your use.
|
|
31
|
+
This license terminates automatically if you breach it.
|
|
32
|
+
|
|
33
|
+
Want the kernel source, broader rights, or to do something this doesn't cover?
|
|
34
|
+
**Ask: jack@captainapp.co.uk.** The plan is to open Nomos up gradually, with
|
|
35
|
+
the people actually using it — telling us what you're building is how that
|
|
36
|
+
happens faster.
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// NOMOS — Nomos Sovereign: participants act · verify · remember LOCALLY; hosted
|
|
3
|
+
// remotes are replaceable custody/transport, not truth. ⇒ ONE Nomos GitHolon
|
|
4
|
+
// wasm32-wasip1 artifact {kernel · projection · embedded
|
|
5
|
+
// QuickJS engine} on V8 + WASI-shim, byte-identical everywhere.
|
|
6
|
+
// If a file isn't this / hosting this / authoring for this / proving this — it's gone.
|
|
7
|
+
|
|
8
|
+
// `nomos-compile` / `holon-compile` — the thin launcher: resolve `tsx` (the
|
|
9
|
+
// caller's, else the DSL's own dependency) and run `src/compile_package_main.ts`
|
|
10
|
+
// under it (the main dynamically imports the caller's TS domain modules, which
|
|
11
|
+
// needs the tsx ESM loader). tsx is a regular dependency of @githolon/dsl, so a bare
|
|
12
|
+
// `npx holon compile` / `npx nomos-compile` works with zero devDependency setup.
|
|
13
|
+
// Resolution goes through Node's resolver (createRequire), NOT node_modules/.bin
|
|
14
|
+
// paths — hoisted/workspace installs put the bin shims elsewhere.
|
|
15
|
+
import { execFileSync } from "node:child_process";
|
|
16
|
+
import { readFileSync } from "node:fs";
|
|
17
|
+
import { createRequire } from "node:module";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
20
|
+
|
|
21
|
+
const DSL_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
22
|
+
const MAIN = path.join(DSL_DIR, "src", "compile_package_main.ts");
|
|
23
|
+
|
|
24
|
+
/** Resolve a dependency's package dir from `fromDir`, walking Node's resolver. */
|
|
25
|
+
function resolvePkgDir(name, fromDir) {
|
|
26
|
+
try {
|
|
27
|
+
const req = createRequire(pathToFileURL(path.join(fromDir, "noop.js")));
|
|
28
|
+
return path.dirname(req.resolve(`${name}/package.json`));
|
|
29
|
+
} catch {
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const tsxDir = resolvePkgDir("tsx", process.cwd()) ?? resolvePkgDir("tsx", DSL_DIR);
|
|
35
|
+
if (!tsxDir) {
|
|
36
|
+
console.error("nomos-compile: tsx not found — reinstall @githolon/dsl (tsx is one of its dependencies)");
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
const tsxPkg = JSON.parse(readFileSync(path.join(tsxDir, "package.json"), "utf8"));
|
|
40
|
+
const tsxBinRel = typeof tsxPkg.bin === "string" ? tsxPkg.bin : tsxPkg.bin?.tsx;
|
|
41
|
+
const tsxCli = path.join(tsxDir, tsxBinRel ?? "dist/cli.mjs");
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
execFileSync(process.execPath, [tsxCli, MAIN, ...process.argv.slice(2)], {
|
|
45
|
+
stdio: "inherit",
|
|
46
|
+
cwd: process.cwd(),
|
|
47
|
+
});
|
|
48
|
+
} catch (e) {
|
|
49
|
+
process.exit(e.status ?? 1);
|
|
50
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@githolon/dsl",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Nomos 2 domain-authoring DSL: aggregates + directives in TS, executed and encoded to the Rust kernel's wire shapes.",
|
|
6
|
+
"license": "SEE LICENSE IN LICENSE.md",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/Captain-App/nomos2.git",
|
|
10
|
+
"directory": "dsl"
|
|
11
|
+
},
|
|
12
|
+
"main": "src/index.ts",
|
|
13
|
+
"bin": {
|
|
14
|
+
"nomos-compile": "./compile_package.mjs",
|
|
15
|
+
"holon-compile": "./compile_package.mjs"
|
|
16
|
+
},
|
|
17
|
+
"exports": {
|
|
18
|
+
".": "./src/index.ts",
|
|
19
|
+
"./manifest": "./src/manifest.ts",
|
|
20
|
+
"./compose": "./src/compose.ts",
|
|
21
|
+
"./usd": "./src/usd.ts",
|
|
22
|
+
"./engine-entry": "./src/engine_entry.ts",
|
|
23
|
+
"./build-package": "./src/build_package.ts",
|
|
24
|
+
"./compile-engine": "./src/compile_engine.ts",
|
|
25
|
+
"./codegen-dart": "./src/codegen_dart.ts",
|
|
26
|
+
"./codegen-dot": "./src/codegen_dot.ts",
|
|
27
|
+
"./codegen-provider": "./src/codegen_provider_dart.ts",
|
|
28
|
+
"./framework/identity": "./src/framework/identity.ts",
|
|
29
|
+
"./framework/disclosure": "./src/framework/disclosure.ts",
|
|
30
|
+
"./framework/bootstrap": "./src/framework/bootstrap.ts",
|
|
31
|
+
"./framework/domain_lifecycle": "./src/framework/domain_lifecycle.ts",
|
|
32
|
+
"./framework/sync_lifecycle": "./src/framework/sync_lifecycle.ts",
|
|
33
|
+
"./framework/repair": "./src/framework/repair.ts",
|
|
34
|
+
"./framework/workspaces": "./src/framework/workspaces.ts",
|
|
35
|
+
"./package.json": "./package.json"
|
|
36
|
+
},
|
|
37
|
+
"files": [
|
|
38
|
+
"src",
|
|
39
|
+
"!src/**/*.test.ts",
|
|
40
|
+
"compile_package.mjs"
|
|
41
|
+
],
|
|
42
|
+
"publishConfig": {
|
|
43
|
+
"access": "public"
|
|
44
|
+
},
|
|
45
|
+
"scripts": {
|
|
46
|
+
"typecheck": "tsc --noEmit",
|
|
47
|
+
"test": "vitest run"
|
|
48
|
+
},
|
|
49
|
+
"dependencies": {
|
|
50
|
+
"esbuild": "0.24.2",
|
|
51
|
+
"tsx": "^4.19.2",
|
|
52
|
+
"zod": "^4.4.3"
|
|
53
|
+
},
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"@types/node": "^25.9.2",
|
|
56
|
+
"typescript": "^5.6.3",
|
|
57
|
+
"vitest": "^2.1.8"
|
|
58
|
+
}
|
|
59
|
+
}
|
package/src/aggregate.ts
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// NOMOS — Nomos Sovereign: participants act · verify · remember LOCALLY; hosted
|
|
2
|
+
// remotes are replaceable custody/transport, not truth. ⇒ ONE Nomos GitHolon
|
|
3
|
+
// wasm32-wasip1 artifact {kernel · projection · embedded
|
|
4
|
+
// QuickJS engine} on V8 + WASI-shim, byte-identical everywhere. V8 = portability; the one
|
|
5
|
+
// wasm = determinism. No native, no wasmtime, no 2nd artifact, no domain-JS on bare V8.
|
|
6
|
+
// If a file isn't this / hosting this / authoring for this / proving this — it's gone.
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* `aggregate(id, fields)` -> a typed handle.
|
|
10
|
+
*
|
|
11
|
+
* The stable string id is declared ONCE, here, at the declaration site. That
|
|
12
|
+
* literal IS the wire id (it's string DATA, so it's minify-safe — never a
|
|
13
|
+
* renamed symbol). Everywhere else you reference the returned handle, never the
|
|
14
|
+
* string: a typo'd handle is a compile error.
|
|
15
|
+
*/
|
|
16
|
+
import type { Field } from "./fields.js";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A pure, replay-stable predicate over one aggregate's PROJECTED snapshot —
|
|
20
|
+
* evaluated BY THE ENGINE after each event-apply. Returns a TYPED verdict.
|
|
21
|
+
* PURE over the snapshot (no clock, no IO, no live reads); runs in the sealed engine.
|
|
22
|
+
* PRESENCE ONLY is hashed in the manifest (like a directive `plan`); the body ships
|
|
23
|
+
* in the engine bundle, never in the ledger.
|
|
24
|
+
*/
|
|
25
|
+
export type AggregateInvariantVerdict = { accept: true } | { reject: string };
|
|
26
|
+
export type AggregateInvariantFn = (snapshot: Record<string, unknown>) => AggregateInvariantVerdict;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* An aggregate has two genuinely different kinds of member, and the DSL keeps them
|
|
30
|
+
* APART so no downstream layer ever has to re-derive the distinction:
|
|
31
|
+
*
|
|
32
|
+
* • STORED FIELDS (`fields`) — real data with a leaf kind (string/int/ref/enum/…).
|
|
33
|
+
* The engine projects, decodes, hashes and wires these. Every manifest / wire /
|
|
34
|
+
* codegen / projection consumer iterates `fields` and is GUARANTEED never to meet
|
|
35
|
+
* a virtual kind — so none of them special-case anything.
|
|
36
|
+
* • VIRTUAL INVERSES (`hasMany`) — `t.hasMany(Child).via("backref")` relations. NOT
|
|
37
|
+
* stored: they derive the inverse read index (`Child by backref`) and the `.add`
|
|
38
|
+
* back-ref write. Consumed ONLY by the two layers that own the 1:N read side
|
|
39
|
+
* (`hasManyIndexes`) and the `.add` authoring dispatch.
|
|
40
|
+
*
|
|
41
|
+
* The dev still declares both inline in one object (easy to model); `aggregate()`
|
|
42
|
+
* partitions them here, ONCE — the only place that knows `hasMany` is virtual.
|
|
43
|
+
*/
|
|
44
|
+
type IsHasMany<Fld> = Fld extends Field<unknown, "hasMany"> ? true : false;
|
|
45
|
+
/** The STORED subset of a declared field record — everything except virtual `t.hasMany`. */
|
|
46
|
+
export type StoredFields<F extends Record<string, Field>> = {
|
|
47
|
+
[K in keyof F as IsHasMany<F[K]> extends true ? never : K]: F[K];
|
|
48
|
+
};
|
|
49
|
+
/** The VIRTUAL `t.hasMany` inverse subset of a declared field record. */
|
|
50
|
+
export type HasManyFields<F extends Record<string, Field>> = {
|
|
51
|
+
[K in keyof F as IsHasMany<F[K]> extends true ? K : never]: F[K];
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** Runtime partition of a declared field record into stored fields + virtual `hasMany` inverses. */
|
|
55
|
+
function partitionFields(fields: Record<string, Field>): {
|
|
56
|
+
stored: Record<string, Field>;
|
|
57
|
+
hasMany: Record<string, Field>;
|
|
58
|
+
} {
|
|
59
|
+
const stored: Record<string, Field> = {};
|
|
60
|
+
const hasMany: Record<string, Field> = {};
|
|
61
|
+
for (const [name, field] of Object.entries(fields)) {
|
|
62
|
+
if (field.kind === "hasMany") hasMany[name] = field;
|
|
63
|
+
else stored[name] = field;
|
|
64
|
+
}
|
|
65
|
+
return { stored, hasMany };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface AggregateHandle<
|
|
69
|
+
Id extends string = string,
|
|
70
|
+
F extends Record<string, Field> = Record<string, Field>,
|
|
71
|
+
> {
|
|
72
|
+
/** The wire id — the literal you passed in. */
|
|
73
|
+
readonly id: Id;
|
|
74
|
+
/** STORED fields only — real data; `keyof` is what op helpers check field names against. */
|
|
75
|
+
readonly fields: StoredFields<F>;
|
|
76
|
+
/** VIRTUAL `t.hasMany` inverses — empty object when the aggregate declares none. */
|
|
77
|
+
readonly hasMany: HasManyFields<F>;
|
|
78
|
+
/** Brand so a plain string is never accepted where a handle is required. */
|
|
79
|
+
readonly __isAggregateHandle: true;
|
|
80
|
+
/** Presence-only flag (hashed); OMITTED ENTIRELY when no invariant is declared. */
|
|
81
|
+
readonly hasInvariant?: true;
|
|
82
|
+
/** The executable body — ships in the engine bundle, NOT the manifest. */
|
|
83
|
+
readonly invariant?: AggregateInvariantFn;
|
|
84
|
+
/**
|
|
85
|
+
* The LAW-DECLARED instance CAP (Jack's ruling: instance limits are DOMAIN POLICY,
|
|
86
|
+
* never a fabric id-shape special case). `cap: 1` = at most one instance per
|
|
87
|
+
* workspace — the admission gate refuses an over-cap Create with a clear message.
|
|
88
|
+
* Carried in the canonical manifest (hash-bearing); OMITTED when uncapped so
|
|
89
|
+
* cap-free domains hash byte-identically to before this key existed.
|
|
90
|
+
*/
|
|
91
|
+
readonly cap?: number;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function aggregate<const Id extends string, F extends Record<string, Field>>(
|
|
95
|
+
id: Id,
|
|
96
|
+
fields: F,
|
|
97
|
+
opts?: { invariant?: AggregateInvariantFn; cap?: number },
|
|
98
|
+
): AggregateHandle<Id, F> {
|
|
99
|
+
const { stored, hasMany } = partitionFields(fields);
|
|
100
|
+
if (opts?.cap !== undefined && (!Number.isInteger(opts.cap) || opts.cap < 1)) {
|
|
101
|
+
throw new Error(`aggregate '${id}': cap must be a positive integer (got ${opts.cap})`);
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
id,
|
|
105
|
+
fields: stored as StoredFields<F>,
|
|
106
|
+
hasMany: hasMany as HasManyFields<F>,
|
|
107
|
+
__isAggregateHandle: true,
|
|
108
|
+
...(opts?.invariant !== undefined
|
|
109
|
+
? { hasInvariant: true as const, invariant: opts.invariant }
|
|
110
|
+
: {}),
|
|
111
|
+
...(opts?.cap !== undefined ? { cap: opts.cap } : {}),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* A bound aggregate reference — a handle pinned to a concrete INSTANCE id.
|
|
117
|
+
*
|
|
118
|
+
* #105 (instance binding): an `AggregateHandle.id` is the aggregate TYPE literal
|
|
119
|
+
* (e.g. `Thing`), correct only for one-per-workspace aggregates. Every
|
|
120
|
+
* multi-instance aggregate (Thing, Node, Container, …) needs a per-instance
|
|
121
|
+
* address. `instance(Thing, "thing-a")` returns a `BoundAggregate` carrying BOTH
|
|
122
|
+
* the runtime instance `id` AND the static `type` (+ the handle's `fields`, so op
|
|
123
|
+
* helpers keep their `keyof`-checked field/value typing). Op helpers accept either
|
|
124
|
+
* a bare handle (unbound → `aggregateId = type`, the legacy one-per-workspace
|
|
125
|
+
* shape) or a bound ref (→ `aggregateId = instanceId`, `aggregateType = type`).
|
|
126
|
+
*/
|
|
127
|
+
export interface BoundAggregate<
|
|
128
|
+
Id extends string = string,
|
|
129
|
+
F extends Record<string, Field> = Record<string, Field>,
|
|
130
|
+
> {
|
|
131
|
+
/** The concrete instance id — what reaches `WireEvent.aggregate`. */
|
|
132
|
+
readonly id: string;
|
|
133
|
+
/** The aggregate TYPE (the handle's static id) — surfaced to `view()` via `__type`. */
|
|
134
|
+
readonly type: Id;
|
|
135
|
+
/** The STORED field map, carried through so op helpers keep field-name/value typing.
|
|
136
|
+
* (Op-helper typing infers off the `F` param, so virtual `hasMany` is never settable.) */
|
|
137
|
+
readonly fields: StoredFields<F>;
|
|
138
|
+
/** Brand: a bound ref is distinct from a bare handle. */
|
|
139
|
+
readonly __isBoundAggregate: true;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Bind an aggregate handle to a concrete instance id. ADDITIVE: the handle itself
|
|
144
|
+
* is unchanged; `instance(h, id)` is the opt-in per-instance address. Op helpers
|
|
145
|
+
* (`set`/`addToSet`/`setEntry`) accept the returned ref and lower it to a
|
|
146
|
+
* `PlannedOp` carrying the instance id AND the aggregate type.
|
|
147
|
+
*/
|
|
148
|
+
export function instance<Id extends string, F extends Record<string, Field>>(
|
|
149
|
+
handle: AggregateHandle<Id, F>,
|
|
150
|
+
id: string,
|
|
151
|
+
): BoundAggregate<Id, F> {
|
|
152
|
+
return { id, type: handle.id, fields: handle.fields, __isBoundAggregate: true };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* A name-only handle for a FORWARD / CIRCULAR `t.ref` target — an aggregate referenced
|
|
157
|
+
* before its `const` is declared (e.g. `Estate.headquartersSiteId -> Site` while
|
|
158
|
+
* `Site.estateId -> Estate`). `t.ref` reads only the target's wire `.id`, so a
|
|
159
|
+
* fields-less, relation-less stand-in is a faithful, minify-safe forward declaration.
|
|
160
|
+
*
|
|
161
|
+
* Use this instead of hand-building `{ id, fields: {}, hasMany: {}, __isAggregateHandle }`
|
|
162
|
+
* so the handle shape stays in exactly ONE place — add a member to `AggregateHandle`
|
|
163
|
+
* and every forward ref keeps compiling for free.
|
|
164
|
+
*/
|
|
165
|
+
export function forwardRef<const Id extends string>(id: Id): AggregateHandle<Id, Record<string, never>> {
|
|
166
|
+
return { id, fields: {}, hasMany: {}, __isAggregateHandle: true };
|
|
167
|
+
}
|
package/src/authoring.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// NOMOS — Nomos Sovereign: participants act · verify · remember LOCALLY; hosted
|
|
2
|
+
// remotes are replaceable custody/transport, not truth. ⇒ ONE Nomos GitHolon
|
|
3
|
+
// wasm32-wasip1 artifact {kernel · projection · embedded
|
|
4
|
+
// QuickJS engine}. If a file isn't this / hosting this / authoring for this / proving this — it's gone.
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* THE AGGREGATE AUTHORING SURFACE — Nomos owns every birth; the dev talks DDD (Jack 2026-06-08).
|
|
8
|
+
*
|
|
9
|
+
* This is LIBRARY code (`@githolon/dsl`). It uses exactly ONE host capability — `nomos.mint(typeTag)` (a
|
|
10
|
+
* captured-entropy id) — and does everything else itself: it turns `create()` / `.set` / `.add` /
|
|
11
|
+
* `.relate` into ordinary `PlannedOp`s (the same shape the typed `set`/`addToSet`/`setEntry` produce),
|
|
12
|
+
* which `executeDirectiveToIntent` groups into kernel events. The engine never records wire events directly.
|
|
13
|
+
*
|
|
14
|
+
* const child = create(Child).set("name", p.name);
|
|
15
|
+
* parent.add("children", child); // ← writes child.parent = parent.id (ONE event, on the child)
|
|
16
|
+
*
|
|
17
|
+
* `.add` DISPATCHES on the declared field kind: a `t.hasMany(Child).via("parent")` field writes the
|
|
18
|
+
* child's back-reference (the cheap N:1 — parent untouched, no growing set); a bounded `t.set(...)` field
|
|
19
|
+
* does an `AddToSet` on the aggregate itself. A dev therefore cannot build a write-amplifying parent
|
|
20
|
+
* collection — see `docs/aggregate_lifecycle_and_relations.md`.
|
|
21
|
+
*
|
|
22
|
+
* Ops are collected in a per-dispatch SINK (library-side state, invisible to the engine); `executeDirectiveToIntent`
|
|
23
|
+
* resets it before the plan and drains it after, merging the authored ops with whatever the plan returns.
|
|
24
|
+
* So a `.plan` that fluently authors and `return []`s still emits its events — the recorded ops are real.
|
|
25
|
+
*/
|
|
26
|
+
import type { AggregateHandle } from "./aggregate.js";
|
|
27
|
+
import type { Field } from "./fields.js";
|
|
28
|
+
import type { FieldOp, PlannedOp } from "./ops.js";
|
|
29
|
+
|
|
30
|
+
// ── the per-dispatch authoring sink — library state the engine never sees (NOT a side channel out of
|
|
31
|
+
// the sandbox; it is internal to the directive runtime, drained synchronously by `executeDirectiveToIntent`). ──
|
|
32
|
+
let sink: PlannedOp[] = [];
|
|
33
|
+
/** Clear the sink before a plan runs (called by `executeDirectiveToIntent`). */
|
|
34
|
+
export function __resetAuthoring(): void {
|
|
35
|
+
sink = [];
|
|
36
|
+
}
|
|
37
|
+
/** Take + clear the ops authored via the fluent surface during a plan (called by `executeDirectiveToIntent`). */
|
|
38
|
+
export function __drainAuthoring(): PlannedOp[] {
|
|
39
|
+
const s = sink;
|
|
40
|
+
sink = [];
|
|
41
|
+
return s;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface NomosMint {
|
|
45
|
+
mint(typeTag: string): string;
|
|
46
|
+
}
|
|
47
|
+
/** The ONE host capability this surface uses — a captured, typed, guaranteed-unique id. */
|
|
48
|
+
function mint(typeTag: string): string {
|
|
49
|
+
return (globalThis as unknown as { nomos: NomosMint }).nomos.mint(typeTag);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A reference to an aggregate the dev HOLDS — minted-new (`create`) or bound-to-existing (`existing`). */
|
|
53
|
+
export interface AggregateRef {
|
|
54
|
+
readonly __type: string;
|
|
55
|
+
readonly __id: string;
|
|
56
|
+
/** Set a scalar field (Lww). */
|
|
57
|
+
set(field: string, value: unknown): this;
|
|
58
|
+
/** Set one entry of a map field (per-key Lww). */
|
|
59
|
+
setEntry(field: string, key: string, value: unknown): this;
|
|
60
|
+
/** Add to a collection: a `t.hasMany` relation → writes the child's back-ref; a `t.set` → AddToSet on self. */
|
|
61
|
+
add(field: string, value: unknown): this;
|
|
62
|
+
/** Relate to another aggregate by reference (writes the ref field on THIS aggregate). */
|
|
63
|
+
relate(rel: string, target: AggregateRef): this;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
class Ref implements AggregateRef {
|
|
67
|
+
constructor(
|
|
68
|
+
private readonly handle: AggregateHandle,
|
|
69
|
+
readonly __id: string,
|
|
70
|
+
private readonly birth: boolean,
|
|
71
|
+
) {}
|
|
72
|
+
get __type(): string {
|
|
73
|
+
return this.handle.id;
|
|
74
|
+
}
|
|
75
|
+
/** Emit a field op TARGETING THIS aggregate, marked `creates` iff this ref is a birth. */
|
|
76
|
+
private emit(field: string, kind: FieldOp["kind"], extra: Partial<FieldOp>): void {
|
|
77
|
+
const op: FieldOp = { aggregateId: this.__id, aggregateType: this.__type, field, kind, ...extra };
|
|
78
|
+
sink.push(this.birth ? { ...op, marker: "creates" } : op);
|
|
79
|
+
}
|
|
80
|
+
set(field: string, value: unknown): this {
|
|
81
|
+
this.emit(field, "set", { value });
|
|
82
|
+
return this;
|
|
83
|
+
}
|
|
84
|
+
setEntry(field: string, key: string, value: unknown): this {
|
|
85
|
+
this.emit(field, "setEntry", { entryKey: key, value });
|
|
86
|
+
return this;
|
|
87
|
+
}
|
|
88
|
+
relate(rel: string, target: AggregateRef): this {
|
|
89
|
+
this.emit(rel, "set", { value: target.__id });
|
|
90
|
+
return this;
|
|
91
|
+
}
|
|
92
|
+
add(field: string, value: unknown): this {
|
|
93
|
+
// `.add` resolves the named member across the aggregate's two member kinds: a virtual
|
|
94
|
+
// `t.hasMany` relation (write the child's back-ref) vs a stored `t.set` field (AddToSet).
|
|
95
|
+
// The two collections are kept apart by `aggregate()`, so this is the ONE place that bridges them.
|
|
96
|
+
const rel = (this.handle.hasMany as Record<string, Field>)[field];
|
|
97
|
+
if (rel !== undefined) {
|
|
98
|
+
// RELATION: write the back-reference ON THE CHILD (the cheap N:1). The op lands in the CHILD's
|
|
99
|
+
// bucket with the CHILD's birth-marker — this aggregate (the parent) is never touched.
|
|
100
|
+
const child = value as Ref;
|
|
101
|
+
child.emit(rel.viaField!, "set", { value: this.__id });
|
|
102
|
+
} else {
|
|
103
|
+
// BOUNDED INTRINSIC SET: AddToSet on this aggregate itself.
|
|
104
|
+
const v = value instanceof Ref ? value.__id : String(value);
|
|
105
|
+
this.emit(field, "addToSet", { items: [v] });
|
|
106
|
+
}
|
|
107
|
+
return this;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Ask Nomos to CREATE a new aggregate of the handle's type → a fluent reference (id minted, no choice). */
|
|
112
|
+
export function create(agg: AggregateHandle): AggregateRef {
|
|
113
|
+
return new Ref(agg, mint(agg.id), true);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** A reference to an EXISTING aggregate the dev already holds an id for (e.g. from the payload / a read). */
|
|
117
|
+
export function existing(agg: AggregateHandle, id: string): AggregateRef {
|
|
118
|
+
return new Ref(agg, id, false);
|
|
119
|
+
}
|