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 +5 -0
- package/index.mjs +2 -2
- package/lib/scaffold.mjs +1 -1
- package/package.json +1 -1
- package/templates/minimal/AGENTS.md +13 -6
- package/templates/minimal/README.md +37 -10
- package/templates/minimal/_gitignore +6 -0
- package/templates/minimal/daemon.json +8 -0
- package/templates/minimal/package.json +10 -4
- package/templates/minimal/server/app-api.ts +37 -21
- package/templates/minimal/shared/app-def.ts +9 -3
- package/templates/minimal/src/components/Composer.tsx +5 -4
- package/templates/minimal/src/components/NewRoomForm.tsx +4 -2
- package/templates/minimal/src/routes/__root.tsx +11 -2
- package/templates/minimal/src/styles.css +202 -79
- package/templates/minimal/topology.ncl +19 -0
- package/templates/minimal/vite-env.d.ts +2 -1
- package/templates/minimal/vite.config.ts +14 -3
- package/templates/minimal/server/dev.ts +0 -148
- package/templates/minimal/server/migrate.ts +0 -47
- package/templates/minimal/server/seed.ts +0 -70
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 +
|
|
120
|
-
` Open two browser windows
|
|
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
|
@@ -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
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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`
|
|
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
|
-
| `
|
|
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`
|
|
39
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
| `
|
|
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) |
|
|
@@ -8,12 +8,16 @@
|
|
|
8
8
|
"node": ">=22.18"
|
|
9
9
|
},
|
|
10
10
|
"scripts": {
|
|
11
|
-
"
|
|
12
|
-
"
|
|
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
|
-
"
|
|
16
|
-
"
|
|
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):
|
|
59
|
-
//
|
|
60
|
-
//
|
|
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)
|
|
85
|
-
//
|
|
86
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
|
86
|
-
//
|
|
87
|
-
//
|
|
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
|
-
|
|
18
|
-
// The author is NOT an arg — it's the acting principal (ctx.user), injected
|
|
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:
|
|
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
|
-
|
|
18
|
-
|
|
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: "#
|
|
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
|
-
|
|
3
|
-
--
|
|
4
|
-
--panel
|
|
5
|
-
--
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
--
|
|
9
|
-
--
|
|
10
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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.
|
|
87
|
+
gap: 0.85rem;
|
|
52
88
|
}
|
|
53
89
|
.app-whoami {
|
|
54
|
-
|
|
55
|
-
|
|
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:
|
|
100
|
+
max-width: 760px;
|
|
60
101
|
margin: 0 auto;
|
|
61
|
-
padding:
|
|
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.
|
|
67
|
-
|
|
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
|
|
73
|
-
font-
|
|
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(--
|
|
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
|
-
|
|
84
|
-
font-
|
|
85
|
-
|
|
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
|
-
|
|
94
|
-
border:
|
|
95
|
-
|
|
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.
|
|
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
|
-
|
|
107
|
-
|
|
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.
|
|
125
|
-
margin-bottom: 1.
|
|
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.
|
|
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
|
-
|
|
231
|
+
gap: 1rem;
|
|
232
|
+
padding: 1rem 1.15rem;
|
|
146
233
|
background: var(--panel);
|
|
147
|
-
border: 1px solid var(--
|
|
148
|
-
border-radius:
|
|
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(--
|
|
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:
|
|
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:
|
|
160
|
-
|
|
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
|
-
|
|
165
|
-
font-size: 0.
|
|
166
|
-
|
|
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.
|
|
172
|
-
margin:
|
|
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(--
|
|
280
|
+
border: 1px solid var(--line);
|
|
177
281
|
border-radius: 12px;
|
|
178
|
-
padding: 0.
|
|
282
|
+
padding: 0.85rem 1rem;
|
|
179
283
|
}
|
|
180
284
|
.app-message-head {
|
|
181
285
|
display: flex;
|
|
182
286
|
justify-content: space-between;
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
margin-bottom: 0.25rem;
|
|
287
|
+
gap: 1rem;
|
|
288
|
+
margin-bottom: 0.3rem;
|
|
186
289
|
}
|
|
187
290
|
.app-message-author {
|
|
188
|
-
font-weight:
|
|
189
|
-
|
|
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.
|
|
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:
|
|
207
|
-
right:
|
|
317
|
+
bottom: 1.25rem;
|
|
318
|
+
right: 1.25rem;
|
|
208
319
|
display: grid;
|
|
209
|
-
gap: 0.
|
|
320
|
+
gap: 0.6rem;
|
|
210
321
|
z-index: 50;
|
|
211
322
|
}
|
|
212
323
|
.app-toast {
|
|
213
|
-
background: var(--
|
|
214
|
-
color:
|
|
215
|
-
|
|
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-
|
|
329
|
+
font-size: 0.85rem;
|
|
330
|
+
font-weight: 500;
|
|
218
331
|
max-width: 320px;
|
|
219
|
-
box-shadow: 0
|
|
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 (
|
|
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/*
|
|
27
|
-
//
|
|
28
|
-
plugins: [
|
|
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();
|