create-rindle 0.4.3 → 0.5.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/README.md CHANGED
@@ -17,6 +17,11 @@ cd my-app
17
17
  pnpm dev
18
18
  ```
19
19
 
20
+ Full docs — what the template contains and how to grow it:
21
+ **[rindle.sh/docs/create-rindle](https://rindle.sh/docs/create-rindle)** · markdown
22
+ mirror: [`create-rindle.md`](https://rindle.sh/docs/create-rindle.md) · for agents:
23
+ [llms.txt](https://rindle.sh/llms.txt)
24
+
20
25
  ## What you get
21
26
 
22
27
  A minimal but real three-tier Rindle app (a tiny forum-of-rooms with **live message counts**):
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 daemon + 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.3",
3
+ "version": "0.5.0",
4
4
  "license": "Apache-2.0",
5
5
  "repository": {
6
6
  "type": "git",
@@ -13,12 +13,18 @@ Rindle docs are served as raw markdown for LLMs: index at
13
13
 
14
14
  ## Commands
15
15
 
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.
16
+ - `pnpm dev` — the one command: `concurrently` runs two processes — **daemon**
17
+ (`rindle up` supervising `rindled`, a prebuilt binary from `@rindle/cli`, no
18
+ Rust toolchain) applies `migrations/*.sql` and regenerates
19
+ `shared/schema.gen.ts` (re-running both on every `migrations/` change), and
20
+ **web** (`vite dev` on :3000) serves the app + the `/api/rindle/*` server
21
+ routes. Run either alone with `pnpm daemon` / `pnpm dev:web`.
20
22
  - `pnpm typecheck` — regenerates the route tree, then `tsc --noEmit`.
21
- - `pnpm migrate` / `pnpm seed` — one-shot apply / seed against the running daemon.
23
+ - `pnpm migrate` — one-shot `rindle migrate apply` against the running daemon
24
+ (the dev loop already applies on boot + on every `migrations/` change).
25
+ - `pnpm rindle:deploy` / `pnpm rindle:migrate:remote` — deploy the daemon tier to
26
+ Rindle Cloud (reads `topology.ncl`; run `rindle login` once first) and push
27
+ `migrations/*.sql` to it. Local dev never reads `topology.ncl`.
22
28
 
23
29
  ## Rules that keep the app correct
24
30
 
@@ -69,7 +75,8 @@ review:
69
75
  | `src/rindle-client.ts` | the one-call browser wire-up (`createRindleClient`) |
70
76
  | `server/app-api.ts` | the authority: `registerQueries` + `sharedApiMutators` + server-only policy |
71
77
  | `src/routes/api.rindle.*.tsx` | TanStack Start server routes exposing the authority over HTTP |
72
- | `server/dev.ts` | dev orchestration (`rindle up` + seed + Vite) |
78
+ | `daemon.json` | local `rindled` config `rindle up` reads (loopback, no auth token in dev) |
79
+ | `topology.ncl` | Rindle Cloud deploy topology (`rindle deploy`) — not used by local dev |
73
80
  | `src/ssr.ts` | SSR preload of the same named queries for first paint |
74
81
 
75
82
  ## Reading more
@@ -35,16 +35,42 @@ pnpm install
35
35
  pnpm dev
36
36
  ```
37
37
 
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.
38
+ `pnpm dev` runs two processes with [`concurrently`](https://www.npmjs.com/package/concurrently)
39
+ the standard TanStack Start dev shape:
44
40
 
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.
41
+ - **`daemon`** `rindle up` supervises `rindled` (prebuilt binaries from `@rindle/cli`, no Rust
42
+ toolchain needed) against the committed `daemon.json`, applies the migrations, and regenerates
43
+ `shared/schema.gen.ts` re-running both on every `migrations/*.sql` change (`--watch`).
44
+ - **`web`** — `vite dev` on <http://localhost:3000>, which serves both the app and the
45
+ `/api/rindle/*` Start server routes.
46
+
47
+ You can run either alone: `pnpm daemon` / `pnpm dev:web`. The local daemon binds to loopback and runs
48
+ without an auth token; a real deployment sets one (`authToken` in the daemon config + a verified
49
+ `RINDLE_DAEMON_TOKEN` on the server), which never reaches the browser.
50
+
51
+ Open two browser windows to watch writes sync live: create a room, post a message, and watch the
52
+ room's count update on the home page with no polling. Try a room name or message containing "spam" to
53
+ see the rejection path (the optimistic write snaps back + a toast).
54
+
55
+ > Requires Node ≥ 22.18. The TanStack route tree (`src/routeTree.gen.ts`) is a generated artifact —
56
+ > `pnpm dev` and `pnpm generate-routes` produce it; `pnpm typecheck` runs `tsr generate` first.
57
+
58
+ ## Deploy
59
+
60
+ Local dev runs the daemon on your machine (`daemon.json`). To run the **data tier** on Rindle Cloud,
61
+ `topology.ncl` describes the managed app:
62
+
63
+ ```bash
64
+ rindle login # once — authenticate to Rindle Cloud
65
+ pnpm rindle:deploy # provision / re-attach the managed rindled (writes .rindle/cloud.json)
66
+ pnpm rindle:migrate:remote # push migrations/*.sql to the deployed daemon
67
+ ```
68
+
69
+ `rindle deploy` reads `topology.ncl` and records the binding in `.rindle/cloud.json` — commit it, so
70
+ future deploys re-attach to the same app. That deploys the **daemon** only; the **web app** (this
71
+ Vite/TanStack Start app) deploys to any Node host — point its `RINDLE_DAEMON_URL` +
72
+ `RINDLE_DAEMON_TOKEN` (server) and `VITE_DAEMON_WS` (browser) at the deployed daemon. Local dev is
73
+ unaffected: bare `rindle up` ignores `topology.ncl` and uses `daemon.json`.
48
74
 
49
75
  ## Devtools
50
76
 
@@ -63,7 +89,8 @@ of `vite build` and never ship to production.
63
89
  | `shared/schema.gen.ts` | generated `@rindle/client` schema (don't edit by hand) |
64
90
  | `shared/app-def.ts` | the contract root: schema re-export, relationships, isomorphic mutators |
65
91
  | `shared/auth.ts` | the identity seam (`AuthProvider`) |
66
- | `server/dev.ts` | one-command dev orchestrator |
92
+ | `daemon.json` | the local `rindled` config (`rindle up` reads it) — loopback, no auth token in dev |
93
+ | `topology.ncl` | Rindle Cloud deploy topology (`rindle deploy`) — **not** used by local dev |
67
94
  | `server/app-api.ts` | the authority: query resolution, `sharedApiMutators`, policy (host-agnostic) |
68
95
  | `server/rindle-http.ts` | adapts the authority to a Web Request (the Start API routes call it) |
69
96
  | `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
@@ -0,0 +1,8 @@
1
+ {
2
+ "bindHost": "127.0.0.1",
3
+ "db": "rindle.db",
4
+ "httpPort": 7600,
5
+ "wsPort": 7601,
6
+ "nWorkers": 4,
7
+ "tables": []
8
+ }
@@ -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
+ "daemon": "rindle up --config daemon.json --migrate --gen shared/schema.gen.ts --watch --dir migrations",
12
+ "dev:web": "vite dev --port 3000",
13
+ "dev": "concurrently -k -n daemon,web -c blue,green \"rindle up --config daemon.json --migrate --gen shared/schema.gen.ts --watch --dir migrations\" \"vite dev --port 3000\"",
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 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",
@@ -14,6 +14,7 @@ import {
14
14
  defineApiMutators,
15
15
  registerQueries,
16
16
  runSharedMutation,
17
+ scoped,
17
18
  sharedApiMutators,
18
19
  } from "@rindle/api-server";
19
20
  import type {
@@ -22,7 +23,6 @@ import type {
22
23
  MutationContext,
23
24
  MutatorCtx,
24
25
  RindleApiServer,
25
- ServerMutationTx,
26
26
  SharedMutatorWithArgs,
27
27
  } from "@rindle/api-server";
28
28
  import { HttpRindleDaemonClient } from "@rindle/daemon-client";
@@ -55,9 +55,13 @@ const apiQueries = registerQueries<User>([roomsQuery, roomDetailQuery]);
55
55
  // whose server run needs no authority beyond that triad needs no entry here at all.
56
56
  //
57
57
  // 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).
58
+ // its auto-wrapped default (spread first, override wins):
59
+ // createRoom a `withGuard` arg policy: an empty or "spam" name hard-rejects BEFORE any write.
60
+ // postMessage a `scoped` mutator (work-outside-tx): it runs async MODERATION *outside* the
61
+ // transaction, then opens the ONE write via `scope.transact`, driving the SAME isomorphic body the
62
+ // browser predicted (its room-exists guard is a `tx.row` read — no raw SQL, and the client predicts
63
+ // it too). A scoped mutator lets server-only work that must NOT hold a DB write open (a moderation/
64
+ // LLM/anti-abuse call, a payment) run before — or after — the atomic write.
61
65
 
62
66
  /** The `MutatorCtx` a shared body sees on the server: the AUTHENTICATED subject (throws if absent —
63
67
  * a business rejection). Never a client-supplied author. */
