create-githolon 0.5.1 → 0.7.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/index.mjs +9 -2
- package/package.json +1 -1
- package/template/README.md +21 -15
- package/template/docs/02-authoring.md +1 -1
- package/template/docs/03-client.md +2 -2
- package/template/docs/05-superpowers.md +1 -1
- package/template/docs/07-security.md +176 -0
- package/template/domains/todo.ts +85 -0
- package/template/nomos.package.mjs +6 -5
- package/template/test/e2e.mts +68 -148
- package/template/domains/guestbook.ts +0 -164
package/index.mjs
CHANGED
|
@@ -55,9 +55,16 @@ cpSync(TEMPLATE, targetDir, { recursive: true });
|
|
|
55
55
|
// and the scaffold restores the dot.
|
|
56
56
|
renameSync(path.join(targetDir, "gitignore"), path.join(targetDir, ".gitignore"));
|
|
57
57
|
|
|
58
|
-
|
|
58
|
+
// Parameterize the scaffold to the app name. The generated typed client + its baked
|
|
59
|
+
// hash const are NAMED off the package name (`__APP_NAME__`): the artifact filename
|
|
60
|
+
// keeps the raw name (build/<app>.client.ts), the hash SYMBOL normalizes it to
|
|
61
|
+
// UPPER_SNAKE (the same transform `githolon compile` uses — see codegen_ts.ts). The
|
|
62
|
+
// client FACTORY (`todoClient`) is named off the DOMAIN key, which stays `todo`.
|
|
63
|
+
const appHash = `${appName.replace(/[-\s]+/g, "_").replace(/([a-z0-9])([A-Z])/g, "$1_$2").toUpperCase()}_DOMAIN_HASH`;
|
|
64
|
+
const subst = (s) => s.replaceAll("__APP_NAME__", appName).replaceAll("__APP_HASH__", appHash);
|
|
65
|
+
for (const f of ["package.json", "README.md", "nomos.package.mjs", "domains/todo.ts", "test/e2e.mts"]) {
|
|
59
66
|
const p = path.join(targetDir, f);
|
|
60
|
-
writeFileSync(p, readFileSync(p, "utf8")
|
|
67
|
+
if (existsSync(p)) writeFileSync(p, subst(readFileSync(p, "utf8")), "utf8");
|
|
61
68
|
}
|
|
62
69
|
|
|
63
70
|
const git = (cmd) => spawnSync("git", cmd, { cwd: targetDir, stdio: "ignore" }).status === 0;
|
package/package.json
CHANGED
package/template/README.md
CHANGED
|
@@ -24,7 +24,7 @@ real workspace afterwards.
|
|
|
24
24
|
|
|
25
25
|
```bash
|
|
26
26
|
npx githolon ws create <ws> # births a fresh workspace on your identity; SAVES its one-time secret to ~/.holon
|
|
27
|
-
npx githolon deploy <ws> # POSTs build/
|
|
27
|
+
npx githolon deploy <ws> # POSTs build/__APP_NAME__.deploy.json with the stored secret
|
|
28
28
|
npm run e2e # the full loop, live: offline write → sync → admission → cloud query
|
|
29
29
|
```
|
|
30
30
|
|
|
@@ -55,14 +55,14 @@ git just works against the cloud.)
|
|
|
55
55
|
|
|
56
56
|
## Make it YOURS — the five-minute walkthrough
|
|
57
57
|
|
|
58
|
-
The starter is the tutorial; this is the exact path from
|
|
58
|
+
The starter is the tutorial; this is the exact path from the todo list to your own law.
|
|
59
59
|
|
|
60
60
|
1. **Rename the law file** and reshape it:
|
|
61
|
-
`git mv domains/
|
|
62
|
-
(aggregate fields each tagged a merge driver
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
61
|
+
`git mv domains/todo.ts domains/<yours>.ts` — keep the patterns
|
|
62
|
+
(aggregate fields each tagged a merge driver — `Lww` scalar, `AddWins` set;
|
|
63
|
+
directives = zod payload → pure plan; `addToSet` for AddWins sets — `set()` on
|
|
64
|
+
a set field is refused; `done` is a 2-value Lww enum because there is no
|
|
65
|
+
`t.bool()`; sharing is one native Zanzibar tuple via `writeTuple`).
|
|
66
66
|
2. **Point the compile config at it** — `nomos.package.mjs`:
|
|
67
67
|
```js
|
|
68
68
|
export default { name: "<pkg>", domains: [{ key: "<pkg>", modules: ["./domains/<yours>.ts"] }] };
|
|
@@ -83,13 +83,15 @@ The starter is the tutorial; this is the exact path from guestbook to your own l
|
|
|
83
83
|
reference until it's clean.
|
|
84
84
|
5. **Prove it live:** `npm run e2e` — your law deploys to a throwaway workspace,
|
|
85
85
|
an offline write syncs through admission, the cloud answers your declared
|
|
86
|
-
query and count, and
|
|
86
|
+
query and count, and one native sharing tuple is recorded. (Prove it OFFLINE
|
|
87
|
+
first with `npx githolon proof` — the generated proof on a local holon, no
|
|
88
|
+
cloud.)
|
|
87
89
|
|
|
88
90
|
Then deploy it for real: `npx githolon login --agent && npx githolon ws create <ws> && npx githolon deploy <ws>`.
|
|
89
91
|
|
|
90
92
|
## Docs — in the box
|
|
91
93
|
|
|
92
|
-
`docs/` travels with you — offline, like everything else here.
|
|
94
|
+
`docs/` travels with you — offline, like everything else here. Seven short
|
|
93
95
|
pages, each under a screen and a half:
|
|
94
96
|
|
|
95
97
|
1. [The mental model](./docs/01-mental-model.md) — holons, content-addressed
|
|
@@ -107,18 +109,22 @@ pages, each under a screen and a half:
|
|
|
107
109
|
6. [Evolving law](./docs/06-law-evolution.md) — what's safe to change once
|
|
108
110
|
real data exists (add freely; rename/retype honestly), the era rule, and
|
|
109
111
|
the one trap: old clients after a breaking upgrade.
|
|
112
|
+
7. [Security](./docs/07-security.md) — private aggregates (`requires(cap)` +
|
|
113
|
+
grant/revoke, the holon gates its own reads) and operator-blind E2E
|
|
114
|
+
encrypted fields (`.encrypted()`, the key handshake, `githolon decrypt`).
|
|
110
115
|
|
|
111
116
|
## What's here
|
|
112
117
|
|
|
113
|
-
- `docs/` — the
|
|
114
|
-
- `domains/
|
|
115
|
-
directives, a declared query, a
|
|
116
|
-
|
|
118
|
+
- `docs/` — the seven pages above; the offline reference.
|
|
119
|
+
- `domains/todo.ts` — the starter domain (the law): a shared, offline-first todo
|
|
120
|
+
list — two aggregates, the directives, a declared query, a where-filtered count,
|
|
121
|
+
and one native Zanzibar sharing tuple. Rename it, reshape it, or add domains
|
|
122
|
+
with `npx githolon generate domain <name>`.
|
|
117
123
|
- `nomos.package.mjs` — the compile config (module list per domain key; reads
|
|
118
124
|
are auto-discovered from exports).
|
|
119
|
-
- `build/
|
|
125
|
+
- `build/__APP_NAME__.client.ts` — GENERATED typed TS client (payload types from
|
|
120
126
|
the engine's zod, read models from the field kinds, the law hash baked in).
|
|
121
|
-
- `build/
|
|
127
|
+
- `build/__APP_NAME__.proof.mts` — GENERATED runnable proof, synthesized from your
|
|
122
128
|
own directives/queries/counts on every compile; `npx githolon proof` runs it.
|
|
123
129
|
- `test/e2e.mts` — proves compile → deploy → typed offline write/query →
|
|
124
130
|
edge admission → cloud declared query, against the live cloud.
|
|
@@ -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/
|
|
7
|
+
`domains/todo.ts` demonstrates the core patterns below; reshape it.
|
|
8
8
|
|
|
9
9
|
## Aggregates: typed fields, each tagged a merge driver
|
|
10
10
|
|
|
@@ -7,10 +7,10 @@ is the map.
|
|
|
7
7
|
|
|
8
8
|
```ts
|
|
9
9
|
import { connect } from "@githolon/client";
|
|
10
|
-
import {
|
|
10
|
+
import { todoClient } from "../build/<app>.client.ts";
|
|
11
11
|
|
|
12
12
|
const holon = await connect({ cloud, workspace, clientId });
|
|
13
|
-
const app =
|
|
13
|
+
const app = todoClient(holon);
|
|
14
14
|
```
|
|
15
15
|
|
|
16
16
|
`connect` pulls the wasm, the workspace manifests, and the ledger, then runs
|
|
@@ -63,4 +63,4 @@ is for reconciliation only.
|
|
|
63
63
|
is the complete, verifiable history of the workspace.
|
|
64
64
|
|
|
65
65
|
Back to the [README](../README.md) — or start reshaping
|
|
66
|
-
`domains/
|
|
66
|
+
`domains/todo.ts` ([02-authoring.md](./02-authoring.md)).
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# Security: private reads & operator-blind encryption
|
|
2
|
+
|
|
3
|
+
Two opt-in layers, both e2e-proven, both **era-ruled** — a domain that uses
|
|
4
|
+
neither pays nothing, and a chain sealed before these features replays
|
|
5
|
+
byte-identically forever. You add them field by field; nothing else changes.
|
|
6
|
+
|
|
7
|
+
The throughline: **the githolon enforces its own access**. Not the host, not a
|
|
8
|
+
server's `if (user.canRead)` — the same byte-identical wasm gate every peer runs.
|
|
9
|
+
A read you aren't entitled to is refused by the holon itself, on every replica.
|
|
10
|
+
|
|
11
|
+
## Layer 1 — private aggregates: the holon gates its own reads
|
|
12
|
+
|
|
13
|
+
Make a type private and a read touching it is served only to a principal the
|
|
14
|
+
workspace **granted** the capability. `role ≡ cap` in v1 — the role you grant is
|
|
15
|
+
the cap a read requires.
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import {
|
|
19
|
+
aggregate, t, Lww, requires, userPermissionAggregate, grant, revoke, directive, query, z,
|
|
20
|
+
} from "@githolon/dsl";
|
|
21
|
+
|
|
22
|
+
// PUBLIC — anyone reads (the control: privacy must not change how public serves).
|
|
23
|
+
export const Announcement = aggregate("Announcement", { text: t.string().merge(Lww) });
|
|
24
|
+
|
|
25
|
+
// PRIVATE — a read of Note needs the `readNote` cap.
|
|
26
|
+
export const Note = aggregate("Note",
|
|
27
|
+
{ owner: t.string().merge(Lww), body: t.string().merge(Lww) },
|
|
28
|
+
{ visibility: requires("readNote") });
|
|
29
|
+
|
|
30
|
+
// The framework permission aggregate — EXPORT it, so grants fold into THIS chain
|
|
31
|
+
// (the bindings the read gate reads). Forgetting this is the #1 mistake.
|
|
32
|
+
export const UserPermission = userPermissionAggregate;
|
|
33
|
+
|
|
34
|
+
// The owner bestows the cap on a reader. Authored through the secret-gated
|
|
35
|
+
// `/author` lane (or `githolon author`) — only the owner can grant.
|
|
36
|
+
export const grantReader = directive("grantReader").ensures(UserPermission)
|
|
37
|
+
.payload(z.object({ principal: z.string(), grantedBy: z.string(), grantedAt: z.string() }))
|
|
38
|
+
.plan((p) => grant({
|
|
39
|
+
userId: p.principal, scope: ["workspace", "notes"], role: "readNote",
|
|
40
|
+
grantedBy: p.grantedBy, grantedAt: p.grantedAt,
|
|
41
|
+
}));
|
|
42
|
+
|
|
43
|
+
// Revoke flips that same binding to status:revoked (forward-only, remove-wins).
|
|
44
|
+
export const revokeReader = directive("revokeReader").ensures(UserPermission)
|
|
45
|
+
.payload(z.object({
|
|
46
|
+
principal: z.string(), grantedBy: z.string(), grantedAt: z.string(),
|
|
47
|
+
revokedBy: z.string(), revokedAt: z.string(),
|
|
48
|
+
}))
|
|
49
|
+
.plan((p) => revoke({
|
|
50
|
+
userId: p.principal, scope: ["workspace", "notes"], role: "readNote",
|
|
51
|
+
grantedBy: p.grantedBy, grantedAt: p.grantedAt, revokedBy: p.revokedBy, revokedAt: p.revokedAt,
|
|
52
|
+
}));
|
|
53
|
+
|
|
54
|
+
export const notesByOwner = query("notesByOwner").key("owner").returns(Note); // inherits the gate
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
How it behaves:
|
|
58
|
+
|
|
59
|
+
- **Who decides.** The read gate runs in the wasm against the workspace's own
|
|
60
|
+
folded role-bindings — never the host. Every peer (the cloud edge included)
|
|
61
|
+
re-derives the same verdict.
|
|
62
|
+
- **Two grains.** *Granular* reads (`query`, `queryById`) need the **specific**
|
|
63
|
+
cap. *Bulk* reads — cloning `main`, `snapshot`, `count`, `sum` — need **any**
|
|
64
|
+
declared cap and reveal everything (pulling `main` hands over the whole ledger;
|
|
65
|
+
that's the deal). A fully public workspace clones openly, as before.
|
|
66
|
+
- **Who you are.** A read carries the principal (an `x-nomos-auth` token, verified;
|
|
67
|
+
or the transitional `x-nomos-principal`). No principal ⇒ a private read is
|
|
68
|
+
refused, fail-closed.
|
|
69
|
+
- **Revocation is honest.** Remove-wins: a revoked principal can never be
|
|
70
|
+
re-granted by a replayed grant. The post-revoke read re-derives authority from
|
|
71
|
+
the maintained projection — cost scales with the number of *identities*, not
|
|
72
|
+
your data volume (revoking one reader over 10k private records stays fast).
|
|
73
|
+
|
|
74
|
+
The owner grants through the secret-gated lane; your end-user app carries **no
|
|
75
|
+
secret** — its writes are judged by edge admission, and it simply can't author a
|
|
76
|
+
grant. Authority enters only through the owner.
|
|
77
|
+
|
|
78
|
+
## Layer 2 — encrypted fields: operator-blind, end-to-end
|
|
79
|
+
|
|
80
|
+
Mark a field `.encrypted()` and its value is sealed **on the client** before it
|
|
81
|
+
ever reaches the cloud. The operator stores and serves ciphertext; only a
|
|
82
|
+
key-holder can read it. This is RFC 9420-style group keys mapped onto the ledger:
|
|
83
|
+
per-scope keys, distributed as on-chain wrapped-key events, revocation as
|
|
84
|
+
forward-only key rotation.
|
|
85
|
+
|
|
86
|
+
```ts
|
|
87
|
+
import {
|
|
88
|
+
aggregate, t, Lww, requires, directive, create, query, z,
|
|
89
|
+
userPermissionAggregate, grant,
|
|
90
|
+
KeyScope, WrappedKey, advanceKeyEpoch, wrapKey, wrappedKeysByRecipient,
|
|
91
|
+
} from "@githolon/dsl";
|
|
92
|
+
|
|
93
|
+
export const Note = aggregate("Note",
|
|
94
|
+
{ owner: t.string().merge(Lww), body: t.string().merge(Lww).encrypted() }, // body is E2E-encrypted
|
|
95
|
+
{ visibility: requires("readNote") }); // .encrypted() REQUIRES a private aggregate (the cap is the scope)
|
|
96
|
+
|
|
97
|
+
export const UserPermission = userPermissionAggregate;
|
|
98
|
+
export const KS = KeyScope; // the per-scope key head — export so it folds
|
|
99
|
+
export const WK = WrappedKey; // the on-chain wrapped keys — export so it folds
|
|
100
|
+
export const advanceEpoch = advanceKeyEpoch; // the key rotation — export so it routes
|
|
101
|
+
export const wrap1 = wrapKey;
|
|
102
|
+
export const wraps = wrappedKeysByRecipient;
|
|
103
|
+
|
|
104
|
+
export const writeNote = directive("writeNote").creates(Note)
|
|
105
|
+
.payload(z.object({ owner: z.string(), body: z.string() })) // `body` is the PRE-SEALED envelope
|
|
106
|
+
.plan((p) => { create(Note).set("owner", p.owner).set("body", p.body); return []; });
|
|
107
|
+
|
|
108
|
+
export const grantReader = directive("grantReader").ensures(UserPermission)
|
|
109
|
+
.payload(z.object({ principal: z.string(), grantedBy: z.string(), grantedAt: z.string() }))
|
|
110
|
+
.plan((p) => grant({ userId: p.principal, scope: ["workspace", "w"], role: "readNote",
|
|
111
|
+
grantedBy: p.grantedBy, grantedAt: p.grantedAt }));
|
|
112
|
+
|
|
113
|
+
export const notesByOwner = query("notesByOwner").key("owner").returns(Note);
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
`.encrypted()` is only legal on a **private** (`requires(cap)`) aggregate — the
|
|
117
|
+
cap *is* the encryption scope — and only on an **Lww-class** field (the value is
|
|
118
|
+
a ciphertext envelope replaced wholesale). `githolon compile` refuses otherwise,
|
|
119
|
+
naming the fix.
|
|
120
|
+
|
|
121
|
+
### The handshake (operator-blind)
|
|
122
|
+
|
|
123
|
+
The cloud never sees a key or a plaintext. The dance:
|
|
124
|
+
|
|
125
|
+
1. **The reader makes a device key.** An X25519 keypair (`cryptoKeygen`); the
|
|
126
|
+
secret stays on the device, the public key is published.
|
|
127
|
+
2. **The owner grants the cap** (`grantReader`) — opens the read gate *and* adds
|
|
128
|
+
the reader to the scope's key roster.
|
|
129
|
+
3. **The owner advances the epoch** (`advanceKeyEpoch`), wrapping the scope key
|
|
130
|
+
to every member's public key (computed client-side, `cryptoWrapKey`). The
|
|
131
|
+
fan-out gate verifies the wraps cover exactly the surviving roster — no missing
|
|
132
|
+
member (lost access), no extra (a leak).
|
|
133
|
+
4. **The author seals the field** — resolve the `(type, field)` stable ids from
|
|
134
|
+
the law, `crypto_seal_field` with the scope key → an envelope, write the
|
|
135
|
+
envelope as the field value.
|
|
136
|
+
5. **The reader unwraps and reads** — `cryptoUnwrapKey` with the device secret
|
|
137
|
+
recovers the scope key; inject it on the read and the field comes back
|
|
138
|
+
decrypted. Without the key it stays an opaque envelope.
|
|
139
|
+
|
|
140
|
+
A standalone proof of the whole loop ships as a dev tool:
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
githolon decrypt <ws> --as <principal> --secret <b64> --query notesByOwner
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
It clones the ledger, unwraps that principal's scope keys from the on-chain
|
|
147
|
+
wrapped keys, and runs the query **decrypted** — the operator-blind ledger is
|
|
148
|
+
plaintext only to a key-holder, and this proves it as that holder.
|
|
149
|
+
|
|
150
|
+
### What to keep in mind
|
|
151
|
+
|
|
152
|
+
- **Revocation is forward-only.** Removing a reader advances the epoch and
|
|
153
|
+
re-wraps to the survivors; old ciphertext is *not* re-encrypted. A removed
|
|
154
|
+
member can still read what they were already shown — "no new reads" is the only
|
|
155
|
+
honest semantic over an append-only log.
|
|
156
|
+
- **Metadata is public.** Who is a member, how many, ciphertext sizes, write
|
|
157
|
+
timing, and any plaintext field you `count` / `sum` / index *by* — all visible.
|
|
158
|
+
Encrypt the secret value, not the thing you group by.
|
|
159
|
+
- **Renames are safe.** The envelope carries the field's stable id, so renaming
|
|
160
|
+
an encrypted field (or its aggregate) keeps old ciphertext decryptable under the
|
|
161
|
+
new label. A true removal or retype still needs the evolve-gate dispositions
|
|
162
|
+
(see `06-law-evolution.md`).
|
|
163
|
+
- **Never write plaintext into an encrypted field.** The gate refuses anything
|
|
164
|
+
that isn't a valid envelope sealed to the declared slot.
|
|
165
|
+
- **Ergonomics today.** The generated client types the payloads but does not yet
|
|
166
|
+
seal/unwrap for you — drive the seal and the key injection through the holon's
|
|
167
|
+
crypto ops (the reference flow is small; ask if you want a helper wired into the
|
|
168
|
+
generated client).
|
|
169
|
+
|
|
170
|
+
## Defense in depth, not a panic button
|
|
171
|
+
|
|
172
|
+
Per-scope keys mean a client only ever *holds* keys for scopes it may read — so
|
|
173
|
+
leaking your own authorized view is harmless, and holding a key for data you were
|
|
174
|
+
never granted simply can't happen. A device keystore (non-extractable WebCrypto on
|
|
175
|
+
web, the OS keystore on native) is real hardening on top, but the load-bearing
|
|
176
|
+
guarantee is the key scoping, not the enclave.
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* __APP_NAME__ — a shared, offline-first todo list. The smallest domain that shows the
|
|
3
|
+
* magic: two people edit the SAME list while BOTH offline, blind to each other, and
|
|
4
|
+
* converge byte-identically when they sync — because the merge is the LAW (kernel-folded
|
|
5
|
+
* CRDT), not the app's job. You write only aggregates + directives; the engine does the
|
|
6
|
+
* rest. Rename it, add fields, add directives — it's yours from the first byte.
|
|
7
|
+
*/
|
|
8
|
+
import {
|
|
9
|
+
z, aggregate, t, Lww, AddWins, directive, create, instance, set, addToSet,
|
|
10
|
+
query, count, writeTuple, RelationTuple, relateSubject, relationTuplesByObject,
|
|
11
|
+
} from "@githolon/dsl";
|
|
12
|
+
|
|
13
|
+
// A todo is open or done. (There is no t.bool() — model a flag as a 2-value Lww enum.)
|
|
14
|
+
const DONE = ["open", "done"] as const;
|
|
15
|
+
|
|
16
|
+
// ── what we store ─────────────────────────────────────────────────────────────
|
|
17
|
+
export const TodoList = aggregate("TodoList", {
|
|
18
|
+
title: t.string().merge(Lww), // last writer wins
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
export const Todo = aggregate("Todo", {
|
|
22
|
+
listId: t.string().merge(Lww), // which list this todo is on (the query key)
|
|
23
|
+
text: t.string().merge(Lww), // last writer wins
|
|
24
|
+
done: t.enum(DONE).merge(Lww), // last writer wins
|
|
25
|
+
tags: t.set(t.string()).merge(AddWins), // concurrent taggers UNION — nobody's tag is lost
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// Re-export the framework's Zanzibar tuple aggregate so its events fold into THIS
|
|
29
|
+
// chain. relateSubject (an `.ensures(RelationTuple)` directive) is what marks the
|
|
30
|
+
// tuple an ensured type, which shareList's tuple write below needs.
|
|
31
|
+
export { RelationTuple, relateSubject };
|
|
32
|
+
|
|
33
|
+
// ── what people do (directives PLAN ops; the sealed engine replays them) ────────
|
|
34
|
+
export const createList = directive("createList")
|
|
35
|
+
.creates(TodoList) // Nomos MINTS the id — callers never supply one
|
|
36
|
+
.payload(z.object({ title: z.string().min(1) }))
|
|
37
|
+
.plan((p) => { create(TodoList).set("title", p.title); return []; });
|
|
38
|
+
|
|
39
|
+
export const addTodo = directive("addTodo")
|
|
40
|
+
.creates(Todo)
|
|
41
|
+
.payload(z.object({ listId: z.string().min(1), text: z.string().min(1) }))
|
|
42
|
+
.plan((p) => {
|
|
43
|
+
create(Todo).set("listId", p.listId).set("text", p.text).set("done", "open");
|
|
44
|
+
return [];
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
export const renameTodo = directive("renameTodo")
|
|
48
|
+
.mutates(Todo)
|
|
49
|
+
.payload(z.object({ todoId: z.string(), text: z.string().min(1) }))
|
|
50
|
+
.plan((p) => [set(instance(Todo, p.todoId), "text", p.text)]);
|
|
51
|
+
|
|
52
|
+
// The caller decides open↔done and rides it IN the payload (a plan is pure — no clock,
|
|
53
|
+
// no random). Concurrent toggles are Lww: the later HLC wins, and both peers converge.
|
|
54
|
+
export const toggleTodo = directive("toggleTodo")
|
|
55
|
+
.mutates(Todo)
|
|
56
|
+
.payload(z.object({ todoId: z.string(), done: z.enum(DONE) }))
|
|
57
|
+
.plan((p) => [set(instance(Todo, p.todoId), "done", p.done)]);
|
|
58
|
+
|
|
59
|
+
// addToSet is the ONLY additive write to an AddWins set (set() would overwrite).
|
|
60
|
+
export const tagTodo = directive("tagTodo")
|
|
61
|
+
.mutates(Todo)
|
|
62
|
+
.payload(z.object({ todoId: z.string(), tags: z.array(z.string()).min(1) }))
|
|
63
|
+
.plan((p) => [addToSet(instance(Todo, p.todoId), "tags", p.tags)]);
|
|
64
|
+
|
|
65
|
+
// Sharing = ONE native Zanzibar tuple: TodoList:<id>#<role>@user:<user>.
|
|
66
|
+
export const shareList = directive("shareList")
|
|
67
|
+
.ensures(RelationTuple)
|
|
68
|
+
.payload(z.object({
|
|
69
|
+
listId: z.string().min(1), userId: z.string().min(1),
|
|
70
|
+
role: z.enum(["editor", "viewer"]), sharedBy: z.string().min(1), sharedAt: z.string(),
|
|
71
|
+
}))
|
|
72
|
+
.plan((p) => writeTuple({
|
|
73
|
+
object: `TodoList:${p.listId}`, relation: p.role, subject: `user:${p.userId}`,
|
|
74
|
+
grantedBy: p.sharedBy, grantedAt: p.sharedAt,
|
|
75
|
+
}));
|
|
76
|
+
|
|
77
|
+
// ── what people read (auto-discovered by shape; routed to every peer) ───────────
|
|
78
|
+
export const todosByList = query("todosByList").key("listId").returns(Todo);
|
|
79
|
+
|
|
80
|
+
// An O(1) maintained tally: how many todos are still open, per list.
|
|
81
|
+
export const openTodosByList = count("openTodosByList")
|
|
82
|
+
.of(Todo).where((p) => p.field("done").eq("open")).by("listId");
|
|
83
|
+
|
|
84
|
+
// The raw sharing tuples for a list — the audit surface (re-exported so it routes here).
|
|
85
|
+
export { relationTuplesByObject };
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
// The
|
|
2
|
-
// Read-side declarations (
|
|
3
|
-
// module's exports by shape — nothing to wire here.
|
|
1
|
+
// The githolon-compile config: one package, one domain, one module file.
|
|
2
|
+
// Read-side declarations (the query + the count) are auto-discovered from the
|
|
3
|
+
// module's exports by shape — nothing to wire here. The framework RelationTuple
|
|
4
|
+
// aggregate + its relationTuplesByObject query are pulled in by todo.ts's re-exports.
|
|
4
5
|
// Building a Flutter app too? Add `dart: true` for the typed Dart frontend
|
|
5
6
|
// (build/dart/) alongside the TS client.
|
|
6
7
|
export default {
|
|
7
|
-
name: "
|
|
8
|
-
domains: [{ key: "
|
|
8
|
+
name: "__APP_NAME__",
|
|
9
|
+
domains: [{ key: "todo", modules: ["./domains/todo.ts"] }],
|
|
9
10
|
};
|
package/template/test/e2e.mts
CHANGED
|
@@ -1,23 +1,25 @@
|
|
|
1
|
-
// E2E: the FULL
|
|
2
|
-
//
|
|
1
|
+
// E2E: the FULL loop against a LIVE Nomos Cloud, driven through the GENERATED TYPED
|
|
2
|
+
// CLIENT (`build/__APP_NAME__.client.ts` — emitted by githolon compile):
|
|
3
3
|
//
|
|
4
4
|
// compile → create workspace → deploy (package + manifest overlay, ONE POST) →
|
|
5
|
-
// web client connects → TYPED
|
|
6
|
-
//
|
|
7
|
-
// declared query returns the entry.
|
|
5
|
+
// web client connects → TYPED createList + addTodo LOCALLY (offline writes) →
|
|
6
|
+
// sync + edge admission → the CLOUD's declared query + count return the work.
|
|
8
7
|
//
|
|
9
8
|
// The docs travel with you, offline: docs/01-mental-model.md first, then
|
|
10
9
|
// docs/03-client.md explains every call here. This file is the NARRATED
|
|
11
|
-
// walkthrough you reshape by hand; its GENERATED sibling — build
|
|
10
|
+
// walkthrough you reshape by hand; its GENERATED sibling — build/__APP_NAME__.proof.mts,
|
|
12
11
|
// emitted by every compile from your own law — needs no rewriting: `githolon proof`.
|
|
13
12
|
//
|
|
14
|
-
//
|
|
13
|
+
// The OFFLINE convergence proof (two peers, blind edits, byte-identical sync, no cloud)
|
|
14
|
+
// lives in test/convergence.local.mjs: node test/convergence.local.mjs
|
|
15
|
+
//
|
|
16
|
+
// Run from this directory AFTER `npx githolon compile`: npx tsx test/e2e.mts
|
|
15
17
|
import { readFileSync } from "node:fs";
|
|
16
18
|
import { connect } from "@githolon/client";
|
|
17
|
-
import {
|
|
19
|
+
import { todoClient, __APP_HASH__ } from "../build/__APP_NAME__.client.ts";
|
|
18
20
|
|
|
19
21
|
const CLOUD = (process.env.NOMOS_CLOUD || "https://nomos.captainapp.co.uk").replace(/\/+$/, "");
|
|
20
|
-
const WS = process.env.NOMOS_WS || "
|
|
22
|
+
const WS = process.env.NOMOS_WS || "todo-e2e-" + Math.random().toString(36).slice(2, 8);
|
|
21
23
|
|
|
22
24
|
// explicit annotation on the const so TS's never-call narrowing applies after fail(...)
|
|
23
25
|
// IDENTITY NOTE: this proof uses the bare x-nomos-principal lane so the test is
|
|
@@ -26,169 +28,87 @@ const WS = process.env.NOMOS_WS || "gb-e2e-" + Math.random().toString(36).slice(
|
|
|
26
28
|
const fail: (m: string) => never = (m) => { console.error("✗ " + m); process.exit(1); };
|
|
27
29
|
const ok = (m: string) => console.log("✓ " + m);
|
|
28
30
|
|
|
29
|
-
const deploy = JSON.parse(readFileSync(new URL("../build/
|
|
31
|
+
const deploy = JSON.parse(readFileSync(new URL("../build/__APP_NAME__.deploy.json", import.meta.url), "utf8"));
|
|
30
32
|
|
|
31
|
-
// 1. create the workspace (capture the ONE-TIME secret)
|
|
33
|
+
// 1. create the workspace (capture the ONE-TIME secret)
|
|
32
34
|
let r = await fetch(`${CLOUD}/v1/workspaces/${WS}`, { method: "POST", headers: { "x-nomos-principal": "nomos-e2e-suite" } });
|
|
33
35
|
let d = await r.json();
|
|
34
36
|
if (!d.ok) fail(`create workspace: ${JSON.stringify(d)}`);
|
|
35
37
|
const SECRET: string = d.workspaceSecret;
|
|
36
38
|
if (!SECRET?.startsWith("nws_v1_")) fail(`no workspaceSecret returned: ${JSON.stringify(d)}`);
|
|
37
|
-
ok(`workspace ${WS} created — controller ${d.controller.domainHash.slice(0, 12)}
|
|
39
|
+
ok(`workspace ${WS} created — controller ${d.controller.domainHash.slice(0, 12)}…`);
|
|
38
40
|
|
|
39
41
|
// ownership: a deploy WITHOUT the secret must be refused
|
|
40
42
|
r = await fetch(`${CLOUD}/v1/workspaces/${WS}/domains`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(deploy) });
|
|
41
43
|
if (r.status !== 401) fail(`unauthenticated deploy was not refused (got ${r.status})`);
|
|
42
44
|
ok("ownership — deploy without the workspace secret refused (401)");
|
|
43
45
|
|
|
46
|
+
// deploy WITH the secret. The generated client's baked hash IS the deployed law's hash.
|
|
44
47
|
r = await fetch(`${CLOUD}/v1/workspaces/${WS}/domains`, { method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${SECRET}` }, body: JSON.stringify(deploy) });
|
|
45
48
|
d = await r.json();
|
|
46
49
|
if (!d.ok || d.installation?.[0]?.data?.["status.phase"] !== "Active") fail(`deploy: ${JSON.stringify(d)}`);
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
ok(`guestbook deployed Active — ${GUESTBOOK_DOMAIN_HASH.slice(0, 12)}… (baked into the typed client)`);
|
|
50
|
+
if (d.domainHash !== __APP_HASH__) fail(`deploy hash ${d.domainHash} != generated client's ${__APP_HASH__}`);
|
|
51
|
+
ok(`law deployed Active — ${__APP_HASH__.slice(0, 12)}… (baked into the typed client)`);
|
|
50
52
|
|
|
51
|
-
// 2. connect
|
|
52
|
-
const holon = await connect({ cloud: CLOUD, workspace: WS, clientId: "
|
|
53
|
-
const app =
|
|
53
|
+
// 2. connect a web client (pulls wasm + manifests + ledger) and bind the typed surface
|
|
54
|
+
const holon = await connect({ cloud: CLOUD, workspace: WS, clientId: "owner-1" });
|
|
55
|
+
const app = todoClient(holon);
|
|
54
56
|
ok(`web client connected — typed client bound (replica ${holon.replica.slice(0, 6)}…)`);
|
|
55
57
|
|
|
56
|
-
|
|
57
|
-
if (!(rows.length && rows[0].data["status.phase"] === "Active")) fail("local replay: guestbook not Active locally");
|
|
58
|
-
ok("local replay — guestbook law Active in the local projection");
|
|
59
|
-
|
|
60
|
-
// 3. LOCAL-FIRST write through the TYPED surface: payload shape + enum are compile-checked
|
|
61
|
-
const signedAt = new Date().toISOString();
|
|
62
|
-
// THE OFFLINE PROOF IS ENFORCED, not narrated: while we author/query/count
|
|
63
|
-
// LOCALLY, fetch THROWS — if any "local" path touched the network, this test fails.
|
|
58
|
+
// 3. LOCAL-FIRST writes — ENFORCED offline (fetch is trapped; any network touch fails).
|
|
64
59
|
const realFetch = globalThis.fetch;
|
|
65
60
|
globalThis.fetch = (() => { throw new Error("OFFLINE PROOF VIOLATED: local authoring touched the network"); }) as typeof fetch;
|
|
66
61
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
//
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
await app.
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
if (after[0]!.data.moodEmoji !== "🤩") fail(`derived moodEmoji not projected locally: ${JSON.stringify(after[0]!.data)}`);
|
|
89
|
-
ok("derived field projects locally — moodEmoji 🤩 (engine-computed, never in the ledger)");
|
|
90
|
-
|
|
91
|
-
// 5c. DECLARED COUNT: the maintained O(1) tally, typed accessor, local projection
|
|
92
|
-
const localCount = await app.entriesPerMood("delighted");
|
|
93
|
-
if (localCount !== 1) fail(`local declared count: ${localCount}`);
|
|
94
|
-
ok("declared count maintained locally — entriesPerMood('delighted') = 1");
|
|
62
|
+
// A directive returns the new local HEAD (a string). Aggregate ids are MINTED by the
|
|
63
|
+
// engine — read them back from the declared query. `addTodo.listId` is a plain string
|
|
64
|
+
// the app chooses (the grouping key), so we don't need createList's minted id to wire
|
|
65
|
+
// todos to a list — we just key both on the same `listId`.
|
|
66
|
+
const listId = "list-launch-plan";
|
|
67
|
+
await app.createList({ title: "Launch plan" });
|
|
68
|
+
await app.addTodo({ listId, text: "Draft the deck" });
|
|
69
|
+
await app.addTodo({ listId, text: "Book the venue" });
|
|
70
|
+
ok("typed offline writes — createList + 2 addTodo, under the pulled law");
|
|
71
|
+
|
|
72
|
+
// the TYPED declared query routes LOCALLY; minted Todo ids come back on the rows
|
|
73
|
+
const seeded = await app.todosByList({ listId });
|
|
74
|
+
if (seeded.length !== 2) fail(`local query: expected 2 todos, got ${seeded.length}`);
|
|
75
|
+
const t1 = seeded.find((row) => row.data.text === "Draft the deck")!.id;
|
|
76
|
+
await app.toggleTodo({ todoId: t1, done: "done" }); // Lww enum — open ↔ done
|
|
77
|
+
await app.tagTodo({ todoId: t1, tags: ["urgent", "q3"] }); // AddWins set
|
|
78
|
+
ok(`read back a minted Todo id (${t1.slice(0, 16)}…) → toggled done + tagged`);
|
|
79
|
+
|
|
80
|
+
const localOpen = await app.openTodosByList(listId);
|
|
81
|
+
if (localOpen !== 1) fail(`local count: expected 1 open, got ${localOpen}`);
|
|
82
|
+
ok("local reads route — todosByList = 2, openTodosByList = 1 (2 todos − 1 done), no network");
|
|
95
83
|
|
|
96
84
|
globalThis.fetch = realFetch;
|
|
97
|
-
ok("offline proof ENFORCED — fetch was trapped: local write/query/
|
|
85
|
+
ok("offline proof ENFORCED — fetch was trapped: local write/query/count touched no network");
|
|
98
86
|
|
|
99
|
-
//
|
|
100
|
-
// Bare sync() IS the whole loop — admit defaults to true.
|
|
87
|
+
// 4. sync: push the session branch → edge admission merges to canonical main
|
|
101
88
|
const s = await holon.sync();
|
|
102
89
|
if (!s.admission?.ok) fail(`sync/admit: ${JSON.stringify(s)}`);
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
fail(`cloud declared query: ${JSON.stringify(d)}`);
|
|
111
|
-
}
|
|
112
|
-
ok("cloud declared query returns the admitted entry WITH the admitted reaction — full loop closed");
|
|
113
|
-
|
|
114
|
-
// 8. the EDGE projects the derived field + maintains the count too (same wasm, same law)
|
|
115
|
-
d = await (await fetch(`${CLOUD}/v1/workspaces/${WS}/aggregates/${encodeURIComponent(entryId)}`)).json();
|
|
116
|
-
if (d.rows?.[0]?.data?.moodEmoji !== "🤩") fail(`edge derived projection: ${JSON.stringify(d.rows?.[0]?.data)}`);
|
|
117
|
-
ok("derived field projects at the edge — moodEmoji 🤩");
|
|
118
|
-
d = await (await fetch(`${CLOUD}/v1/workspaces/${WS}/counts/entriesPerMood?group=delighted`)).json();
|
|
90
|
+
ok("synced — session branch admitted onto canonical main");
|
|
91
|
+
|
|
92
|
+
// 5. the CLOUD answers the declared query + the maintained count from canonical main
|
|
93
|
+
d = await (await fetch(`${CLOUD}/v1/workspaces/${WS}/query?queryId=todosByList¶ms=${encodeURIComponent(JSON.stringify({ listId }))}`)).json();
|
|
94
|
+
if (!(d.ok && d.rows.length === 2)) fail(`cloud query: ${JSON.stringify(d)}`);
|
|
95
|
+
ok("cloud query — todosByList returned both todos from canonical main");
|
|
96
|
+
d = await (await fetch(`${CLOUD}/v1/workspaces/${WS}/counts/openTodosByList?group=${encodeURIComponent(listId)}`)).json();
|
|
119
97
|
if (!(d.ok && d.count === 1)) fail(`cloud count: ${JSON.stringify(d)}`);
|
|
120
|
-
ok("cloud count
|
|
121
|
-
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
await
|
|
130
|
-
|
|
131
|
-
d = await (await fetch(`${CLOUD}/v1/workspaces/${WS}/
|
|
132
|
-
const
|
|
133
|
-
if (
|
|
134
|
-
ok(
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
// taggers' admitted writes — inside a pull; no reconnect, no rebuild.
|
|
138
|
-
await holon.pull();
|
|
139
|
-
const head = await holon.head();
|
|
140
|
-
const refsAdv = await (await fetch(`${CLOUD}/v1/workspaces/${WS}/git/info/refs?service=git-upload-pack`)).text();
|
|
141
|
-
const remoteMain = (refsAdv.match(/([0-9a-f]{40}) refs\/heads\/main/) || [])[1];
|
|
142
|
-
if (head !== remoteMain) fail(`not converged in place: local ${head?.slice(0, 10)} vs main ${remoteMain?.slice(0, 10)}`);
|
|
143
|
-
ok("converged in place — local head == canonical main, no reconnect");
|
|
144
|
-
|
|
145
|
-
// 11. WATCH — the local reactive read: watchById polls the LOCAL projection and fires
|
|
146
|
-
// the callback whenever the row changes (purely local — no network, no server push
|
|
147
|
-
// to wait on). A typed mutate lands; the watcher sees it.
|
|
148
|
-
const watched: Record<string, unknown>[] = [];
|
|
149
|
-
const stopWatch = holon.watchById(entryId, (rows) => { watched.push(rows[0]?.data ?? {}); }, { intervalMs: 50 });
|
|
150
|
-
await app.reactToEntry({ entryId, reactor: "carol", reaction: "wow" });
|
|
151
|
-
const sawReaction = async () => {
|
|
152
|
-
for (let i = 0; i < 100; i++) {
|
|
153
|
-
if (watched.some((dd) => (dd.reactions as Record<string, unknown> | undefined)?.carol === "wow")) return true;
|
|
154
|
-
await new Promise((res) => setTimeout(res, 50));
|
|
155
|
-
}
|
|
156
|
-
return false;
|
|
157
|
-
};
|
|
158
|
-
if (!(await sawReaction())) fail(`watchById never observed carol's reaction: ${JSON.stringify(watched.at(-1))}`);
|
|
159
|
-
stopWatch(); // the returned stop() ends the polling — no leaked timers
|
|
160
|
-
ok("watchById fires on change — typed mutate observed reactively (carol → wow), watcher stopped");
|
|
161
|
-
await holon.sync(); // ride carol's reaction to main so the ledger ends converged
|
|
162
|
-
|
|
163
|
-
// 12. DEAD-LETTER QUEUE — refused work is NEVER lost. The honest jam: a user's client
|
|
164
|
-
// on a FRESH workspace installs a domain LOCALLY (offline-first — the edge has no
|
|
165
|
-
// such law) and does real work under it. On sync the edge cannot certify that law:
|
|
166
|
-
// a DOMAIN rejection PARKS the intent — full provenance, durable, rides
|
|
167
|
-
// export()/restore — it does not vanish. (A directive whose domain isn't in YOUR
|
|
168
|
-
// law refuses at dispatch time, loudly — only committed-then-refused work parks.)
|
|
169
|
-
const WS2 = WS + "-dlq";
|
|
170
|
-
d = await (await fetch(`${CLOUD}/v1/workspaces/${WS2}`, { method: "POST", headers: { "x-nomos-principal": "nomos-e2e-suite" } })).json();
|
|
171
|
-
if (!d.ok) fail(`create DLQ workspace: ${JSON.stringify(d)}`);
|
|
172
|
-
const jammed = await connect({ cloud: CLOUD, workspace: WS2, clientId: "jammed-user" });
|
|
173
|
-
await jammed.dispatch("nomos", "installDomain", {
|
|
174
|
-
domainHash: GUESTBOOK_DOMAIN_HASH, packageUsda: deploy.packageUsda, installedBy: "local-user",
|
|
175
|
-
authorityScope: "workspace/root", dependencies: [], finalizers: [],
|
|
176
|
-
}, { domainHash: d.controller.domainHash });
|
|
177
|
-
await guestbookClient(jammed).signGuestbook({ author: "marta", message: "work I refuse to lose", mood: "happy", signedAt: new Date().toISOString() });
|
|
178
|
-
const js = await jammed.sync();
|
|
179
|
-
if (!(js.converged?.deadLettered ?? []).length) fail(`tenant write did not dead-letter: ${JSON.stringify(js.converged)}`);
|
|
180
|
-
const dlq = await jammed.deadLetters();
|
|
181
|
-
if (!(dlq.length === 1 && dlq[0]!.domain === "guestbook" && dlq[0]!.directiveId === "signGuestbook" && dlq[0]!.source === "edge")) {
|
|
182
|
-
fail(`DLQ provenance wrong: ${JSON.stringify(dlq)}`);
|
|
183
|
-
}
|
|
184
|
-
ok(`dead letter parked with provenance — ${dlq[0]!.domain}/${dlq[0]!.directiveId} refused by ${dlq[0]!.source}, work NOT lost`);
|
|
185
|
-
// THE RETRY LANE (not run here — see test/dlq.e2e.mjs in the nomos2 repo for the full
|
|
186
|
-
// unjam): the domain dev deploys the law fix (`POST /v1/workspaces/:ws/domains`, secret-
|
|
187
|
-
// gated), then `retryDeadLetter(id)` — or the owner's `POST /dead-letters/retry` — lands
|
|
188
|
-
// the parked write on main. Here the app makes the OTHER explicit choice:
|
|
189
|
-
const dropped = await jammed.discardDeadLetter(dlq[0]!.id ?? dlq[0]!.oid);
|
|
190
|
-
if (!(dropped.ok && dropped.removed === 1)) fail(`discard: ${JSON.stringify(dropped)}`);
|
|
191
|
-
if ((await jammed.deadLetters()).length !== 0) fail("DLQ not drained after discard");
|
|
192
|
-
ok("discardDeadLetter drains the queue — the APP's explicit choice, never the runtime's");
|
|
193
|
-
|
|
194
|
-
console.log(`\nALL GREEN — typed client: compile → deploy → connect → typed write/query/mutate/derive/count → admit → converge → watch → dead-letter custody → cloud reads (${WS})`);
|
|
98
|
+
ok("cloud count — openTodosByList = 1 (the where-filtered O(1) tally, maintained)");
|
|
99
|
+
|
|
100
|
+
// 6. SHARING — one native Zanzibar tuple. Author it through the owner's secret-gated
|
|
101
|
+
// /author lane, then read it back from the cloud's audit query.
|
|
102
|
+
r = await fetch(`${CLOUD}/v1/workspaces/${WS}/author`, {
|
|
103
|
+
method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${SECRET}` },
|
|
104
|
+
body: JSON.stringify({ domain: "todo", directiveId: "shareList", payload: {
|
|
105
|
+
listId, userId: "bob", role: "editor", sharedBy: "owner-1", sharedAt: new Date().toISOString() } }),
|
|
106
|
+
});
|
|
107
|
+
d = await r.json();
|
|
108
|
+
if (!d.ok) fail(`shareList author: ${JSON.stringify(d)}`);
|
|
109
|
+
d = await (await fetch(`${CLOUD}/v1/workspaces/${WS}/query?queryId=relationTuplesByObject¶ms=${encodeURIComponent(JSON.stringify({ object: `TodoList:${listId}` }))}`)).json();
|
|
110
|
+
const editor = (d.rows ?? []).find((u: { data: { relation: string } }) => u.data.relation === "editor");
|
|
111
|
+
if (editor?.data?.subjects?.["user:bob"]?.status !== "active") fail(`sharing tuple not recorded: ${JSON.stringify(d)}`);
|
|
112
|
+
ok("sharing — TodoList#editor@user:bob recorded as a native relation tuple (active)");
|
|
113
|
+
|
|
114
|
+
console.log(`\nALL GREEN — typed client: compile → deploy → connect → typed offline writes → query/count → admit → cloud reads → native sharing (${WS})`);
|
|
@@ -1,164 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* guestbook — the smallest complete Nomos tenant domain, written the only way a
|
|
3
|
-
* domain is ever written: aggregates + directives on `@githolon/dsl`. No apply/fold/
|
|
4
|
-
* merge code — the kernel owns folding; the directive plans ops, the sealed engine
|
|
5
|
-
* replays them deterministically on every peer.
|
|
6
|
-
*
|
|
7
|
-
* What it shows:
|
|
8
|
-
* * an aggregate with Lww scalars, an AddWins set, and a MapOf(Lww) map;
|
|
9
|
-
* * a `.creates` directive (Nomos mints the aggregate id — never the caller);
|
|
10
|
-
* * a `.mutates` directive bound to an existing instance;
|
|
11
|
-
* * a declared, indexed read query (auto-discovered by `nomos-compile`);
|
|
12
|
-
* * a DERIVED read field (engine-projected, PURE fn of the folded fields);
|
|
13
|
-
* * a COUNT (a maintained tally, grouped by a field).
|
|
14
|
-
*/
|
|
15
|
-
import {
|
|
16
|
-
z,
|
|
17
|
-
aggregate,
|
|
18
|
-
t,
|
|
19
|
-
Lww,
|
|
20
|
-
AddWins,
|
|
21
|
-
MapOf,
|
|
22
|
-
directive,
|
|
23
|
-
create,
|
|
24
|
-
instance,
|
|
25
|
-
set,
|
|
26
|
-
setEntry,
|
|
27
|
-
addToSet,
|
|
28
|
-
query,
|
|
29
|
-
derived,
|
|
30
|
-
count,
|
|
31
|
-
type DerivedDecl,
|
|
32
|
-
type CountDecl,
|
|
33
|
-
} from "@githolon/dsl";
|
|
34
|
-
|
|
35
|
-
// --- vocabulary ---------------------------------------------------------------
|
|
36
|
-
|
|
37
|
-
export const ENTRY_MOODS = ["delighted", "happy", "neutral", "grumpy"] as const;
|
|
38
|
-
|
|
39
|
-
// --- aggregate ----------------------------------------------------------------
|
|
40
|
-
|
|
41
|
-
/** An entry may carry at most this many tags — the declared invariant's honest rule. */
|
|
42
|
-
export const MAX_TAGS = 10;
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* One guestbook entry. The wire type `GuestbookEntry` is what readers key on.
|
|
46
|
-
*
|
|
47
|
-
* THE DECLARED AGGREGATE INVARIANT (#41 — declared invariants are REAL law): an
|
|
48
|
-
* entry holds AT MOST {@link MAX_TAGS} tags. The kernel judges the POST-FOLD
|
|
49
|
-
* snapshot at the ONE admission gate on every peer — a write that would leave
|
|
50
|
-
* an entry over the limit refuses TYPED (`tags-exceed-limit:10`), offline and
|
|
51
|
-
* on the edge alike. AddWins still merges concurrent adds; the invariant
|
|
52
|
-
* bounds what any single admitted write may leave behind.
|
|
53
|
-
*/
|
|
54
|
-
export const GuestbookEntry = aggregate(
|
|
55
|
-
"GuestbookEntry",
|
|
56
|
-
{
|
|
57
|
-
author: t.string().merge(Lww),
|
|
58
|
-
message: t.string().merge(Lww),
|
|
59
|
-
mood: t.enum(ENTRY_MOODS).merge(Lww),
|
|
60
|
-
signedAt: t.string().merge(Lww), // ISO-8601, caller-stamped
|
|
61
|
-
updatedAt: t.string().merge(Lww), // ISO-8601
|
|
62
|
-
/** Free-form additive tags — concurrent adds commute (AddWins). */
|
|
63
|
-
tags: t.set(t.string()).merge(AddWins),
|
|
64
|
-
/** Reactions keyed by reactor — per-key Lww, concurrent reactors commute. */
|
|
65
|
-
reactions: t.map(t.string()).merge(MapOf(Lww)),
|
|
66
|
-
},
|
|
67
|
-
{
|
|
68
|
-
invariant: (snap) => {
|
|
69
|
-
const tags = snap["tags"];
|
|
70
|
-
const n = Array.isArray(tags) ? tags.length : 0;
|
|
71
|
-
return n > MAX_TAGS ? { reject: `tags-exceed-limit:${MAX_TAGS}` } : { accept: true };
|
|
72
|
-
},
|
|
73
|
-
},
|
|
74
|
-
);
|
|
75
|
-
|
|
76
|
-
// --- directives ---------------------------------------------------------------
|
|
77
|
-
|
|
78
|
-
/** Sign the guestbook: creates an entry (Nomos mints the id). */
|
|
79
|
-
export const signGuestbook = directive("signGuestbook")
|
|
80
|
-
.creates(GuestbookEntry)
|
|
81
|
-
.payload(
|
|
82
|
-
z.object({
|
|
83
|
-
author: z.string().min(1),
|
|
84
|
-
message: z.string().min(1),
|
|
85
|
-
mood: z.enum(ENTRY_MOODS),
|
|
86
|
-
signedAt: z.string(), // ISO-8601
|
|
87
|
-
tags: z.array(z.string()).optional(),
|
|
88
|
-
}),
|
|
89
|
-
)
|
|
90
|
-
.plan((p) => {
|
|
91
|
-
const entry = create(GuestbookEntry);
|
|
92
|
-
entry
|
|
93
|
-
.set("author", p.author)
|
|
94
|
-
.set("message", p.message)
|
|
95
|
-
.set("mood", p.mood)
|
|
96
|
-
.set("signedAt", p.signedAt)
|
|
97
|
-
.set("updatedAt", p.signedAt);
|
|
98
|
-
for (const tag of p.tags ?? []) entry.add("tags", tag);
|
|
99
|
-
return [];
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
* Tag an entry — the ADDITIVE set write. Two people tagging the SAME entry at
|
|
104
|
-
* the SAME time both win: AddWins unions the adds, nothing is lost. (addToSet
|
|
105
|
-
* is the ONLY additive write to an AddWins set — set() would overwrite, and is
|
|
106
|
-
* refused at both the type level and runtime.)
|
|
107
|
-
*/
|
|
108
|
-
export const tagEntry = directive("tagEntry")
|
|
109
|
-
.mutates(GuestbookEntry)
|
|
110
|
-
.payload(z.object({ entryId: z.string(), tags: z.array(z.string()).min(1) }))
|
|
111
|
-
.plan((p) => [addToSet(instance(GuestbookEntry, p.entryId), "tags", p.tags)]);
|
|
112
|
-
|
|
113
|
-
/** React to an entry: a map-PUT at the reactor's key (concurrent reactors commute). */
|
|
114
|
-
export const reactToEntry = directive("reactToEntry")
|
|
115
|
-
.mutates(GuestbookEntry)
|
|
116
|
-
.payload(
|
|
117
|
-
z.object({
|
|
118
|
-
entryId: z.string(),
|
|
119
|
-
reactor: z.string().min(1),
|
|
120
|
-
reaction: z.string().min(1),
|
|
121
|
-
reactedAt: z.string(), // ISO-8601
|
|
122
|
-
}),
|
|
123
|
-
)
|
|
124
|
-
.plan((p) => {
|
|
125
|
-
const entry = instance(GuestbookEntry, p.entryId);
|
|
126
|
-
return [
|
|
127
|
-
setEntry(entry, "reactions", p.reactor, p.reaction),
|
|
128
|
-
set(entry, "updatedAt", p.reactedAt),
|
|
129
|
-
];
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
// --- declared read queries (auto-discovered by nomos-compile) -------------------
|
|
133
|
-
|
|
134
|
-
/** All entries by one author — an indexed probe, not a scan. */
|
|
135
|
-
export const entriesByAuthor = query("entriesByAuthor").key("author").returns(GuestbookEntry);
|
|
136
|
-
|
|
137
|
-
// --- derived read fields (engine-projected, PURE; NEVER stamped into the ledger) ---
|
|
138
|
-
|
|
139
|
-
/** The mood → emoji rendering table — verbatim the {@link ENTRY_MOODS} vocabulary. */
|
|
140
|
-
export const MOOD_EMOJI: Record<(typeof ENTRY_MOODS)[number], string> = {
|
|
141
|
-
delighted: "🤩",
|
|
142
|
-
happy: "😊",
|
|
143
|
-
neutral: "🙂",
|
|
144
|
-
grumpy: "😠",
|
|
145
|
-
};
|
|
146
|
-
|
|
147
|
-
/**
|
|
148
|
-
* `GuestbookEntry.moodEmoji` — an ENGINE-PROJECTED DERIVED read field: a PURE fn of the
|
|
149
|
-
* entry's folded `mood` (the {@link MOOD_EMOJI} table lookup; `"❔"` while `mood` is
|
|
150
|
-
* still unfolded — partial folds are normal). Computed BY THE ENGINE during the
|
|
151
|
-
* read-model projection and stored ONLY in the read model — never stamped into a kernel
|
|
152
|
-
* op/event, so the ledger stays pure user-intents and the value is always re-derivable.
|
|
153
|
-
* Exported INDIVIDUALLY at top level: both the engine bundle and `nomos-compile`
|
|
154
|
-
* auto-discover it from the module exports by shape ({id, of, returns, fn}).
|
|
155
|
-
*/
|
|
156
|
-
export const moodEmoji: DerivedDecl = derived("moodEmoji")
|
|
157
|
-
.of(GuestbookEntry)
|
|
158
|
-
.returns(z.string())
|
|
159
|
-
.as((a) => MOOD_EMOJI[a.mood as (typeof ENTRY_MOODS)[number]] ?? "❔");
|
|
160
|
-
|
|
161
|
-
// --- counts (maintained tallies — O(1) reads, never a scan) ----------------------
|
|
162
|
-
|
|
163
|
-
/** How many entries per mood — one maintained counter per distinct `mood` value. */
|
|
164
|
-
export const entriesPerMood: CountDecl = count("entriesPerMood").of(GuestbookEntry).by("mood");
|