create-rindle 0.1.6

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.
Files changed (46) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +70 -0
  3. package/index.mjs +124 -0
  4. package/lib/scaffold.mjs +169 -0
  5. package/package.json +38 -0
  6. package/templates/minimal/README.md +72 -0
  7. package/templates/minimal/_gitignore +7 -0
  8. package/templates/minimal/migrations/0001_init.sql +21 -0
  9. package/templates/minimal/package.json +44 -0
  10. package/templates/minimal/server/app-api.ts +115 -0
  11. package/templates/minimal/server/auth-dev.ts +22 -0
  12. package/templates/minimal/server/dev.ts +148 -0
  13. package/templates/minimal/server/migrate.ts +47 -0
  14. package/templates/minimal/server/rindle-http.ts +38 -0
  15. package/templates/minimal/server/seed.ts +70 -0
  16. package/templates/minimal/shared/app-def.ts +100 -0
  17. package/templates/minimal/shared/auth.ts +18 -0
  18. package/templates/minimal/shared/schema.gen.ts +29 -0
  19. package/templates/minimal/src/RindleApp.tsx +47 -0
  20. package/templates/minimal/src/components/Composer.tsx +38 -0
  21. package/templates/minimal/src/components/MessageCard.queries.ts +13 -0
  22. package/templates/minimal/src/components/MessageCard.tsx +20 -0
  23. package/templates/minimal/src/components/NewRoomForm.tsx +30 -0
  24. package/templates/minimal/src/components/RoomCard.queries.ts +20 -0
  25. package/templates/minimal/src/components/RoomCard.tsx +23 -0
  26. package/templates/minimal/src/components/RoomView.queries.ts +26 -0
  27. package/templates/minimal/src/components/Toaster.tsx +31 -0
  28. package/templates/minimal/src/components/TopBar.tsx +36 -0
  29. package/templates/minimal/src/devtools.tsx +27 -0
  30. package/templates/minimal/src/lib/format.ts +8 -0
  31. package/templates/minimal/src/lib/use-handle.ts +16 -0
  32. package/templates/minimal/src/rindle-client.ts +94 -0
  33. package/templates/minimal/src/routeTree.gen.ts +147 -0
  34. package/templates/minimal/src/router.tsx +20 -0
  35. package/templates/minimal/src/routes/__root.tsx +64 -0
  36. package/templates/minimal/src/routes/api.rindle.mutate.tsx +16 -0
  37. package/templates/minimal/src/routes/api.rindle.query.tsx +16 -0
  38. package/templates/minimal/src/routes/api.rindle.read.tsx +16 -0
  39. package/templates/minimal/src/routes/index.tsx +51 -0
  40. package/templates/minimal/src/routes/r.$id.tsx +60 -0
  41. package/templates/minimal/src/ssr.ts +51 -0
  42. package/templates/minimal/src/styles.css +220 -0
  43. package/templates/minimal/src/tanstack-start.d.ts +6 -0
  44. package/templates/minimal/tsconfig.json +17 -0
  45. package/templates/minimal/vite-env.d.ts +15 -0
  46. package/templates/minimal/vite.config.ts +29 -0