@@ -81,19 +85,29 @@ const apiMutators = defineApiMutators<User, ApiMutators<User>>({
81
85
  // (a) a server-only policy on the shared body: an empty or "spam" name throws BEFORE any write.
82
86
  createRoom: withGuard(sharedMutators.createRoom, (a) => void cleanName(a.name)),
83
87
 
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>) => {
88
+ // (b) a SCOPED mutator (work-outside-tx): server-only code runs OUTSIDE the write transaction, then
89
+ // ONE atomic write opens via `scope.transact`, then post-commit code may run.
90
+ postMessage: scoped<User, unknown>(async (scope, raw, ctx) => {
88
91
  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
- },
92
+
93
+ // OUTSIDE the transaction: moderation is an async, out-of-database call (a real app would reach an
94
+ // LLM or anti-abuse service) — the ONE thing here the client genuinely can't predict. A scoped
95
+ // mutator exists precisely so this does NOT hold a DB write open across the network round trip. A
96
+ // throw rejects the post before any write the client's optimistic message snaps back. For a
97
+ // NON-idempotent external effect, key it on the stable `ctx.envelope.mid` so a retried envelope
98
+ // can't double-fire it.
99
+ await moderateBody(a.body);
100
+
101
+ // INSIDE the transaction: drive the SAME isomorphic body the browser predicted (shared/app-def.ts).
102
+ // Its room-exists guard is a `tx.row` READ (read-your-writes) — no raw SQL, no server-only insert —
103
+ // so the client predicts the same guard. This is the one atomic write; the daemon stamps `lmid`
104
+ // together with it. A throw inside the body would be a business rejection surfaced as
105
+ // `MutationRejected` (data rolls back, `lmid` still advances).
106
+ await scope.transact(sharedMutators.postMessage, a, sharedCtx(ctx));
107
+
108
+ // AFTER commit: fire post-commit effects here (a push notification, a webhook). They can't roll the
109
+ // write back, so keep them best-effort (wrap your own try/catch). None are needed for this demo.
110
+ }),
97
111
  });
