create-rindle 0.4.4 → 0.6.3

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 CHANGED
@@ -116,8 +116,8 @@ async function main() {
116
116
  (opts.install ? "" : ` ${pm} install\n`) +
117
117
  ` ${run} dev\n\n` +
118
118
  ` The migrations in migrations/*.sql are the source of truth — \`${run} dev\` applies them,\n` +
119
- ` generates shared/schema.gen.ts, boots the daemon + API + Vite, and seeds a little data.\n` +
120
- ` Open two browser windows to watch writes sync live.\n\n`,
119
+ ` generates shared/schema.gen.ts, and boots the topology pair + Vite (app + /api/rindle routes).\n` +
120
+ ` Open two browser windows, create a room, and watch writes sync live.\n\n`,
121
121
  );
122
122
  }
123
123
 
package/lib/scaffold.mjs CHANGED
@@ -18,7 +18,7 @@ export const PROJECT_NAME_TOKEN = "__PROJECT_NAME__";
18
18
  // future binary asset (an icon, say) is copied verbatim rather than corrupted by a string replace.
19
19
  const TEXT_EXT = new Set([
20
20
  ".ts", ".tsx", ".js", ".mjs", ".cjs", ".json", ".jsonc", ".css", ".html", ".sql", ".md", ".txt",
21
- ".gitignore", ".npmrc", ".env",
21
+ ".ncl", ".gitignore", ".npmrc", ".env",
22
22
  ]);
23
23
 
24
24
  const isTextFile = (name) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-rindle",
3
- "version": "0.4.4",
3
+ "version": "0.6.3",
4
4
  "license": "Apache-2.0",
