create-githolon 0.4.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -4,7 +4,7 @@ You write exactly TWO things: aggregates and directives. Never apply/fold/
|
|
|
4
4
|
merge code — the kernel owns folding; your directive PLANS ops and the sealed
|
|
5
5
|
engine replays them deterministically on every peer. Declared reads (query,
|
|
6
6
|
count, derived, sum) are auto-discovered from your module's exports by shape.
|
|
7
|
-
`domains/guestbook.ts` demonstrates
|
|
7
|
+
`domains/guestbook.ts` demonstrates the core patterns below; reshape it.
|
|
8
8
|
|
|
9
9
|
## Aggregates: typed fields, each tagged a merge driver
|
|
10
10
|
|
|
@@ -48,7 +48,90 @@ Plan ops: `set` (scalars/refs) · `addToSet` (the ONLY additive write to an
|
|
|
48
48
|
AddWins set — `set()` on a set field would overwrite the union and is REFUSED
|
|
49
49
|
at the type level and at runtime) · `setEntry` (one map key) · `strike`
|
|
50
50
|
(retract). `.creates(Agg)` mints the id — payloads never carry one;
|
|
51
|
-
`.mutates(Agg)` takes the instance id in the payload
|
|
51
|
+
`.mutates(Agg)` takes the instance id in the payload; `.ensures(Agg)` upserts
|
|
52
|
+
at a DETERMINISTIC id (next section).
|
|
53
|
+
|
|
54
|
+
## Upserts: `.ensures` — create-or-amend at a deterministic id
|
|
55
|
+
|
|
56
|
+
When the CALLER owns the identity (a reading keyed `probe:day`, a config row
|
|
57
|
+
keyed by name), mark the directive `.ensures(Agg)` and address the instance
|
|
58
|
+
with an id DERIVED FROM THE PAYLOAD — never minted, never guessed:
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
export const recordReading = directive("recordReading").ensures(Reading)
|
|
62
|
+
.payload(z.object({ probe: z.string(), day: z.string(), value: z.number().int() }))
|
|
63
|
+
.plan((p) => {
|
|
64
|
+
const r = instance(Reading, `reading:${p.probe}:${p.day}`);
|
|
65
|
+
return [set(r, "probe", p.probe), set(r, "day", p.day), set(r, "value", p.value)];
|
|
66
|
+
});
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
The first dispatch creates the row; a re-dispatch folds onto it IN PLACE under
|
|
70
|
+
the field merge drivers (Lww: a re-submission of the same value is a no-op, a
|
|
71
|
+
corrected value replaces it). That makes the write IDEMPOTENT — any host can
|
|
72
|
+
re-submit without double-counting. Collectors, crons, and retry loops live on
|
|
73
|
+
this.
|
|
74
|
+
|
|
75
|
+
## "Singletons" — minted record + natural-key query, NEVER a fixed row id
|
|
76
|
+
|
|
77
|
+
A common instinct is to model a one-of-a-kind record (a platform, a config, a
|
|
78
|
+
tenant) with a FIXED aggregate id — `create(Platform)` then address it forever
|
|
79
|
+
at `"the-platform"`. **The gate refuses this.** Every `create()` mints a
|
|
80
|
+
kernel id (`<TypeTag>_<uuidv7>`) and `check_create_ids` rejects a hand-written
|
|
81
|
+
id typed (`NotMinted`). Identity is minted; names are data.
|
|
82
|
+
|
|
83
|
+
So a singleton is **a minted record carrying its natural key as a FIELD, plus an
|
|
84
|
+
indexed query to find it by that key:**
|
|
85
|
+
|
|
86
|
+
```ts
|
|
87
|
+
export const Platform = aggregate("Platform", {
|
|
88
|
+
platformId: t.string().merge(Lww), // the natural key — a FIELD, not the row id
|
|
89
|
+
namespace: t.string().merge(Lww),
|
|
90
|
+
// … the rest of the platform's state
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
export const registerPlatform = directive("registerPlatform").creates(Platform)
|
|
94
|
+
.payload(z.object({ platformId: z.string().min(1), namespace: z.string().min(1) }))
|
|
95
|
+
.plan((p) => { create(Platform).set("platformId", p.platformId).set("namespace", p.namespace); return []; });
|
|
96
|
+
// ^ NO id in the payload — Nomos MINTS it. The typed client mints-when-omitted.
|
|
97
|
+
|
|
98
|
+
export const platformByPlatformId = query("platformByPlatformId").key("platformId").returns(Platform);
|
|
99
|
+
// ^ "the singleton" is read by its natural key, O(1) — never by a guessed row id.
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
The same pattern is how you model `namespaceId`, `providerId`, a config record
|
|
103
|
+
keyed by `name` — each a minted row + a `…By<NaturalKey>` query. If you truly
|
|
104
|
+
want create-or-amend semantics at a caller-owned key (and the natural key IS the
|
|
105
|
+
identity), use `.ensures` above instead — that lane DELIBERATELY addresses a
|
|
106
|
+
derived id and is the ONLY way a non-minted id is lawful.
|
|
107
|
+
|
|
108
|
+
### Multi-aggregate ensures: `withMarker`
|
|
109
|
+
|
|
110
|
+
A directive's marker covers its OWN target aggregate only. When the same plan
|
|
111
|
+
also upserts a SECOND aggregate (a sample updating its monthly meter in the
|
|
112
|
+
same intent), tag that aggregate's write explicitly with
|
|
113
|
+
`withMarker(op, "ensures")` — untagged fan-out rides as a mutate of a row
|
|
114
|
+
that may not exist yet:
|
|
115
|
+
|
|
116
|
+
```ts
|
|
117
|
+
export const recordSample = directive("recordSample").ensures(Sample)
|
|
118
|
+
.payload(/* … */)
|
|
119
|
+
.plan((p) => {
|
|
120
|
+
const s = instance(Sample, sampleId(p)); // the directive's .ensures target
|
|
121
|
+
const m = instance(Meter, meterId(p)); // the sibling upsert
|
|
122
|
+
return [
|
|
123
|
+
set(s, "value", p.value),
|
|
124
|
+
withMarker(set(m, "month", monthOf(p)), "ensures"), // ONE tagged op marks the whole aggregate's event
|
|
125
|
+
set(m, "monthToDate", p.monthToDate),
|
|
126
|
+
];
|
|
127
|
+
});
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
One tagged op per sibling aggregate is enough (conflicting markers for the
|
|
131
|
+
same aggregate refuse at encode, fail-closed). This is exactly how Nomos
|
|
132
|
+
Cloud's own usage tenant folds a reading AND its budget meter in ONE intent —
|
|
133
|
+
so the budget invariant judges both together: a reading that would leave the
|
|
134
|
+
meter over an unacked threshold refuses, and the sample refuses WITH it.
|
|
52
135
|
|
|
53
136
|
## Determinism or death
|
|
54
137
|
|
|
@@ -69,8 +152,10 @@ admission fails everywhere, forever.
|
|
|
69
152
|
engine-projected read field. Lives only in the read model, never in the
|
|
70
153
|
ledger, so it is always re-derivable.
|
|
71
154
|
|
|
72
|
-
Export each at top level; `githolon compile`
|
|
73
|
-
manifest so they work at the edge AND in clients.
|
|
74
|
-
`build/<pkg>.summary.txt` — it
|
|
155
|
+
Export each at top level; `githolon compile` auto-discovers them by shape and
|
|
156
|
+
routes them into the read manifest so they work at the edge AND in clients.
|
|
157
|
+
After a compile, read `build/<pkg>.summary.txt` — it lists every aggregate,
|
|
158
|
+
directive, query, count, and sum you actually built; if a declared read is
|
|
159
|
+
missing there, it is missing everywhere.
|
|
75
160
|
|
|
76
161
|
Next: [03-client.md](./03-client.md) — driving it from an app.
|
|
@@ -27,10 +27,12 @@ the three-era chain cold-verifies green end to end; also
|
|
|
27
27
|
| add an aggregate / a domain | **safe** | the same deploy lane; nothing existing moves |
|
|
28
28
|
| widen a payload (new optional zod key) | **safe** | old clients' payloads still validate |
|
|
29
29
|
| deprecate a field (keep it, stop writing) | **safe** | old rows keep reading it |
|
|
30
|
-
| rename a field | **
|
|
31
|
-
|
|
|
30
|
+
| rename a field / an aggregate | **safe — framework-handled** | names are labels; identity is the compiler-minted STABLE ID. Rename the declaration, recompile (commit `nomos.stable-ids.json` — the lockfile carries identity), deploy: the evolve gate admits silently and old rows READ THE NEW NAME automatically. No migration, no backfill |
|
|
31
|
+
| remove a field / an aggregate | **refused typed, then disposed** | the upgrade refuses naming the stable id; redeploy with `dispositions: { retired: ["<sid>"] }` in the deploy JSON to acknowledge the true removal |
|
|
32
|
+
| retype a field in place | **refused typed — prefer a new field** | the upgrade refuses (same stable id, new kind); `dispositions: { retyped: { "<sid>": "why" } }` admits it, but old rows keep their authored type → mixed-type reads. A NEW field is almost always better |
|
|
32
33
|
| narrow/change a payload or plan old clients still dispatch | **refused typed** | the old trap is closed: a divergent old-hash write gets a typed era refusal at the gate (never a silently poisoned chain) — see below |
|
|
33
|
-
|
|
|
34
|
+
| read on the write path (`read()` in a plan) | **safe — when declared** | declare each query on the directive (`.reads(theQuery)`); the read is served live at author, captured onto the intent, replayed at verify, and CAS-checked at every gate (a stale premise is a typed read-conflict refusal) |
|
|
35
|
+
| automatic migration of old VALUES (rescale on read) | **not yet** | the upcaster hatch exists in the gate but ships inert; renames no longer need it (stable ids) |
|
|
34
36
|
| rewrite what old intents meant | **never** | history is sealed and self-verifying — that is the point |
|
|
35
37
|
|
|
36
38
|
## Recipes
|
|
@@ -49,32 +51,72 @@ a field's declaration orphans it from reads (see rename, next) even though the
|
|
|
49
51
|
data stays in the chain. Removing a directive breaks any client still
|
|
50
52
|
dispatching it. Deletion buys you nothing — law size is not data size.
|
|
51
53
|
|
|
52
|
-
### Rename a field
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
54
|
+
### Rename a field or aggregate (framework-handled)
|
|
55
|
+
|
|
56
|
+
Names are LABELS; identity is the STABLE ID the compiler mints at first
|
|
57
|
+
appearance and carries across compiles via `nomos.stable-ids.json` — **commit
|
|
58
|
+
that lockfile** (it is how a rename keeps its identity across machines and
|
|
59
|
+
clean clones). The flow:
|
|
60
|
+
|
|
61
|
+
1. Rename the declaration in `domains/*.ts`.
|
|
62
|
+
2. Recompile — the summary prints `INFERRED RENAME geoPos -> pos (stable id
|
|
63
|
+
… carried)`. If you meant a removal + an unrelated addition instead, split
|
|
64
|
+
the edit into two compiles (or answer the evolve gate, below).
|
|
65
|
+
3. Deploy. The evolve gate diffs old vs new law BY STABLE ID: a rename is the
|
|
66
|
+
same id with a new label — **it admits silently**, and the projection folds
|
|
67
|
+
via the stable id, so old rows READ THE NEW NAME immediately. Nothing on
|
|
68
|
+
the chain changes (history stays append-only); no client backfill exists
|
|
69
|
+
anymore. *(Proof: the era-1 row reads `pos` the moment v2 installs.)*
|
|
70
|
+
|
|
71
|
+
### Remove a field (the evolve gate asks)
|
|
72
|
+
|
|
73
|
+
Deleting a declaration makes its stable id DISAPPEAR — the deploy is refused
|
|
74
|
+
typed, naming the id and the remedies. If it is a true removal, redeploy with
|
|
75
|
+
the disposition (an in-history fact on the upgrade intent):
|
|
76
|
+
|
|
77
|
+
```jsonc
|
|
78
|
+
// add to build/<name>.deploy.json before POSTing (or via curl --data)
|
|
79
|
+
"dispositions": { "retired": ["<the sid from the refusal>"] }
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
If it was actually a rename the compiler could not infer (no lockfile, or an
|
|
83
|
+
ambiguous edit), recompile with the lockfile present — or declare
|
|
84
|
+
`"dispositions": { "rebinds": { "<newSid>": "<oldSid>" } }`.
|
|
85
|
+
|
|
86
|
+
### Retype in place (refused; prefer a new field)
|
|
87
|
+
|
|
88
|
+
Same field name, new kind (`t.string()` → `t.int()`) is the SAME stable id
|
|
89
|
+
with a different kind — the deploy refuses typed. You may acknowledge it with
|
|
90
|
+
`"dispositions": { "retyped": { "<sid>": "grid index supersedes lat-long" } }`,
|
|
91
|
+
but rows keep their AUTHORED type, so reads go mixed-type across eras and a
|
|
92
|
+
typed client's read model lies for old rows. A NEW field (new identity) is
|
|
93
|
+
almost always the better move. *(Proof: the era-2 string `pos` row reads back
|
|
94
|
+
as a string under the disposed era-3 int schema, beside an int row.)*
|
|
95
|
+
|
|
96
|
+
### Read on the write path (captured reads)
|
|
97
|
+
|
|
98
|
+
A plan that must read to decide declares the query on its directive and calls
|
|
99
|
+
`read()`:
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
export const auditShelf = directive("auditShelf")
|
|
103
|
+
.creates(Audit)
|
|
104
|
+
.payload(z.object({ shelf: z.string().min(1), auditedAt: z.string().min(1) }))
|
|
105
|
+
.plan((p) => {
|
|
106
|
+
const rows = read(entriesByShelf, { shelf: p.shelf }) as unknown[];
|
|
107
|
+
create(Audit).set("shelf", p.shelf).set("seen", rows.length).set("auditedAt", p.auditedAt);
|
|
108
|
+
return [];
|
|
109
|
+
})
|
|
110
|
+
.reads(entriesByShelf); // ← the declaration that makes nomos.read lawful
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
The read is served LIVE from the committed pre-write state at author, captured
|
|
114
|
+
onto the intent (its premise becomes part of its truth), replayed at verify,
|
|
115
|
+
and RE-DERIVED at every gate: if the rows moved between your author and the
|
|
116
|
+
edge admission (a concurrent writer), the write is a **typed read-conflict
|
|
117
|
+
refusal** — the chain is the compare-and-swap register; re-read and
|
|
118
|
+
re-dispatch. Undeclared `read()` refuses at compile. Reads are same-workspace
|
|
119
|
+
and size-bounded by law. *(Proof: `bench/scale/captured_reads.test.mjs`.)*
|
|
78
120
|
|
|
79
121
|
### Old clients after a breaking change (the trap, now closed)
|
|
80
122
|
|
|
@@ -119,5 +161,6 @@ the cloud fans the same deploy into every open shard
|
|
|
119
161
|
`cloud/web-client/test/heavy_coordinator.e2e.mjs`.
|
|
120
162
|
|
|
121
163
|
Back to the [README](../README.md) — or the deep dive:
|
|
122
|
-
`architecture/law_evolution.md` in the nomos2 repo (the substrate mechanics
|
|
123
|
-
the
|
|
164
|
+
`architecture/law_evolution.md` in the nomos2 repo (the substrate mechanics:
|
|
165
|
+
stable identifiers, the evolve gate, the sid-lineage fold, and the still-inert
|
|
166
|
+
upcaster surface for true value migrations).
|