98
112
 
99
113
  /** A bearer-auth'd daemon control-plane target. */
@@ -152,11 +166,13 @@ function cleanName(name: string): string {
152
166
  return out;
153
167
  }
154
168
 
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;
169
+ /** Message moderation — a PLACEHOLDER for a real async, out-of-database check (an LLM / anti-abuse
170
+ * service). It runs OUTSIDE the write transaction (see `postMessage`), the whole reason a scoped
171
+ * mutator exists: you don't hold a DB write open across a network call. Here it just flags the "spam"
172
+ * demo; throwing rejects the post before any write (server-only, so the client's optimistic message
173
+ * snaps back). Empty/normalization is handled ISOMORPHICALLY in the shared body, so it's not here. */
174
+ async function moderateBody(body: string): Promise<void> {
175
+ if (/\bspam\b/i.test(normalizeBody(body))) throw new Error('the word "spam" is not allowed');
160
176
  }
161
177
 
162
178
  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
 
@@ -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
  }
@@ -0,0 +1,19 @@
1
+ # Rindle Cloud topology for this app — one write-owning `rindled` (the `dev` profile / Shape A).
2
+ #
3
+ # A topology is a plain record of inputs; `rindle deploy` (and `rindle render`) merge it onto the
4
+ # `rindle.ncl` library embedded in the `rindle` binary — nothing to import or install. LOCAL dev does
5
+ # NOT read this file: bare `rindle up` runs off `daemon.json` (see package.json `dev` / `daemon`).
6
+ # This is what the CLOUD tooling reads:
7
+ #
8
+ # rindle login # once — authenticate to Rindle Cloud
9
+ # rindle deploy # provision / re-attach the managed app (writes .rindle/cloud.json)
10
+ # rindle migrate apply --remote # push migrations/*.sql to the deployed daemon
11
+ #
12
+ # `app` becomes the cloud display name and groups every component under one app in `rindle ps`.
13
+ # `dev` maps to the cloud `single` profile: one standalone daemon.
14
+ {
15
+ profile = "dev",
16
+ app = "__PROJECT_NAME__",
17
+ daemonHttpPort = 7600,
18
+ daemonWsPort = 7601,
19
+ }
@@ -6,7 +6,8 @@ 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
+ /** The daemon's PUBLIC subscription ws. Unset in dev (rindle-client.ts falls back to
10
+ * ws://127.0.0.1:7601); set it per deploy to point the browser at your daemon host. */
10
11
  readonly VITE_DAEMON_WS?: string;
11
12
  }
12
13
 
@@ -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. The daemon ws is still connected to directly (ws://127.0.0.1:7601).
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();