5
5
  "repository": {
6
6
  "type": "git",
@@ -3,9 +3,10 @@
3
3
  Guidance for coding agents (and new humans). This is a **Rindle** app — an
4
4
  incremental-view-maintenance (IVM) engine keeps every registered query's result
5
5
  exact on each write instead of re-running it. Three tiers: a TanStack Start SPA
6
- running the wasm engine in-process, an API authority, and the `rindled` daemon
7
- that owns the SQLite data. The correctness contract everywhere is
8
- **view-after-write == fresh-query**.
6
+ running the wasm engine in-process, an API authority, and the data tier — the one
7
+ topology (design 214): a `rindle-replicator` write-master + a `rindled`
8
+ read-follower (`followers = 1` = the colocated pair). The correctness contract
9
+ everywhere is **view-after-write == fresh-query**.
9
10
 
10
11
  Rindle docs are served as raw markdown for LLMs: index at
11
12
  <https://rindle.sh/llms.txt>, the whole app track in one file at
@@ -13,12 +14,22 @@ Rindle docs are served as raw markdown for LLMs: index at
13
14
 
14
15
  ## Commands
15
16
 
16
- - `pnpm dev` — the only command you need: boots `rindled` (prebuilt binary from
17
- `@rindle/cli`, no Rust toolchain), applies `migrations/*.sql`, regenerates
18
- `shared/schema.gen.ts`, seeds, and starts Vite (the app + the `/api/rindle`
19
- server routes). Re-applies/regenerates on every `migrations/` change.
17
+ - `pnpm dev` — the one command: `concurrently` runs two processes — **fleet**
18
+ (`rindle up` renders `rindle.ncl` and supervises the pair — the
19
+ `rindle-replicator` write-master + the `rindled` follower, prebuilt binaries
20
+ from `@rindle/cli`, no Rust toolchain) applies `migrations/*.sql` to the master
21
+ and regenerates `shared/schema.gen.ts` from the follower (re-running both on
22
+ every `migrations/` change), and **web** (`rindle exec -- vite dev` on :3000) receives bindings
23
+ derived from `rindle.ncl` and serves the app +
24
+ the `/api/rindle/*` server routes. Run either alone with `pnpm fleet` /
25
+ `pnpm dev:web`.
20
26
  - `pnpm typecheck` — regenerates the route tree, then `tsc --noEmit`.
21
- - `pnpm migrate` / `pnpm seed` — one-shot apply / seed against the running daemon.
27
+ - `pnpm migrate` — one-shot `rindle exec -- rindle migrate apply` against the write-master derived
28
+ from `rindle.ncl` (the follower's `/migrate` is write-fenced). The dev loop already
29
+ applies on boot + on every `migrations/` change.
30
+ - `pnpm rindle:deploy` / `pnpm rindle:migrate:remote` — deploy the data tier to
31
+ Rindle Cloud (reads `rindle.ncl`, the same file `rindle up` runs locally; run
32
+ `rindle login` once first) and push `migrations/*.sql` to the deployed master.
22
33
 
23
34
  ## Rules that keep the app correct
24
35
 
@@ -49,9 +60,9 @@ review:
49
60
  `src/components/*.queries.ts` and register them in `server/app-api.ts`. An
50
61
  ad-hoc `store.query.…` builder resolves **locally only** — it never opens a
51
62
  server subscription.
52
- 5. **The daemon token is server-only.** It gates the private control plane
53
- (`:7600`) and must never reach the browser; the browser holds only the
54
- lease-gated public WebSocket (`:7601`).
63
+ 5. **Daemon tokens are server-only.** They gate the follower and write-master control planes and
64
+ must never reach the browser; the browser reaches only the lease-gated fleet edge. Local endpoint
65
+ bindings come from `rindle exec`; never copy topology ports into package scripts.
55
66
  6. **Subscribe to windows, not whole tables** — order + `limit`, and ratchet
56
67
  `limit` up for "load more". The engine keeps the window (and any `countAs`)
57
68
  exact as rows enter and leave.
@@ -69,7 +80,7 @@ review:
69
80
  | `src/rindle-client.ts` | the one-call browser wire-up (`createRindleClient`) |
70
81
  | `server/app-api.ts` | the authority: `registerQueries` + `sharedApiMutators` + server-only policy |
71
82
  | `src/routes/api.rindle.*.tsx` | TanStack Start server routes exposing the authority over HTTP |
72
- | `server/dev.ts` | dev orchestration (`rindle up` + seed + Vite) |
83
+ | `rindle.ncl` | the one topology (the colocated pair) — `rindle up` runs it locally, `rindle deploy` provisions it |
73
84
  | `src/ssr.ts` | SSR preload of the same named queries for first paint |
74
85
 
75
86
  ## Reading more
@@ -18,7 +18,10 @@ Three tiers, same as the Rindle flagship examples:
18
18
  reaches it through TanStack Start **server routes** (`src/routes/api.rindle.{query,read,mutate}.tsx`,
19
19
  via `server/rindle-http.ts`) that run in the same server as the app, and SSR calls the very same
20
20
  factory **in-process** (no network hop).
21
- - **Daemon** (`rindled`) owns the SQLite data + live IVM and streams normalized deltas to subscribers.
21
+ - **Data tier** — the one topology (design 214): a `rindle-replicator` **write-master** plus a
22
+ `rindled` **read-follower** that owns the live IVM and streams normalized deltas to subscribers.
23
+ Writes land on the master; the follower serves reads. `followers = 1` is the *colocated pair* —
24
+ both processes on one box, the smallest shape.
22
25
 
23
26
  ## SQL is the source of truth
24
27
 
@@ -35,16 +38,47 @@ pnpm install
35
38
  pnpm dev
36
39
  ```
37
40
 
38
- `pnpm dev` (`server/dev.ts`) is one command: it boots the daemon (`rindle up` supervising `rindled`
39
- prebuilt binaries from `@rindle/cli`, no Rust toolchain needed), applies the migrations, regenerates
40
- `shared/schema.gen.ts`, starts Vite (which serves both the app and the `/api/rindle` route), and seeds
41
- a little data. Open two browser windows
42
- to watch writes sync live. Post a message and watch the room's count update on the home page. Try a
43
- room name or message containing "spam" to see the rejection path.
41
+ `pnpm dev` runs two processes with [`concurrently`](https://www.npmjs.com/package/concurrently)
42
+ the standard TanStack Start dev shape:
44
43
 
45
- > Requires Node 22.18 (runs the `.ts` server files via Node's built-in type stripping). The TanStack
46
- > route tree (`src/routeTree.gen.ts`) is a generated artifact `pnpm dev` and `pnpm generate-routes`
47
- > produce it; `pnpm typecheck` runs `tsr generate` first.
44
+ - **`fleet`** `rindle up` renders `rindle.ncl` and supervises the whole pair (the
45
+ `rindle-replicator` write-master + the `rindled` followerprebuilt binaries from `@rindle/cli`,
46
+ no Rust toolchain needed), applies the migrations to the master, and regenerates
47
+ `shared/schema.gen.ts` from the follower's schema — re-running both on every `migrations/*.sql`
48
+ change (`--watch`).
49
+ - **`web`** — `rindle exec -- vite dev` on <http://localhost:3000>, which serves both the app and the
50
+ `/api/rindle/*` Start server routes. `rindle exec` derives the stable fleet-edge read/ws binding
51
+ and write-master binding from `rindle.ncl`; no endpoint is copied into the package scripts.
52
+
53
+ You can run either alone: `pnpm fleet` / `pnpm dev:web`. Both processes bind to loopback and run
54
+ without an auth token in dev; a real deployment sets tokens (`RINDLE_DAEMON_TOKEN` for reads,
55
+ `RINDLE_REPLICATOR_TOKEN` for writes on the server) that never reach the browser.
56
+
57
+ Open two browser windows to watch writes sync live: create a room, post a message, and watch the
58
+ room's count update on the home page with no polling. Try a room name or message containing "spam" to
59
+ see the rejection path (the optimistic write snaps back + a toast).
60
+
61
+ > Requires Node ≥ 22.18. The TanStack route tree (`src/routeTree.gen.ts`) is a generated artifact —
62
+ > `pnpm dev` and `pnpm generate-routes` produce it; `pnpm typecheck` runs `tsr generate` first.
63
+
64
+ ## Deploy
65
+
66
+ `rindle.ncl` describes the **one topology** — the same file `rindle up` runs locally. To run the
67
+ **data tier** on Rindle Cloud:
68
+
69
+ ```bash
70
+ rindle login # once — authenticate to Rindle Cloud
71
+ pnpm rindle:deploy # provision / re-attach the managed app (writes .rindle/cloud.json)
72
+ pnpm rindle:migrate:remote # push migrations/*.sql to the deployed write-master
73
+ ```
74
+
75
+ `rindle deploy` reads `rindle.ncl` and records the binding in `.rindle/cloud.json` — commit it, so
76
+ future deploys re-attach to the same app. `followers = 1` provisions the colocated pair
77
+ (`single-backup`); raise it for read replicas (`read-scaled`). That deploys the **data tier**; the
78
+ **web app** (this Vite/TanStack Start app) deploys to any Node host — point its `RINDLE_DAEMON_URL`
79
+ (fleet-edge reads) + `RINDLE_REPLICATOR_URL` (writes) + their tokens (server) at the deployed data
80
+ tier, and set `VITE_FLEET_WS` to the stable public fleet ws. These bindings are identical for one or
81
+ many followers, and follower affinity is always enabled.
48
82
 
49
83
  ## Devtools
50
84
 
@@ -63,7 +97,7 @@ of `vite build` and never ship to production.
63
97
  | `shared/schema.gen.ts` | generated `@rindle/client` schema (don't edit by hand) |
64
98
  | `shared/app-def.ts` | the contract root: schema re-export, relationships, isomorphic mutators |
65
99
  | `shared/auth.ts` | the identity seam (`AuthProvider`) |
66
- | `server/dev.ts` | one-command dev orchestrator |
100
+ | `rindle.ncl` | the one topology (the colocated pair) — `rindle up` runs it locally, `rindle deploy` provisions it |
67
101
  | `server/app-api.ts` | the authority: query resolution, `sharedApiMutators`, policy (host-agnostic) |
68
102
  | `server/rindle-http.ts` | adapts the authority to a Web Request (the Start API routes call it) |
69
103
  | `src/routes/api.rindle.*.tsx` | the three API endpoints as Start server routes (the browser's API) |
@@ -5,3 +5,9 @@ dist
5
5
  *.db-*
6
6
  .DS_Store
7
7
  *.log
8
+
9
+ # Rindle Cloud: ignore the rendered manifests, but COMMIT the binding (.rindle/cloud.json) so
10
+ # `rindle deploy` re-attaches future deploys to the same managed app.
11
+ /rindle.json
12
+ /.rindle/*
13
+ !/.rindle/cloud.json
@@ -8,12 +8,16 @@
8
8
  "node": ">=22.18"
9
9
  },
10
10
  "scripts": {
11
- "dev": "node server/dev.ts",
12
- "migrate": "node server/migrate.ts",
11
+ "fleet": "rindle up --migrate --gen shared/schema.gen.ts --watch --dir migrations",
12
+ "dev:web": "rindle exec -- vite dev --port 3000",
13
+ "dev": "concurrently -k -n fleet,web -c blue,green \"pnpm fleet\" \"pnpm dev:web\"",
13
14
  "generate-routes": "tsr generate",
14
15
  "build": "vite build",
15
- "typecheck": "tsr generate && tsc --noEmit",
16
- "seed": "node server/seed.ts"
16
+ "preview": "vite preview",
17
+ "migrate": "rindle exec -- rindle migrate apply --dir migrations",
18
+ "rindle:deploy": "rindle deploy",
19
+ "rindle:migrate:remote": "rindle migrate apply --remote",
20
+ "typecheck": "tsr generate && tsc --noEmit"
17
21
  },
18
22
  "dependencies": {
19
23
  "@rindle/api-server": "latest",
@@ -26,6 +30,7 @@
26
30
  "@tanstack/react-start": "latest",
27
31
  "react": "^19.1.0",
28
32
  "react-dom": "^19.1.0",
33
+ "ulid": "^3.0.2",
29
34
  "zod": "^4.4.3"
30
35
  },
31
36
  "devDependencies": {
@@ -34,6 +39,7 @@
34
39
  "@rindle/react-devtools": "latest",
35
40
  "@tanstack/router-cli": "^1.167.17",
36
41
  "@tanstack/router-plugin": "^1.168.18",
42
+ "concurrently": "^10.0.3",
37
43
  "@types/node": "^22.10.0",
38
44
  "@types/react": "^19.0.0",
39
45
  "@types/react-dom": "^19.0.0",
@@ -0,0 +1,27 @@
1
+ # The Rindle topology for this app — the ONE topology (design 214): a `rindle-replicator`
2
+ # write-master + one `rindled` read-follower. `followers = 1` is the colocated pair, the smallest
3
+ # shape; raise it for more read replicas.
4
+ #
5
+ # A topology is a plain record of inputs; `rindle up` (local) and `rindle deploy` / `rindle render`
6
+ # (cloud) merge it onto the topology library embedded in the `rindle` binary — nothing to import
7
+ # or install. LOCAL dev reads THIS file: `rindle up` renders it and supervises the pair (see
8
+ # package.json `dev` / `fleet`). The same file drives the cloud:
9
+ #
10
+ # rindle login # once — authenticate to Rindle Cloud
11
+ # rindle deploy # provision / re-attach the managed app (writes .rindle/cloud.json)
12
+ # rindle migrate apply --remote # push migrations/*.sql to the deployed write-master
13
+ #
14
+ # `app` becomes the cloud display name and groups every component under one app in `rindle ps`.
15
+ # `followers = 1` maps to the cloud `single-backup` rung (one box, both processes); `followers > 1`
16
+ # maps to `read-scaled`.
17
+ #
18
+ # Every follower count renders a local fleet edge (the Fly-edge stand-in;
19
+ # FOLLOWER-AFFINITY-DESIGN.md §10). `rindle exec` derives the app's read/write/ws bindings from this
20
+ # file, so changing `followers = 1` to a wider fleet requires no application configuration change.
21
+ # Set `RINDLE_DEV_CLIENT_REGION=A` to simulate a client region; the edge always mints an affinity
22
+ # placement ticket, with a fleet of one resolving trivially to its only follower.
23
+ {
24
+ profile = "replicated",
25
+ app = "__PROJECT_NAME__",
26
+ followers = 1,
27
+ }
@@ -14,7 +14,9 @@ import {
14
14
  defineApiMutators,
15
15
  registerQueries,
16
16
  runSharedMutation,
17
+ scoped,
17
18
  sharedApiMutators,
19
+ SplitDaemonClient,
18
20
  } from "@rindle/api-server";
19
21
  import type {
20
22
  ApiMutator,
@@ -22,7 +24,6 @@ import type {
22
24
  MutationContext,
23
25
  MutatorCtx,
24
26
  RindleApiServer,
25
- ServerMutationTx,
26
27
  SharedMutatorWithArgs,
27
28
  } from "@rindle/api-server";
28
29
  import { HttpRindleDaemonClient } from "@rindle/daemon-client";
@@ -55,9 +56,13 @@ const apiQueries = registerQueries<User>([roomsQuery, roomDetailQuery]);
55
56
  // whose server run needs no authority beyond that triad needs no entry here at all.
56
57
  //
57
58
  // The ONLY explicit entries are server-only AUTHORITY the client must NOT predict, each OVERRIDING
58
- // its auto-wrapped default (spread first, override wins): the "spam" policy guard (so the demo's
59
- // rejection path stays exercised end to end) and postMessage's room-exists gate (relational SQL a
60
- // keyed op can't express the raw `tx.exec` escape hatch).
59
+ // its auto-wrapped default (spread first, override wins):
60
+ // createRoom a `withGuard` arg policy: an empty or "spam" name hard-rejects BEFORE any write.
61
+ // postMessage a `scoped` mutator (work-outside-tx): it runs async MODERATION *outside* the
62
+ // transaction, then opens the ONE write via `scope.transact`, driving the SAME isomorphic body the
63
+ // browser predicted (its room-exists guard is a `tx.row` read — no raw SQL, and the client predicts
64
+ // it too). A scoped mutator lets server-only work that must NOT hold a DB write open (a moderation/
65
+ // LLM/anti-abuse call, a payment) run before — or after — the atomic write.
61
66
 
62
67
  /** The `MutatorCtx` a shared body sees on the server: the AUTHENTICATED subject (throws if absent —
63
68
  * a business rejection). Never a client-supplied author. */
@@ -81,27 +86,42 @@ const apiMutators = defineApiMutators<User, ApiMutators<User>>({
81
86
  // (a) a server-only policy on the shared body: an empty or "spam" name throws BEFORE any write.
82
87
  createRoom: withGuard(sharedMutators.createRoom, (a) => void cleanName(a.name)),
83
88
 
84
- // (b) the raw-SQL escape hatch: insert only into a room that exists, so a racing client can't post
85
- // into a deleted room. The client predicts the plain shared insert and it snaps back if this
86
- // no-ops. `author` is the VERIFIED subject, never a wire arg.
87
- postMessage: async (tx: ServerMutationTx, raw: unknown, ctx: MutationContext<User>) => {
89
+ // (b) a SCOPED mutator (work-outside-tx): server-only code runs OUTSIDE the write transaction, then
90
+ // ONE atomic write opens via `scope.transact`, then post-commit code may run.
91
+ postMessage: scoped<User, unknown>(async (scope, raw, ctx) => {
88
92
  const a = postMessageArgs.parse(raw);
89
- const author = sharedCtx(ctx).user;
90
- const body = cleanBody(a.body);
91
- tx.exec(
92
- `INSERT INTO message (id, roomId, author, body, createdAt)
93
- SELECT ?, ?, ?, ?, ? WHERE EXISTS (SELECT 1 FROM room WHERE id = ?)`,
94
- [a.id, a.roomId, author, body, a.createdAt, a.roomId],
95
- );
96
- },
93
+
94
+ // OUTSIDE the transaction: moderation is an async, out-of-database call (a real app would reach an
95
+ // LLM or anti-abuse service) — the ONE thing here the client genuinely can't predict. A scoped
96
+ // mutator exists precisely so this does NOT hold a DB write open across the network round trip. A
97
+ // throw rejects the post before any write the client's optimistic message snaps back. For a
98
+ // NON-idempotent external effect, key it on the stable `ctx.envelope.mid` so a retried envelope
99
+ // can't double-fire it.
100
+ await moderateBody(a.body);
101
+
102
+ // INSIDE the transaction: drive the SAME isomorphic body the browser predicted (shared/app-def.ts).
103
+ // Its room-exists guard is a `tx.row` READ (read-your-writes) — no raw SQL, no server-only insert —
104
+ // so the client predicts the same guard. This is the one atomic write; the daemon stamps `lmid`
105
+ // together with it. A throw inside the body would be a business rejection surfaced as
106
+ // `MutationRejected` (data rolls back, `lmid` still advances).
107
+ await scope.transact(sharedMutators.postMessage, a, sharedCtx(ctx));
108
+
109
+ // AFTER commit: fire post-commit effects here (a push notification, a webhook). They can't roll the
110
+ // write back, so keep them best-effort (wrap your own try/catch). None are needed for this demo.
111
+ }),
97
112
  });
98
113
 
99
114
  /** A bearer-auth'd daemon control-plane target. */
100
115
  export interface AppApiOptions {
101
- /** The daemon control-plane base URL — bearer-auth'd. */
116
+ /** The follower/read daemon control-plane base URL — bearer-auth'd. */
102
117
  daemonUrl: string;
103
118
  /** The shared bearer token for {@link daemonUrl} (daemon ↔ this tier ONLY — never reaches the browser). */
104
119
  daemonToken: string;
120
+ /** The required write-master (`rindle-replicator`) control-plane base URL. Writes + mutation
121
+ * sessions go here; reads stay on {@link daemonUrl}. */
122
+ replicatorUrl: string;
123
+ /** Bearer for {@link replicatorUrl}, if it gates its write plane. */
124
+ replicatorToken?: string;
105
125
  /** Override the daemon HTTP transport (defaults to global `fetch`). */
106
126
  fetch?: FetchLike;
107
127
  }
@@ -109,13 +129,20 @@ export interface AppApiOptions {
109
129
  /** Build the configured API server. Stateless: safe to construct per-request or once per process.
110
130
  * Reads are PUBLIC; writes require a verified identity. */
111
131
  export function createAppApi(opts: AppApiOptions): RindleApiServer<User> {
112
- const daemon = new HttpRindleDaemonClient({
132
+ const reads = new HttpRindleDaemonClient({
113
133
  baseUrl: opts.daemonUrl,
114
134
  headers: { authorization: `Bearer ${opts.daemonToken}` },
115
135
  fetch: opts.fetch,
116
136
  });
117
137
  return createRindleApiServer<User>({
118
- daemon,
138
+ daemon: new SplitDaemonClient(
139
+ new HttpRindleDaemonClient({
140
+ baseUrl: opts.replicatorUrl,
141
+ headers: opts.replicatorToken ? { authorization: `Bearer ${opts.replicatorToken}` } : undefined,
142
+ fetch: opts.fetch,
143
+ }),
144
+ reads,
145
+ ),
119
146
  schema, // drives the dialect-SQL renderer for the shared mutators' logical ops
120
147
  queries: apiQueries,
121
148
  mutators: apiMutators,
@@ -128,10 +155,16 @@ export function createAppApi(opts: AppApiOptions): RindleApiServer<User> {
128
155
  export function resolveDaemon(
129
156
  env: Record<string, string | undefined>,
130
157
  defaults: { daemonUrl: string; daemonToken: string },
131
- ): Pick<AppApiOptions, "daemonUrl" | "daemonToken"> {
158
+ ): Pick<AppApiOptions, "daemonUrl" | "daemonToken" | "replicatorUrl" | "replicatorToken"> {
159
+ const replicatorUrl = env.RINDLE_REPLICATOR_URL;
160
+ if (!replicatorUrl) {
161
+ throw new Error("RINDLE_REPLICATOR_URL is required: writes must target the replicator write-master");
162
+ }
132
163
  return {
133
164
  daemonUrl: env.RINDLE_DAEMON_URL ?? defaults.daemonUrl,
134
165
  daemonToken: env.RINDLE_DAEMON_TOKEN ?? defaults.daemonToken,
166
+ replicatorUrl,
167
+ replicatorToken: env.RINDLE_REPLICATOR_TOKEN,
135
168
  };
136
169
  }
137
170
 
@@ -152,11 +185,13 @@ function cleanName(name: string): string {
152
185
  return out;
153
186
  }
154
187
 
155
- function cleanBody(body: string): string {
156
- const out = normalizeBody(body);
157
- if (out.length === 0) throw new Error("a message can't be empty");
158
- if (/\bspam\b/i.test(out)) throw new Error('the word "spam" is not allowed');
159
- return out;
188
+ /** Message moderation — a PLACEHOLDER for a real async, out-of-database check (an LLM / anti-abuse
189
+ * service). It runs OUTSIDE the write transaction (see `postMessage`), the whole reason a scoped
190
+ * mutator exists: you don't hold a DB write open across a network call. Here it just flags the "spam"
191
+ * demo; throwing rejects the post before any write (server-only, so the client's optimistic message
192
+ * snaps back). Empty/normalization is handled ISOMORPHICALLY in the shared body, so it's not here. */
193
+ async function moderateBody(body: string): Promise<void> {
194
+ if (/\bspam\b/i.test(normalizeBody(body))) throw new Error('the word "spam" is not allowed');
160
195
  }
161
196
 
162
197
  function requireUser(user: User): Identity {
@@ -82,9 +82,9 @@ export type PostMessageArgs = z.infer<typeof postMessageArgs>;
82
82
  // against the browser's wasm engine (the optimistic prediction) AND asynchronously against a live
83
83
  // transaction on the server, each op rendered to SQL. Deterministic + replayable: every value that
84
84
  // would otherwise come from the clock or a random source is passed in args (ids, timestamps) — the
85
- // client RE-INVOKES the body on every rebase. Normalization runs INSIDE the body, so both tiers
86
- // normalize identically; the server layers its own policy on top (identity required, a "spam"
87
- // rejection demo, the room-exists guard — server/app-api.ts).
85
+ // client RE-INVOKES the body on every rebase. Normalization AND the room-exists guard (a `tx.row`
86
+ // read) run INSIDE the body, so both tiers behave identically; the server layers only what it ALONE
87
+ // can do on top (identity required, an async "spam" moderation rejection demo — server/app-api.ts).
88
88
 
89
89
  export const mutators = {
90
90
  createRoom: shared(createRoomArgs, function* (tx: IsoTx, a: CreateRoomArgs): MutationGen {
@@ -95,6 +95,12 @@ export const mutators = {
95
95
  postMessage: shared(postMessageArgs, function* (tx: IsoTx, a: PostMessageArgs, ctx: MutatorCtx): MutationGen {
96
96
  const body = normalizeBody(a.body);
97
97
  if (!body) return;
98
+ // The room-exists guard, ISOMORPHIC (no raw SQL): READ the room through the mutator so a message
99
+ // never lands in a room that doesn't exist. Read-your-writes on BOTH tiers — the browser's local
100
+ // engine for the optimistic prediction, the server's interactive transaction for the authoritative
101
+ // run — so the client predicts the SAME guard the server enforces, and a post into a deleted room
102
+ // snaps back.
103
+ if (!(yield tx.row("room", { id: a.roomId }))) return;
98
104
  yield tx.insert("message", {
99
105
  id: a.id,
100
106
  roomId: a.roomId,
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { useState } from "react";
6
6
  import type { FormEvent } from "react";
7
+ import { ulid } from "ulid";
7
8
 
8
9
  import { app } from "../rindle-client.ts";
9
10
 
@@ -14,13 +15,13 @@ export function Composer({ roomId }: { roomId: string }) {
14
15
  e.preventDefault();
15
16
  const text = body.trim();
16
17
  if (!text) return;
17
- const now = Date.now();
18
- // The author is NOT an arg — it's the acting principal (ctx.user), injected by each tier.
18
+ // `ulid()` mints a k-sortable, time-ascending id at the callsite (stable across optimistic
19
+ // replays). The author is NOT an arg — it's the acting principal (ctx.user), injected per tier.
19
20
  app.mutate.postMessage({
20
- id: `msg-${now}-${Math.random().toString(36).slice(2, 7)}`,
21
+ id: ulid(),
21
22
  roomId,
22
23
  body: text,
23
- createdAt: now,
24
+ createdAt: Date.now(),
24
25
  });
25
26
  setBody("");
26
27
  }
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { useState } from "react";
6
6
  import type { FormEvent } from "react";
7
+ import { ulid } from "ulid";
7
8
 
8
9
  import { app } from "../rindle-client.ts";
9
10
 
@@ -14,8 +15,9 @@ export function NewRoomForm() {
14
15
  e.preventDefault();
15
16
  const text = name.trim();
16
17
  if (!text) return;
17
- const now = Date.now();
18
- app.mutate.createRoom({ id: `room-${now}-${Math.random().toString(36).slice(2, 7)}`, name: text, createdAt: now });
18
+ // `ulid()` mints a k-sortable, time-ascending id (48-bit ms prefix + 80 random bits) at the
19
+ // callsite, so it's stable across the mutator's optimistic replays and orders chronologically.
20
+ app.mutate.createRoom({ id: ulid(), name: text, createdAt: Date.now() });
19
21
  setName("");
20
22
  }
21
23
 
@@ -60,6 +60,11 @@ async function bootClientInner() {
60
60
  import("@rindle/wasm"),
61
61
  ]);
62
62
  await initWasm(wasmUrl);
63
+ // Every deployment exposes one stable fleet ws, including a fleet of one. `rindle exec` derives
64
+ // VITE_FLEET_WS from rindle.ncl, so increasing `followers` never changes app configuration.
65
+ // VITE_DAEMON_WS is a temporary direct-follower escape hatch for focused harnesses.
66
+ const directWs = import.meta.env.VITE_DAEMON_WS?.trim();
67
+ const fleetWs = import.meta.env.VITE_FLEET_WS?.trim() ?? "ws://127.0.0.1:7650";
63
68
  return createRindleClient({
64
69
  schema,
65
70
  mutators,
@@ -71,8 +76,8 @@ async function bootClientInner() {
71
76
  // Identity per request: the dev handle header. A real app sends a verified token instead.
72
77
  headers: (): Record<string, string> => ({ "x-rindle-user": currentHandle() }),
73
78
  },
74
- // 7601 = the daemon's PUBLIC ws port (7600 is the HTTP control plane).
75
- daemon: { wsUrl: import.meta.env.VITE_DAEMON_WS ?? "ws://127.0.0.1:7601" },
79
+ // Normal app traffic always enters through the fleet edge. Direct follower access is explicit.
80
+ daemon: directWs ? { wsUrl: directWs } : { wsUrl: fleetWs, affinity: true },
76
81
  dev: { resetOnMutationGap: import.meta.env.DEV },
77
82
  onRejected: (envelope, reason) => rejectionHandler(envelope, reason),
78
83
  });
@@ -22,9 +22,18 @@ export const Route = createRootRoute({
22
22
  { charSet: "utf-8" },
23
23
  { name: "viewport", content: "width=device-width, initial-scale=1.0" },
24
24
  { title: "__PROJECT_NAME__" },
25
- { name: "theme-color", content: "#0f1115" },
25
+ { name: "theme-color", content: "#f2eee7" },
26
+ ],
27
+ links: [
28
+ // Hanken Grotesk (UI) + DM Mono (counts, timestamps, labels). Swap for a self-hosted font to drop the CDN.
29
+ { rel: "preconnect", href: "https://fonts.googleapis.com" },
30
+ { rel: "preconnect", href: "https://fonts.gstatic.com", crossOrigin: "anonymous" },
31
+ {
32
+ rel: "stylesheet",
33
+ href: "https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@300;400;500;600;700;800&family=DM+Mono:ital,wght@0,300;0,400;0,500;1,400&display=swap",
34
+ },
35
+ { rel: "stylesheet", href: appCss },
26
36
  ],
27
- links: [{ rel: "stylesheet", href: appCss }],
28
37
  }),
29
38
  shellComponent: RootDocument,
30
39
  });
@@ -1,13 +1,34 @@
1
+ /* Starter styles — matched to the Rindle site (rindle.sh): a warm "paper" canvas with a fine
2
+ dot-grid, near-black ink, an orange accent, Hanken Grotesk for UI, and DM Mono for the technical
3
+ bits (live counts, timestamps, kicker labels). Rebrand by editing the tokens in :root. */
4
+
1
5
  :root {
2
- --bg: #0f1115;
3
- --panel: #171a21;
4
- --panel-2: #1e222b;
5
- --border: #2a2f3a;
6
- --text: #e7e9ee;
7
- --muted: #9aa3b2;
8
- --accent: #6ea8fe;
9
- --danger: #ff7b72;
10
- color-scheme: dark;
6
+ /* surfaces */
7
+ --paper: #f2eee7;
8
+ --panel: #fcfaf5;
9
+ --white: #ffffff;
10
+
11
+ /* ink — text, strongest → faintest */
12
+ --ink: #171411;
13
+ --ink-d: #5f564b;
14
+ --ink-f: #746a5d;
15
+
16
+ /* hairlines + dot-grid */
17
+ --line: #d8cebc;
18
+ --line2: #c7baa3;
19
+ --dot: #d8d0bd;
20
+
21
+ /* accent */
22
+ --accent: #ff4d00;
23
+ --teal: #04756b;
24
+
25
+ --radius: 14px;
26
+
27
+ --font-sans: "Hanken Grotesk", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
28
+ --font-mono: "DM Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
29
+
30
+ color-scheme: light;
31
+ accent-color: var(--accent);
11
32
  }
12
33
 
13
34
  * {
@@ -16,10 +37,13 @@
16
37
 
17
38
  body {
18
39
  margin: 0;
19
- background: var(--bg);
20
- color: var(--text);
21
- font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
22
- line-height: 1.5;
40
+ color: var(--ink);
41
+ font-family: var(--font-sans);
42
+ line-height: 1.55;
43
+ background-color: var(--paper);
44
+ background-image: radial-gradient(var(--dot) 0.9px, transparent 0.9px);
45
+ background-size: 26px 26px;
46
+ -webkit-font-smoothing: antialiased;
23
47
  }
24
48
 
25
49
  a {
@@ -27,102 +51,163 @@ a {
27
51
  text-decoration: none;
28
52
  }
29
53
 
54
+ /* ---- top bar ---- */
30
55
  .app-topbar {
31
56
  display: flex;
32
57
  align-items: center;
33
58
  justify-content: space-between;
34
- padding: 0.85rem 1.25rem;
35
- border-bottom: 1px solid var(--border);
59
+ gap: 1rem;
60
+ flex-wrap: wrap;
61
+ padding: 0.9rem 1.5rem;
62
+ border-bottom: 1px solid var(--line);
36
63
  background: var(--panel);
37
64
  }
38
-
39
65
  .app-wordmark {
66
+ display: inline-flex;
67
+ align-items: baseline;
68
+ gap: 0.5rem;
40
69
  font-weight: 800;
70
+ font-size: 1.2rem;
41
71
  letter-spacing: -0.02em;
42
- font-size: 1.15rem;
43
72
  }
44
73
  .app-wordmark span {
45
- color: var(--accent);
74
+ font-family: var(--font-mono);
75
+ font-size: 0.6rem;
76
+ font-weight: 500;
77
+ text-transform: uppercase;
78
+ letter-spacing: 0.14em;
79
+ color: var(--ink-d);
80
+ border: 1px solid var(--line2);
81
+ border-radius: 999px;
82
+ padding: 0.15em 0.6em;
46
83
  }
47
-
48
84
  .app-topbar-right {
49
85
  display: flex;
50
86
  align-items: center;
51
- gap: 0.75rem;
87
+ gap: 0.85rem;
52
88
  }
53
89
  .app-whoami {
54
- color: var(--muted);
55
- font-size: 0.9rem;
90
+ font-size: 0.85rem;
91
+ color: var(--ink-d);
92
+ }
93
+ .app-whoami b {
94
+ color: var(--ink);
95
+ font-weight: 600;
56
96
  }
57
97
 
98
+ /* ---- layout ---- */
58
99
  .app-main {
59
- max-width: 720px;
100
+ max-width: 760px;
60
101
  margin: 0 auto;
61
- padding: 1.5rem 1.25rem 4rem;
102
+ padding: 2.75rem 1.5rem 5rem;
103
+ }
104
+ .app-page-head {
105
+ margin-bottom: 1.5rem;
62
106
  }
63
-
64
107
  .app-eyebrow {
108
+ margin: 0 0 0.7rem;
109
+ font-family: var(--font-mono);
110
+ font-size: 0.7rem;
65
111
  text-transform: uppercase;
66
- letter-spacing: 0.08em;
67
- font-size: 0.72rem;
68
- color: var(--muted);
69
- margin: 0 0 0.15rem;
112
+ letter-spacing: 0.18em;
113
+ color: var(--accent);
70
114
  }
71
115
  .app-page-head h1 {
72
- margin: 0 0 1rem;
73
- font-size: 1.6rem;
116
+ margin: 0;
117
+ font-weight: 800;
118
+ letter-spacing: -0.025em;
119
+ line-height: 1.05;
120
+ font-size: clamp(1.8rem, 5vw, 2.6rem);
121
+ overflow-wrap: break-word;
74
122
  }
75
123
 
76
124
  .app-empty {
77
- color: var(--muted);
125
+ color: var(--ink-d);
78
126
  }
79
127
  .app-link {
80
128
  color: var(--accent);
129
+ font-weight: 600;
81
130
  }
82
131
  .app-breadcrumb {
83
- color: var(--muted);
84
- font-size: 0.9rem;
85
- margin-bottom: 0.5rem;
132
+ margin-bottom: 0.85rem;
133
+ font-family: var(--font-mono);
134
+ font-size: 0.75rem;
135
+ text-transform: uppercase;
136
+ letter-spacing: 0.08em;
137
+ color: var(--ink-f);
86
138
  }
87
139
  .app-breadcrumb a {
140
+ color: var(--ink-d);
141
+ }
142
+ .app-breadcrumb a:hover {
88
143
  color: var(--accent);
89
144
  }
90
145
 
146
+ /* ---- buttons ---- */
91
147
  .app-btn {
148
+ font-family: var(--font-mono);
149
+ font-size: 0.8rem;
150
+ font-weight: 500;
151
+ text-transform: uppercase;
152
+ letter-spacing: 0.06em;
153
+ color: #1a0f06;
92
154
  background: var(--accent);
93
- color: #0b1020;
94
- border: none;
95
- border-radius: 8px;
96
- padding: 0.5rem 0.9rem;
97
- font-weight: 600;
155
+ border: 0;
156
+ border-radius: 10px;
157
+ padding: 0.7rem 1.1rem;
98
158
  cursor: pointer;
159
+ transition: box-shadow 0.15s ease, transform 0.06s ease;
160
+ }
161
+ .app-btn:hover:not(:disabled) {
162
+ box-shadow: 0 12px 30px -14px color-mix(in srgb, var(--accent) 70%, transparent);
163
+ }
164
+ .app-btn:active:not(:disabled) {
165
+ transform: translateY(1px);
99
166
  }
100
167
  .app-btn:disabled {
101
- opacity: 0.5;
168
+ opacity: 0.45;
102
169
  cursor: not-allowed;
103
170
  }
104
171
  .app-btn-ghost {
172
+ color: var(--ink);
105
173
  background: transparent;
106
- color: var(--text);
107
- border: 1px solid var(--border);
174
+ border: 1px solid var(--line2);
175
+ }
176
+ .app-btn-ghost:hover:not(:disabled) {
177
+ background: var(--white);
178
+ border-color: var(--ink);
179
+ box-shadow: none;
108
180
  }
109
181
 
182
+ /* ---- inputs ---- */
110
183
  input,
111
184
  textarea {
112
185
  width: 100%;
113
- background: var(--panel-2);
114
- border: 1px solid var(--border);
115
- border-radius: 8px;
116
- color: var(--text);
117
- padding: 0.55rem 0.7rem;
118
186
  font: inherit;
187
+ font-family: var(--font-sans);
188
+ color: var(--ink);
189
+ background: var(--white);
190
+ border: 1px solid var(--line);
191
+ border-radius: 10px;
192
+ padding: 0.6rem 0.8rem;
119
193
  resize: vertical;
120
194
  }
195
+ input::placeholder,
196
+ textarea::placeholder {
197
+ color: var(--ink-f);
198
+ }
199
+ input:focus,
200
+ textarea:focus {
201
+ outline: none;
202
+ border-color: var(--ink);
203
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--teal) 22%, transparent);
204
+ }
121
205
 
206
+ /* ---- new room ---- */
122
207
  .app-new-room {
123
208
  display: flex;
124
- gap: 0.5rem;
125
- margin-bottom: 1.25rem;
209
+ gap: 0.6rem;
210
+ margin-bottom: 1.75rem;
126
211
  }
127
212
  .app-new-room input {
128
213
  flex: 1;
@@ -131,90 +216,128 @@ textarea {
131
216
  white-space: nowrap;
132
217
  }
133
218
 
219
+ /* ---- rooms ---- */
134
220
  .app-rooms {
135
221
  list-style: none;
136
222
  margin: 0;
137
223
  padding: 0;
138
224
  display: grid;
139
- gap: 0.6rem;
225
+ gap: 0.7rem;
140
226
  }
141
227
  .app-room-link {
142
228
  display: flex;
143
229
  align-items: center;
144
230
  justify-content: space-between;
145
- padding: 0.9rem 1rem;
231
+ gap: 1rem;
232
+ padding: 1rem 1.15rem;
146
233
  background: var(--panel);
147
- border: 1px solid var(--border);
148
- border-radius: 12px;
234
+ border: 1px solid var(--line);
235
+ border-radius: var(--radius);
236
+ transition: border-color 0.15s ease, box-shadow 0.15s ease, transform 0.1s ease;
149
237
  }
150
238
  .app-room-link:hover {
151
- border-color: var(--accent);
239
+ border-color: var(--line2);
240
+ box-shadow: 0 14px 34px -22px rgba(40, 30, 10, 0.45);
241
+ transform: translateY(-1px);
152
242
  }
153
243
  .app-room-name {
154
- font-weight: 600;
244
+ font-weight: 700;
245
+ font-size: 1.05rem;
246
+ letter-spacing: -0.01em;
247
+ overflow-wrap: anywhere;
155
248
  }
156
249
  .app-count {
157
250
  display: inline-flex;
158
251
  flex-direction: column;
159
- align-items: flex-end;
160
- font-weight: 700;
252
+ align-items: center;
253
+ min-width: 3rem;
254
+ padding: 0.3rem 0.6rem;
255
+ border: 1px solid var(--line2);
256
+ border-radius: 9px;
257
+ background: var(--white);
258
+ font-family: var(--font-mono);
161
259
  font-variant-numeric: tabular-nums;
260
+ color: var(--accent);
261
+ font-size: 1.05rem;
262
+ font-weight: 500;
263
+ line-height: 1.15;
162
264
  }
163
265
  .app-count small {
164
- font-weight: 400;
165
- font-size: 0.7rem;
166
- color: var(--muted);
266
+ color: var(--ink-f);
267
+ font-size: 0.58rem;
268
+ text-transform: uppercase;
269
+ letter-spacing: 0.08em;
167
270
  }
168
271
 
272
+ /* ---- messages ---- */
169
273
  .app-messages {
170
274
  display: grid;
171
- gap: 0.6rem;
172
- margin: 1rem 0;
275
+ gap: 0.65rem;
276
+ margin: 1.5rem 0;
173
277
  }
174
278
  .app-message {
175
279
  background: var(--panel);
176
- border: 1px solid var(--border);
280
+ border: 1px solid var(--line);
177
281
  border-radius: 12px;
178
- padding: 0.75rem 0.9rem;
282
+ padding: 0.85rem 1rem;
179
283
  }
180
284
  .app-message-head {
181
285
  display: flex;
182
286
  justify-content: space-between;
183
- font-size: 0.8rem;
184
- color: var(--muted);
185
- margin-bottom: 0.25rem;
287
+ gap: 1rem;
288
+ margin-bottom: 0.3rem;
186
289
  }
187
290
  .app-message-author {
188
- font-weight: 600;
189
- color: var(--text);
291
+ font-weight: 700;
292
+ font-size: 0.92rem;
293
+ color: var(--ink);
294
+ }
295
+ .app-message-time {
296
+ font-family: var(--font-mono);
297
+ font-size: 0.72rem;
298
+ color: var(--ink-f);
190
299
  }
191
300
  .app-message-body {
192
301
  margin: 0;
193
302
  white-space: pre-wrap;
194
303
  }
195
304
 
305
+ /* ---- composer ---- */
196
306
  .app-composer textarea {
197
- margin-bottom: 0.5rem;
307
+ margin-bottom: 0.6rem;
198
308
  }
199
309
  .app-composer-actions {
200
310
  display: flex;
201
311
  justify-content: flex-end;
202
312
  }
203
313
 
314
+ /* ---- toaster ---- */
204
315
  .app-toaster {
205
316
  position: fixed;
206
- bottom: 1rem;
207
- right: 1rem;
317
+ bottom: 1.25rem;
318
+ right: 1.25rem;
208
319
  display: grid;
209
- gap: 0.5rem;
320
+ gap: 0.6rem;
210
321
  z-index: 50;
211
322
  }
212
323
  .app-toast {
213
- background: var(--danger);
214
- color: #1a0c0a;
215
- padding: 0.6rem 0.9rem;
324
+ background: var(--ink);
325
+ color: var(--paper);
326
+ border-left: 4px solid var(--accent);
327
+ padding: 0.7rem 0.95rem 0.7rem 0.85rem;
216
328
  border-radius: 10px;
217
- font-weight: 600;
329
+ font-size: 0.85rem;
330
+ font-weight: 500;
218
331
  max-width: 320px;
219
- box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
332
+ box-shadow: 0 18px 40px -20px rgba(20, 15, 8, 0.6);
333
+ }
334
+
335
+ @media (max-width: 560px) {
336
+ .app-topbar {
337
+ flex-direction: column;
338
+ align-items: flex-start;
339
+ }
340
+ .app-new-room {
341
+ flex-direction: column;
342
+ }
220
343
  }
@@ -6,8 +6,10 @@ declare module "rindle-wasm-bin?url" {
6
6
  }
7
7
 
8
8
  interface ImportMetaEnv {
9
- /** The daemon's PUBLIC subscription ws (server/dev.ts injects it). */
9
+ /** Deprecated direct-follower test/debug escape hatch. Normal apps use VITE_FLEET_WS. */
10
10
  readonly VITE_DAEMON_WS?: string;
11
+ /** Stable fleet-edge PUBLIC ws, derived by `rindle exec` for one or many followers. */
12
+ readonly VITE_FLEET_WS?: string;
11
13
  }
12
14
 
13
15
  interface ImportMeta {
@@ -4,6 +4,9 @@ import { dirname, resolve } from "node:path";
4
4
  import { tanstackStart } from "@tanstack/react-start/plugin/vite";
5
5
  import react from "@vitejs/plugin-react";
6
6
  import { defineConfig } from "vite";
7
+ // Opt-in: TanStack's devtools panel (router/store inspectors). Uncomment the plugin below to enable it
8
+ // — but KEEP `consolePiping: { enabled: false }` (see the plugins array). Needs `@tanstack/devtools-vite`.
9
+ // import { devtools } from "@tanstack/devtools-vite";
7
10
 
8
11
  // The in-process wasm IVM engine ships prebuilt inside the @rindle/wasm package (pkg/rindle_bg.wasm).
9
12
  // Alias `rindle-wasm-bin?url` to that file so the client can `import url from "rindle-wasm-bin?url"`
@@ -23,7 +26,15 @@ export default defineConfig({
23
26
  // Keep the @rindle/* graph bundled through the SSR/prerender pass (the wasm engine itself is a
24
27
  // client-only dynamic import in src/rindle-client.ts, so it never loads in the shell pass).
25
28
  ssr: { noExternal: [/^@rindle\//] },
26
- // No `server.proxy`: /api/rindle/* is now a Start server route (src/routes/api/rindle/$.ts) served
27
- // by this same dev server. The daemon ws is still connected to directly (ws://127.0.0.1:7601).
28
- plugins: [tanstackStart(), react()],
29
+ // No `server.proxy`: /api/rindle/* are Start server routes (src/routes/api.rindle.*.tsx) served by
30
+ // this same dev server. Browser subscriptions enter through the fleet edge derived by `rindle exec`.
31
+ plugins: [
32
+ // If you add TanStack's devtools panel, DISABLE console piping. The piping cross-forwards console
33
+ // between the SSR server and the browser; a repeated log (a React warning, a Rindle fetch-retry)
34
+ // echoes back and forth and snowballs into a multi-GB HMR-websocket payload that OOM-kills
35
+ // `vite dev`. Disabling it keeps the panel + source injection, just not the console bridge.
36
+ // devtools({ consolePiping: { enabled: false } }),
37
+ tanstackStart(),
38
+ react(),
39
+ ],
29
40
  });
@@ -1,148 +0,0 @@
1
- // One-command dev: boots the daemon (the Rust `rindled`, supervised by `rindle up`) and the Vite dev
2
- // server — which also serves the app's API as a Start server route — and tears them down together.
3
- //
4
- // pnpm dev # boots everything: daemon + migrations + schema gen + seed + Vite (+ API route)
5
- //
6
- // The DATABASE tier is one command: `rindle up --migrate --gen --watch` supervises `rindled` (respawn
7
- // on exit / migrate-at-bounce), applies the migrations/ DDL once it's ready, generates the
8
- // @rindle/client schema TS from the introspected result (shared/schema.gen.ts), and re-runs both on
9
- // every *.sql change. The schema is SQL-first: migrations/*.sql is the source of truth and
10
- // shared/schema.gen.ts is generated from it. Seeding is app code, so it stays here: once the schema is
11
- // live we run the idempotent seed. The rest is the web tier (Vite), which also serves the API route.
12
- //
13
- // The `rindle` + `rindled` binaries come from @rindle/cli (prebuilt, per-platform) — no Rust toolchain
14
- // needed. Working inside the Rindle monorepo? Set RINDLE_BIN_DIR=target/release (after a cargo build)
15
- // to use locally built binaries instead.
16
-
17
- import { spawn, type ChildProcess } from "node:child_process";
18
- import { mkdirSync, writeFileSync } from "node:fs";
19
- import { join, resolve } from "node:path";
20
- import { fileURLToPath } from "node:url";
21
-
22
- import { rindleBinaryPath, rindledBinaryPath } from "@rindle/cli";
23
-
24
- const here = fileURLToPath(new URL(".", import.meta.url));
25
- const appRoot = resolve(here, "..");
26
-
27
- const DAEMON_TOKEN = "dev-daemon-token";
28
- const DAEMON_URL = "http://127.0.0.1:7600"; // the daemon's control plane (httpPort below)
29
- const WS_URL = "ws://127.0.0.1:7601"; // rindled serves subscriptions on their own port
30
-
31
- const children: ChildProcess[] = []; // app tiers (seed) — share our process group
32
- let supervisor: ChildProcess | undefined; // `rindle up` — its own group; it owns the rindled child
33
- let shuttingDown = false;
34
- const stop = () => {
35
- shuttingDown = true;
36
- // `rindle up` runs detached in its own process group and owns `rindled`, so group-kill it to take
37
- // both down; the app tiers share our group and are killed individually.
38
- if (supervisor?.pid) {
39
- try {
40
- process.kill(-supervisor.pid, "SIGKILL");
41
- } catch {
42
- /* already gone */
43
- }
44
- }
45
- for (const child of children) child.kill("SIGKILL");
46
- process.exit(0);
47
- };
48
- process.on("SIGINT", stop);
49
- process.on("SIGTERM", stop);
50
-
51
- function node(script: string, env: NodeJS.ProcessEnv = {}): ChildProcess {
52
- const child = spawn(process.execPath, [join(appRoot, script)], {
53
- stdio: "inherit",
54
- env: { ...process.env, RINDLE_DAEMON_TOKEN: DAEMON_TOKEN, ...env },
55
- });
56
- children.push(child);
57
- return child;
58
- }
59
-
60
- // Poll the control plane until `rindle up --migrate` has applied the schema (tables appear) and the
61
- // daemon is serving — a real readiness gate that also rides through the migrate-at-bounce restart.
62
- async function waitForSchema(timeoutMs = 60_000): Promise<boolean> {
63
- const deadline = Date.now() + timeoutMs;
64
- while (Date.now() < deadline) {
65
- try {
66
- const res = await fetch(`${DAEMON_URL}/schema`, { headers: { authorization: `Bearer ${DAEMON_TOKEN}` } });
67
- if (res.ok) {
68
- const body = (await res.json()) as { tables?: unknown[] };
69
- if (Array.isArray(body.tables) && body.tables.length > 0) return true;
70
- }
71
- } catch {
72
- /* not up yet, or mid-bounce — retry */
73
- }
74
- await new Promise((r) => setTimeout(r, 300));
75
- }
76
- return false;
77
- }
78
-
79
- // Resolve the prebuilt binaries (from @rindle/cli, or RINDLE_BIN_DIR in the monorepo). They must be
80
- // co-located so `rindle up` finds `rindled` as a sibling — @rindle/cli ships them that way.
81
- const rindleBin = rindleBinaryPath();
82
- const rindledBin = rindledBinaryPath();
83
-
84
- // The daemon config `rindle up` runs against: loopback, the dev token, no `tables` (the schema is
85
- // applied from migrations/ on demand and introspected — never created at boot).
86
- const dataDir = join(appRoot, ".rindled");
87
- mkdirSync(dataDir, { recursive: true });
88
- const daemonJson = join(dataDir, "daemon.json");
89
- writeFileSync(
90
- daemonJson,
91
- JSON.stringify({
92
- db: join(dataDir, "app.db"),
93
- httpPort: 7600,
94
- wsPort: 7601,
95
- authToken: DAEMON_TOKEN,
96
- nWorkers: 4,
97
- tables: [],
98
- }),
99
- );
100
-
101
- // The database tier, one command: supervise `rindled`, apply migrations/ on boot, regenerate the
102
- // client schema (`--gen` → shared/schema.gen.ts), and re-run both on every *.sql change (`--watch`).
103
- // Detached into its own process group so `stop()` can group-kill it together with the `rindled` child.
104
- const migrationsDir = join(appRoot, "migrations");
105
- const schemaGen = join(appRoot, "shared/schema.gen.ts"); // SQL-first: generated from migrations/*.sql
106
- supervisor = spawn(
107
- rindleBin,
108
- ["up", "--config", daemonJson, "--daemon-bin", rindledBin, "--migrate", "--gen", schemaGen, "--watch", "--dir", migrationsDir],
109
- {
110
- stdio: ["ignore", "inherit", "inherit"],
111
- detached: true,
112
- env: { ...process.env, RINDLE_DAEMON_TOKEN: DAEMON_TOKEN, RINDLE_DAEMON_URL: DAEMON_URL },
113
- },
114
- );
115
- supervisor.on("exit", (code, signal) => {
116
- if (shuttingDown) return;
117
- console.error(`[dev] rindle up exited (code ${code}, signal ${signal}) — shutting down`);
118
- stop();
119
- });
120
-
121
- // Wait until the schema is live (migrations applied, daemon re-introspected), then the API tier and
122
- // the idempotent seed (a fresh DB shows an empty app until the seed lands; re-running is a no-op).
123
- if (!(await waitForSchema())) {
124
- console.error("[dev] daemon did not come up with a schema in time — check migrations/");
125
- if (supervisor.pid) {
126
- try {
127
- process.kill(-supervisor.pid, "SIGKILL");
128
- } catch {
129
- /* already gone */
130
- }
131
- }
132
- process.exit(1);
133
- }
134
- node("server/seed.ts", { RINDLE_DAEMON_URL: DAEMON_URL });
135
-
136
- // The web tier: Vite programmatically. The app's API tier is a set of Start SERVER ROUTES
137
- // (src/routes/api.rindle.*.tsx) served by this same dev server — no proxy, no standalone API process.
138
- // Those routes AND the SSR loader read the daemon IN-PROCESS (in this Vite process), so point this
139
- // process's own env at the daemon control plane (URL + token); the client bundle gets the PUBLIC ws url.
140
- process.env.RINDLE_DAEMON_URL = DAEMON_URL;
141
- process.env.RINDLE_DAEMON_TOKEN = DAEMON_TOKEN;
142
- process.env.VITE_DAEMON_WS = WS_URL;
143
- const { createServer } = await import("vite");
144
- const vite = await createServer({ root: appRoot });
145
- await vite.listen();
146
- vite.printUrls();
147
- console.log("[dev] migrations + shared/schema.gen.ts auto-apply/regenerate on boot + on every migrations/ change.");
148
- console.log("[dev] open two browser windows to watch writes sync live.");
@@ -1,47 +0,0 @@
1
- // `pnpm migrate` — apply the schema migrations via the `rindle` CLI, then seed. The CLI is the real
2
- // operator tool (the prebuilt binary shipped by @rindle/cli); migrations are applied by
3
- // `rindle migrate apply`, NOT a TS library. Idempotent throughout: the daemon dedups migrations by
4
- // `id`, and the seed carries a durable idempotency key — re-running is a no-op.
5
- //
6
- // pnpm migrate # apply (daemon self-restarts to re-introspect) → seed
7
- // node server/migrate.ts --deploy # CI/prod: apply only (caller seeds separately)
8
-
9
- import { spawnSync } from "node:child_process";
10
- import { join, resolve } from "node:path";
11
- import { fileURLToPath } from "node:url";
12
-
13
- import { rindleBinaryPath } from "@rindle/cli";
14
-
15
- import { seed } from "./seed.ts";
16
-
17
- const appRoot = resolve(fileURLToPath(new URL(".", import.meta.url)), "..");
18
- const migrationsDir = join(appRoot, "migrations");
19
-
20
- const DAEMON_URL = process.env.RINDLE_DAEMON_URL ?? "http://127.0.0.1:7600";
21
- const DAEMON_TOKEN = process.env.RINDLE_DAEMON_TOKEN ?? "dev-daemon-token";
22
- const deploy = process.argv.includes("--deploy");
23
-
24
- function run(cmd: string, args: string[]): void {
25
- const r = spawnSync(cmd, args, { stdio: "inherit" });
26
- if (r.error) throw r.error;
27
- if (r.status !== 0) throw new Error(`\`${cmd} ${args.join(" ")}\` exited with ${r.status ?? r.signal}`);
28
- }
29
-
30
- async function main(): Promise<void> {
31
- const rindle = rindleBinaryPath();
32
- // Apply pending *.sql through the daemon's /migrate. Idempotent (dedup by id), additive-DDL-linted,
33
- // and it waits out a dev daemon's migrate-at-bounce self-restart.
34
- run(rindle, ["migrate", "apply", "--dir", migrationsDir, "--url", DAEMON_URL, "--token", DAEMON_TOKEN]);
35
-
36
- if (deploy) {
37
- console.log("[migrate] --deploy: applied; the caller seeds next.");
38
- return;
39
- }
40
- console.log("[migrate] seeding…");
41
- await seed({ url: DAEMON_URL, token: DAEMON_TOKEN });
42
- }
43
-
44
- main().catch((err) => {
45
- console.error(`[migrate] ${err instanceof Error ? err.message : String(err)}`);
46
- process.exit(1);
47
- });
@@ -1,70 +0,0 @@
1
- // Seed the daemon with a little starter data — a couple of rooms and some messages — pushed through
2
- // the private control plane as ONE foreign SqlTxn carrying an idempotency key: the file-backed daemon
3
- // (`rindled`) won't re-seed across restarts (the key is durable). Retries until the daemon answers, so
4
- // dev.ts can fire-and-forget it during startup.
5
-
6
- import { HttpRindleDaemonClient } from "@rindle/daemon-client";
7
- import type { SqlStatement, WireValue } from "@rindle/daemon-client";
8
-
9
- const DAEMON_URL = process.env.RINDLE_DAEMON_URL ?? "http://127.0.0.1:7600";
10
- const DAEMON_TOKEN = process.env.RINDLE_DAEMON_TOKEN ?? "dev-daemon-token";
11
-
12
- const ROOMS = [
13
- { id: "room-welcome", name: "Welcome" },
14
- { id: "room-random", name: "Random" },
15
- ];
16
-
17
- const MESSAGES = [
18
- { roomId: "room-welcome", author: "ada", body: "Welcome! This whole board is a Rindle app — the room counts are live incremental views." },
19
- { roomId: "room-welcome", author: "grace", body: "Post a message and watch the count on the home page update with no polling." },
20
- { roomId: "room-random", author: "alan", body: "Open two browser windows side by side to see writes sync." },
21
- ];
22
-
23
- const at = (n: number): number => 1717000000000 + n * 60_000; // deterministic timestamps
24
-
25
- function valuesClause(rowCount: number, colCount: number): string {
26
- const tuple = `(${Array(colCount).fill("?").join(", ")})`;
27
- return Array(rowCount).fill(tuple).join(", ");
28
- }
29
-
30
- export function seedStatements(): SqlStatement[] {
31
- const statements: SqlStatement[] = [];
32
-
33
- statements.push({
34
- sql: `INSERT INTO room (id, name, createdAt) VALUES ${valuesClause(ROOMS.length, 3)}`,
35
- params: ROOMS.flatMap((r, i) => [r.id, r.name, at(i)]) as WireValue[],
36
- });
37
-
38
- statements.push({
39
- sql: `INSERT INTO message (id, roomId, author, body, createdAt) VALUES ${valuesClause(MESSAGES.length, 5)}`,
40
- params: MESSAGES.flatMap((m, i) => [`msg-${i}`, m.roomId, m.author, m.body, at(i + ROOMS.length)]) as WireValue[],
41
- });
42
-
43
- return statements;
44
- }
45
-
46
- export interface SeedOptions {
47
- url?: string;
48
- token?: string;
49
- }
50
-
51
- export async function seed({ url = DAEMON_URL, token = DAEMON_TOKEN }: SeedOptions = {}): Promise<void> {
52
- const daemon = new HttpRindleDaemonClient({ baseUrl: url, headers: { authorization: `Bearer ${token}` } });
53
- for (let attempt = 1; ; attempt++) {
54
- try {
55
- const out = await daemon.executeSqlTxn({ idempotencyKey: "seed-v1", statements: seedStatements() });
56
- console.log(
57
- out.applied
58
- ? `[seed] seeded ${ROOMS.length} rooms + ${MESSAGES.length} messages (cv ${out.cv})`
59
- : `[seed] already seeded (idempotency key absorbed the replay)`,
60
- );
61
- return;
62
- } catch (err) {
63
- if (attempt >= 20) throw err;
64
- await new Promise((r) => setTimeout(r, 500)); // daemon still booting — retry
65
- }
66
- }
67
- }
68
-
69
- // Allow `node server/seed.ts` standalone.
70
- if (import.meta.url === `file://${process.argv[1]}`) await seed();