@@ -0,0 +1,72 @@
1
+ # __PROJECT_NAME__
2
+
3
+ A SQL-first [Rindle](https://github.com/rindle-sh/rindle) app on [TanStack Start](https://tanstack.com/start),
4
+ generated by `create-rindle`. It's a tiny forum-of-rooms: each room shows a **live message count**
5
+ that the in-process incremental-view engine keeps exact on every write — no polling.
6
+
7
+ ## The shape
8
+
9
+ Three tiers, same as the Rindle flagship examples:
10
+
11
+ - **Browser** — a TanStack Start SPA whose data layer *is* Rindle. The wasm IVM engine runs
12
+ in-process: reads resolve locally and instantly, writes apply optimistically and reconcile on
13
+ confirmation. Views are co-located Relay-style fragments (`src/components/*.queries.ts`).
14
+ - **API authority** (`server/app-api.ts`) — resolves named queries to ASTs, runs the authoritative SQL
15
+ mutators, and enforces policy: reads are public, writes require an identity, and a `"spam"` name/body
16
+ is rejected (you'll see the optimistic write snap back + a toast). It's host-agnostic: the browser
17
+ reaches it through TanStack Start **server routes** (`src/routes/api.rindle.{query,read,mutate}.tsx`,
18
+ via `server/rindle-http.ts`) that run in the same server as the app, and SSR calls the very same
19
+ factory **in-process** (no network hop).
20
+ - **Daemon** (`rindled`) — owns the SQLite data + live IVM and streams normalized deltas to subscribers.
21
+
22
+ ## SQL is the source of truth
23
+
24
+ The schema lives in `migrations/*.sql`. The `@rindle/client` table schema is **generated** from it
25
+ into `shared/schema.gen.ts` (re-exported by `shared/app-def.ts`, which layers on the relationships,
26
+ normalization, and mutators) — so the TypeScript can't drift from the DDL. To change the schema, edit
27
+ or add a `migrations/*.sql`; `pnpm dev` re-applies it and regenerates `shared/schema.gen.ts` on every
28
+ change.
29
+
30
+ ## Run it
31
+
32
+ ```bash
33
+ pnpm install
34
+ pnpm dev
35
+ ```
36
+
37
+ `pnpm dev` (`server/dev.ts`) is one command: it boots the daemon (`rindle up` supervising `rindled` —
38
+ prebuilt binaries from `@rindle/cli`, no Rust toolchain needed), applies the migrations, regenerates
39
+ `shared/schema.gen.ts`, starts Vite (which serves both the app and the `/api/rindle` route), and seeds
40
+ a little data. Open two browser windows
41
+ to watch writes sync live. Post a message and watch the room's count update on the home page. Try a
42
+ room name or message containing "spam" to see the rejection path.
43
+
44
+ > Requires Node ≥ 22.18 (runs the `.ts` server files via Node's built-in type stripping). The TanStack
45
+ > route tree (`src/routeTree.gen.ts`) is a generated artifact — `pnpm dev` and `pnpm generate-routes`
46
+ > produce it; `pnpm typecheck` runs `tsr generate` first.
47
+
48
+ ## Devtools
49
+
50
+ In development a floating **🌊 Rindle** devtools pane is mounted (`src/devtools.tsx`): a live view of the
51
+ **mutation timeline** (the optimistic fork/rebase loop, with a snap-back highlight when a prediction
52
+ diverges from the server), the **queries inspector**, and the raw **delta stream**. It's wired up in
53
+ `src/devtools.tsx` (the panel) and `src/rindle-client.ts` (`attachDevtools`), both behind
54
+ `import.meta.env.DEV` — so `@rindle/devtools` and `@rindle/react-devtools` are tree-shaken entirely out
55
+ of `vite build` and never ship to production.
56
+
57
+ ## Layout
58
+
59
+ | path | what |
60
+ |---|---|
61
+ | `migrations/*.sql` | the schema — **source of truth** |
62
+ | `shared/schema.gen.ts` | generated `@rindle/client` schema (don't edit by hand) |
63
+ | `shared/app-def.ts` | the contract root: schema re-export, relationships, mutators |
64
+ | `shared/auth.ts` | the identity seam (`AuthProvider`) |
65
+ | `server/dev.ts` | one-command dev orchestrator |
66
+ | `server/app-api.ts` | the authority: query resolution, SQL mutators, policy (host-agnostic) |
67
+ | `server/rindle-http.ts` | adapts the authority to a Web Request (the Start API routes call it) |
68
+ | `src/routes/api.rindle.*.tsx` | the three API endpoints as Start server routes (the browser's API) |
69
+ | `src/ssr.ts` | first-paint preload — calls the authority in-process |
70
+ | `src/components/*.queries.ts` | co-located queries + fragments |
71
+ | `src/routes/*` | TanStack routes |
72
+ | `src/devtools.tsx` | dev-only in-browser devtools pane (tree-shaken from prod) |
@@ -0,0 +1,7 @@
1
+ node_modules
2
+ dist
3
+ .rindled
4
+ *.db
5
+ *.db-*
6
+ .DS_Store
7
+ *.log
@@ -0,0 +1,21 @@
1
+ -- 0001_init — the app's two normalized tables.
2
+ --
3
+ -- This SQL is the SINGLE SOURCE OF TRUTH for the schema. It is applied to the daemon on demand through
4
+ -- the `/migrate` endpoint (run by `rindle migrate apply`, which `pnpm dev` drives for you), and the
5
+ -- @rindle/client TypeScript schema is GENERATED from the daemon's introspected result into
6
+ -- shared/schema.gen.ts — so the TS can't drift from the DDL.
7
+ --
8
+ -- To change the schema: add or edit a file in migrations/. `pnpm dev --watch` re-applies it and
9
+ -- regenerates shared/schema.gen.ts automatically; `pnpm migrate` does the same for a one-shot run.
10
+ --
11
+ -- Column kinds map to the client schema like so: TEXT → string(), INTEGER/REAL → number(), a column
12
+ -- declared BOOLEAN/BOOL → boolean(), JSON → json(). Column ORDER matters — the IVM engine reads it
13
+ -- back with PRAGMA table_info. `IF NOT EXISTS` keeps re-runs safe.
14
+
15
+ CREATE TABLE IF NOT EXISTS room (id TEXT, name TEXT, createdAt REAL, PRIMARY KEY (id));
16
+ -- The room list orders newest-first.
17
+ CREATE INDEX IF NOT EXISTS room_created ON room (createdAt DESC, id);
18
+
19
+ CREATE TABLE IF NOT EXISTS message (id TEXT, roomId TEXT, author TEXT, body TEXT, createdAt REAL, PRIMARY KEY (id));
20
+ -- Forward (a room's messages, oldest-first) AND the reverse the live count uses when a message changes.
21
+ CREATE INDEX IF NOT EXISTS message_room ON message (roomId, createdAt, id);
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "__PROJECT_NAME__",
3
+ "version": "0.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "description": "A SQL-first Rindle app on TanStack Start — generated by create-rindle.",
7
+ "engines": {
8
+ "node": ">=22.18"
9
+ },
10
+ "scripts": {
11
+ "dev": "node server/dev.ts",
12
+ "migrate": "node server/migrate.ts",
13
+ "generate-routes": "tsr generate",
14
+ "build": "vite build",
15
+ "typecheck": "tsr generate && tsc --noEmit",
16
+ "seed": "node server/seed.ts"
17
+ },
18
+ "dependencies": {
19
+ "@rindle/api-server": "latest",
20
+ "@rindle/client": "latest",
21
+ "@rindle/daemon-client": "latest",
22
+ "@rindle/optimistic": "latest",
23
+ "@rindle/react": "latest",
24
+ "@rindle/wasm": "latest",
25
+ "@tanstack/react-router": "latest",
26
+ "@tanstack/react-start": "latest",
27
+ "react": "^19.1.0",
28
+ "react-dom": "^19.1.0",
29
+ "zod": "^4.4.3"
30
+ },
31
+ "devDependencies": {
32
+ "@rindle/cli": "latest",
33
+ "@rindle/devtools": "latest",
34
+ "@rindle/react-devtools": "latest",
35
+ "@tanstack/router-cli": "^1.167.17",
36
+ "@tanstack/router-plugin": "^1.168.18",
37
+ "@types/node": "^22.10.0",
38
+ "@types/react": "^19.0.0",
39
+ "@types/react-dom": "^19.0.0",
40
+ "@vitejs/plugin-react": "^6.0.1",
41
+ "typescript": "^5.7.0",
42
+ "vite": "^8.0.0"
43
+ }
44
+ }
@@ -0,0 +1,115 @@
1
+ // The app authority, runtime-AGNOSTIC. The serverless-shaped tier's actual logic: it resolves named
2
+ // queries to ASTs (the daemon mints opaque leases), runs the AUTHORITATIVE mutators into approved SQL,
3
+ // enforces policy (identity required to write, a "spam" rejection demo), and talks to the daemon over
4
+ // the private bearer-auth'd control plane.
5
+ //
6
+ // It is deliberately free of any host: server/rindle-http.ts adapts it to a Web Request for the Start
7
+ // API routes (src/routes/api.rindle.*.tsx) the browser calls, and the SSR loader (src/ssr.ts) calls the
8
+ // SAME factory in-process. The only per-host inputs are the daemon's control-plane URL + token and the
9
+ // AuthProvider.
10
+
11
+ import { createRindleApiServer, defineApiMutators, registerQueries } from "@rindle/api-server";
12
+ import type { ApiMutators, MutationContext, RindleApiServer, SqlMutationTx } from "@rindle/api-server";
13
+ import { HttpRindleDaemonClient } from "@rindle/daemon-client";
14
+ import type { FetchLike } from "@rindle/daemon-client";
15
+
16
+ import { createRoomArgs, normalizeBody, normalizeName, normalizeSubject, postMessageArgs } from "../shared/app-def.ts";
17
+ import type { Identity } from "../shared/auth.ts";
18
+ import { roomsQuery } from "../src/components/RoomCard.queries.ts";
19
+ import { roomDetailQuery } from "../src/components/RoomView.queries.ts";
20
+
21
+ /** The authority's principal is the verified identity (or undefined when anonymous). */
22
+ export type User = Identity | undefined;
23
+
24
+ // The authority's query surface is just the list of co-located client queries. Each `defineQuery`
25
+ // re-runs its validator on the UNTRUSTED wire args before building the AST, so a malformed client
26
+ // can't smuggle a garbage arg in.
27
+ const apiQueries = registerQueries<User>([roomsQuery, roomDetailQuery]);
28
+
29
+ const apiMutators = defineApiMutators<User, ApiMutators<User>>({
30
+ createRoom: (tx: SqlMutationTx, raw: unknown, ctx: MutationContext<User>) => {
31
+ const a = createRoomArgs.parse(raw);
32
+ requireUser(ctx.user);
33
+ const name = cleanName(a.name);
34
+ tx.exec("INSERT OR IGNORE INTO room (id, name, createdAt) VALUES (?, ?, ?)", [a.id, name, a.createdAt]);
35
+ },
36
+ postMessage: (tx: SqlMutationTx, raw: unknown, ctx: MutationContext<User>) => {
37
+ const a = postMessageArgs.parse(raw);
38
+ const who = requireUser(ctx.user);
39
+ const body = cleanBody(a.body);
40
+ // Insert only into a room that exists — a racing client can't post into a deleted room. `author`
41
+ // is the VERIFIED subject, never the wire arg.
42
+ tx.exec(
43
+ `INSERT INTO message (id, roomId, author, body, createdAt)
44
+ SELECT ?, ?, ?, ?, ? WHERE EXISTS (SELECT 1 FROM room WHERE id = ?)`,
45
+ [a.id, a.roomId, who.subject, body, a.createdAt, a.roomId],
46
+ );
47
+ },
48
+ });
49
+
50
+ /** A bearer-auth'd daemon control-plane target. */
51
+ export interface AppApiOptions {
52
+ /** The daemon control-plane base URL — bearer-auth'd. */
53
+ daemonUrl: string;
54
+ /** The shared bearer token for {@link daemonUrl} (daemon ↔ this tier ONLY — never reaches the browser). */
55
+ daemonToken: string;
56
+ /** Override the daemon HTTP transport (defaults to global `fetch`). */
57
+ fetch?: FetchLike;
58
+ }
59
+
60
+ /** Build the configured API server. Stateless: safe to construct per-request or once per process.
61
+ * Reads are PUBLIC; writes require a verified identity. */
62
+ export function createAppApi(opts: AppApiOptions): RindleApiServer<User> {
63
+ const daemon = new HttpRindleDaemonClient({
64
+ baseUrl: opts.daemonUrl,
65
+ headers: { authorization: `Bearer ${opts.daemonToken}` },
66
+ fetch: opts.fetch,
67
+ });
68
+ return createRindleApiServer<User>({
69
+ daemon,
70
+ queries: apiQueries,
71
+ mutators: apiMutators,
72
+ authorizeQuery: () => true, // public reads
73
+ authorizeMutation: ({ user }) => !!user && user.subject.length > 0, // must be signed in
74
+ });
75
+ }
76
+
77
+ /** Resolve the daemon wiring from an environment bag, shared by both host shells. */
78
+ export function resolveDaemon(
79
+ env: Record<string, string | undefined>,
80
+ defaults: { daemonUrl: string; daemonToken: string },
81
+ ): Pick<AppApiOptions, "daemonUrl" | "daemonToken"> {
82
+ return {
83
+ daemonUrl: env.RINDLE_DAEMON_URL ?? defaults.daemonUrl,
84
+ daemonToken: env.RINDLE_DAEMON_TOKEN ?? defaults.daemonToken,
85
+ };
86
+ }
87
+
88
+ /** Map an error thrown out of the API server (or body parsing) to an HTTP status + message. */
89
+ export function httpErrorOf(err: unknown): { status: number; message: string } {
90
+ const status = typeof err === "object" && err !== null ? (err as { status?: unknown }).status : undefined;
91
+ return {
92
+ status: typeof status === "number" ? status : 500,
93
+ message: String(err instanceof Error ? err.message : err),
94
+ };
95
+ }
96
+
97
+ /** A room name policy that also exercises the REJECTION path end to end (toast in the UI). */
98
+ function cleanName(name: string): string {
99
+ const out = normalizeName(name);
100
+ if (out.length === 0) throw new Error("a room name is required");
101
+ if (/\bspam\b/i.test(out)) throw new Error('the word "spam" is not allowed');
102
+ return out;
103
+ }
104
+
105
+ function cleanBody(body: string): string {
106
+ const out = normalizeBody(body);
107
+ if (out.length === 0) throw new Error("a message can't be empty");
108
+ if (/\bspam\b/i.test(out)) throw new Error('the word "spam" is not allowed');
109
+ return out;
110
+ }
111
+
112
+ function requireUser(user: User): Identity {
113
+ if (!user || user.subject.length === 0) throw new Error("you must be signed in");
114
+ return { subject: normalizeSubject(user.subject) };
115
+ }
@@ -0,0 +1,22 @@
1
+ // The DEV identity provider — resolves the `x-rindle-user` header to an Identity: no passwords, no
2
+ // auth service, so the example runs standalone. The browser persists a handle per tab
3
+ // (src/rindle-client.ts) and sends it on every `/api/rindle/*` call; SSR uses a fixed viewer (reads
4
+ // are public). Swap this for a real provider (e.g. JWT-against-JWKS) without touching the API server.
5
+
6
+ import { normalizeSubject } from "../shared/app-def.ts";
7
+ import type { AuthProvider, Identity } from "../shared/auth.ts";
8
+
9
+ /** Resolve a handle to a dev identity, or null when blank. */
10
+ export function identityFromHandle(handle: string | null | undefined): Identity | null {
11
+ if (!handle) return null;
12
+ const subject = normalizeSubject(handle);
13
+ if (!subject || subject === "anon") return null;
14
+ return { subject };
15
+ }
16
+
17
+ /** The dev AuthProvider: the principal rides the `x-rindle-user` header. */
18
+ export const devAuth: AuthProvider = {
19
+ verify(req: Request): Promise<Identity | null> {
20
+ return Promise.resolve(identityFromHandle(req.headers.get("x-rindle-user")));
21
+ },
22
+ };
@@ -0,0 +1,148 @@
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.");
@@ -0,0 +1,47 @@
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
+ });
@@ -0,0 +1,38 @@
1
+ // The HTTP host for the app authority: it adapts a Web `Request` to the runtime-agnostic factory in
2
+ // app-api.ts and dispatches the three Rindle endpoints. The TanStack Start server routes in
3
+ // src/routes/api.rindle.*.tsx import this from INSIDE their handlers (a dynamic import), so this module
4
+ // and its daemon/SQL deps never reach the client bundle. SSR reads skip this and call the authority
5
+ // in-process instead (src/ssr.ts) — no network hop.
6
+
7
+ import type { ApiContext } from "@rindle/api-server";
8
+
9
+ import { createAppApi, httpErrorOf, resolveDaemon } from "./app-api.ts";
10
+ import type { User } from "./app-api.ts";
11
+ import { devAuth } from "./auth-dev.ts";
12
+
13
+ /** Which Rindle endpoint a request targets. */
14
+ export type RindleRouteKind = "query" | "read" | "mutate";
15
+
16
+ /** Run one `/api/rindle/{query,read,mutate}` request through the authority. Stateless: the authority is
17
+ * built per request (cheap), so this is safe in a fresh-per-request serverless host too. */
18
+ export async function handleRindleJson(kind: RindleRouteKind, request: Request): Promise<Response> {
19
+ try {
20
+ const daemon = resolveDaemon(process.env, {
21
+ daemonUrl: "http://127.0.0.1:7600",
22
+ daemonToken: "dev-daemon-token",
23
+ });
24
+ const api = createAppApi(daemon);
25
+ const body = await request.json().catch(() => ({}));
26
+ const context: ApiContext<User> = { user: (await devAuth.verify(request)) ?? undefined, request };
27
+ const out =
28
+ kind === "query"
29
+ ? await api.handleQueryJson(body, context)
30
+ : kind === "read"
31
+ ? await api.handleReadJson(body, context)
32
+ : await api.handleMutateJson(body, context);
33
+ return Response.json(out);
34
+ } catch (err) {
35
+ const { status, message } = httpErrorOf(err);
36
+ return Response.json({ error: message }, { status });
37
+ }
38
+ }
@@ -0,0 +1,70 @@
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();