create-rindle 0.5.0 → 0.6.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.mjs CHANGED
@@ -116,7 +116,7 @@ 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, and boots the daemon + Vite (app + /api/rindle routes).\n` +
119
+ ` generates shared/schema.gen.ts, and boots the topology pair + Vite (app + /api/rindle routes).\n` +
120
120
  ` Open two browser windows, create a room, and watch writes sync live.\n\n`,
121
121
  );
122
122
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-rindle",
3
- "version": "0.5.0",
3
+ "version": "0.6.4",
4
4
  "license": "Apache-2.0",
5
5
  "repository": {
6
6
  "type": "git",
@@ -3,9 +3,10 @@
3
3
  Guidance for coding agents (and new humans). This is a **Rindle** app — an
4
4
  incremental-view-maintenance (IVM) engine keeps every registered query's result
5
5
  exact on each write instead of re-running it. Three tiers: a TanStack Start SPA
6
- running the wasm engine in-process, an API authority, and the `rindled` daemon
7
- that owns the SQLite data. The correctness contract everywhere is
8
- **view-after-write == fresh-query**.
6
+ running the wasm engine in-process, an API authority, and the data tier — the one
7
+ topology (design 214): a `rindle-replicator` write-master + a `rindled`
8
+ read-follower (`followers = 1` = the colocated pair). The correctness contract
9
+ everywhere is **view-after-write == fresh-query**.
9
10
 
10
11
  Rindle docs are served as raw markdown for LLMs: index at
11
12
  <https://rindle.sh/llms.txt>, the whole app track in one file at
@@ -13,18 +14,22 @@ Rindle docs are served as raw markdown for LLMs: index at
13
14
 
14
15
  ## Commands
15
16
 
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`.
17
+ - `pnpm dev` — the one command: `concurrently` runs two processes — **fleet**
18
+ (`rindle up` renders `rindle.ncl` and supervises the pair the
19
+ `rindle-replicator` write-master + the `rindled` follower, prebuilt binaries
20
+ from `@rindle/cli`, no Rust toolchain) applies `migrations/*.sql` to the master
21
+ and regenerates `shared/schema.gen.ts` from the follower (re-running both on
22
+ every `migrations/` change), and **web** (`rindle exec -- vite dev` on :3000) receives bindings
23
+ derived from `rindle.ncl` and serves the app +
24
+ the `/api/rindle/*` server routes. Run either alone with `pnpm fleet` /
25
+ `pnpm dev:web`.
22
26
  - `pnpm typecheck` — regenerates the route tree, then `tsc --noEmit`.
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`.
27
+ - `pnpm migrate` — one-shot `rindle exec -- rindle migrate apply` against the write-master derived
28
+ from `rindle.ncl` (the follower's `/migrate` is write-fenced). The dev loop already
29
+ applies on boot + on every `migrations/` change.
30
+ - `pnpm rindle:deploy` / `pnpm rindle:migrate:remote` deploy the data tier to
31
+ Rindle Cloud (reads `rindle.ncl`, the same file `rindle up` runs locally; run
32
+ `rindle login` once first) and push `migrations/*.sql` to the deployed master.
28
33
 
29
34
  ## Rules that keep the app correct
30
35
 
@@ -55,9 +60,9 @@ review:
55
60
  `src/components/*.queries.ts` and register them in `server/app-api.ts`. An
56
61
  ad-hoc `store.query.…` builder resolves **locally only** — it never opens a
57
62
  server subscription.
58
- 5. **The daemon token is server-only.** It gates the private control plane
59
- (`:7600`) and must never reach the browser; the browser holds only the
60
- lease-gated public WebSocket (`:7601`).
63
+ 5. **Daemon tokens are server-only.** They gate the follower and write-master control planes and
64
+ must never reach the browser; the browser reaches only the lease-gated fleet edge. Local endpoint
65
+ bindings come from `rindle exec`; never copy topology ports into package scripts.
61
66
  6. **Subscribe to windows, not whole tables** — order + `limit`, and ratchet
62
67
  `limit` up for "load more". The engine keeps the window (and any `countAs`)
63
68
  exact as rows enter and leave.
@@ -75,8 +80,7 @@ review:
75
80
  | `src/rindle-client.ts` | the one-call browser wire-up (`createRindleClient`) |
76
81
  | `server/app-api.ts` | the authority: `registerQueries` + `sharedApiMutators` + server-only policy |
77
82
  | `src/routes/api.rindle.*.tsx` | TanStack Start server routes exposing the authority over HTTP |
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 |
83
+ | `rindle.ncl` | the one topology (the colocated pair) — `rindle up` runs it locally, `rindle deploy` provisions it |
80
84
  | `src/ssr.ts` | SSR preload of the same named queries for first paint |
81
85
 
82
86
  ## Reading more
@@ -18,7 +18,10 @@ Three tiers, same as the Rindle flagship examples:
18
18
  reaches it through TanStack Start **server routes** (`src/routes/api.rindle.{query,read,mutate}.tsx`,
19
19
  via `server/rindle-http.ts`) that run in the same server as the app, and SSR calls the very same
20
20
  factory **in-process** (no network hop).
21
- - **Daemon** (`rindled`) owns the SQLite data + live IVM and streams normalized deltas to subscribers.
21
+ - **Data tier** — the one topology (design 214): a `rindle-replicator` **write-master** plus a
22
+ `rindled` **read-follower** that owns the live IVM and streams normalized deltas to subscribers.
23
+ Writes land on the master; the follower serves reads. `followers = 1` is the *colocated pair* —
24
+ both processes on one box, the smallest shape.
22
25
 
23
26
  ## SQL is the source of truth
24
27
 
@@ -38,15 +41,18 @@ pnpm dev
38
41
  `pnpm dev` runs two processes with [`concurrently`](https://www.npmjs.com/package/concurrently) —
39
42
  the standard TanStack Start dev shape:
40
43
 
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.
44
+ - **`fleet`** — `rindle up` renders `rindle.ncl` and supervises the whole pair (the
45
+ `rindle-replicator` write-master + the `rindled` follower prebuilt binaries from `@rindle/cli`,
46
+ no Rust toolchain needed), applies the migrations to the master, and regenerates
47
+ `shared/schema.gen.ts` from the follower's schema re-running both on every `migrations/*.sql`
48
+ change (`--watch`).
49
+ - **`web`** — `rindle exec -- vite dev` on <http://localhost:3000>, which serves both the app and the
50
+ `/api/rindle/*` Start server routes. `rindle exec` derives the stable fleet-edge read/ws binding
51
+ and write-master binding from `rindle.ncl`; no endpoint is copied into the package scripts.
46
52
 
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.
53
+ You can run either alone: `pnpm fleet` / `pnpm dev:web`. Both processes bind to loopback and run
54
+ without an auth token in dev; a real deployment sets tokens (`RINDLE_DAEMON_TOKEN` for reads,
55
+ `RINDLE_REPLICATOR_TOKEN` for writes on the server) that never reach the browser.
50
56
 
51
57
  Open two browser windows to watch writes sync live: create a room, post a message, and watch the
52
58
  room's count update on the home page with no polling. Try a room name or message containing "spam" to
@@ -57,20 +63,24 @@ see the rejection path (the optimistic write snaps back + a toast).
57
63
 
58
64
  ## Deploy
59
65
 
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:
66
+ `rindle.ncl` describes the **one topology** the same file `rindle up` runs locally. To run the
67
+ **data tier** on Rindle Cloud:
62
68
 
63
69
  ```bash
64
70
  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
71
+ pnpm rindle:deploy # provision / re-attach the managed app (writes .rindle/cloud.json)
72
+ pnpm rindle:migrate:remote # push migrations/*.sql to the deployed write-master
67
73
  ```
68
74
 
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`.
75
+ `rindle deploy` reads `rindle.ncl` and records the binding in `.rindle/cloud.json` — commit it, so
76
+ future deploys re-attach to the same app. `followers = 1` provisions a backed-up write-master + one
77
+ follower (`replicated`, scales 1→N in place); raise it for more read replicas (`read-scaled`);
78
+ `localRetention = true` provisions the one-box colocated pair (`colocated`, no streaming backup
79
+ no durability beyond the box). That deploys the **data tier**; the
80
+ **web app** (this Vite/TanStack Start app) deploys to any Node host — point its `RINDLE_DAEMON_URL`
81
+ (fleet-edge reads) + `RINDLE_REPLICATOR_URL` (writes) + their tokens (server) at the deployed data
82
+ tier, and set `VITE_FLEET_WS` to the stable public fleet ws. These bindings are identical for one or
83
+ many followers, and follower affinity is always enabled.
74
84
 
75
85
  ## Devtools
76
86
 
@@ -89,8 +99,7 @@ of `vite build` and never ship to production.
89
99
  | `shared/schema.gen.ts` | generated `@rindle/client` schema (don't edit by hand) |
90
100
  | `shared/app-def.ts` | the contract root: schema re-export, relationships, isomorphic mutators |
91
101
  | `shared/auth.ts` | the identity seam (`AuthProvider`) |
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 |
102
+ | `rindle.ncl` | the one topology (the colocated pair) — `rindle up` runs it locally, `rindle deploy` provisions it |
94
103
  | `server/app-api.ts` | the authority: query resolution, `sharedApiMutators`, policy (host-agnostic) |
95
104
  | `server/rindle-http.ts` | adapts the authority to a Web Request (the Start API routes call it) |
96
105
  | `src/routes/api.rindle.*.tsx` | the three API endpoints as Start server routes (the browser's API) |
@@ -8,13 +8,13 @@
8
8
  "node": ">=22.18"
9
9
  },
10
10
  "scripts": {
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\"",
11
+ "fleet": "rindle up --migrate --gen shared/schema.gen.ts --watch --dir migrations",
12
+ "dev:web": "rindle exec -- vite dev --port 3000",
13
+ "dev": "concurrently -k -n fleet,web -c blue,green \"pnpm fleet\" \"pnpm dev:web\"",
14
14
  "generate-routes": "tsr generate",
15
15
  "build": "vite build",
16
16
  "preview": "vite preview",
17
- "migrate": "rindle migrate apply --dir migrations",
17
+ "migrate": "rindle exec -- rindle migrate apply --dir migrations",
18
18
  "rindle:deploy": "rindle deploy",
19
19
  "rindle:migrate:remote": "rindle migrate apply --remote",
20
20
  "typecheck": "tsr generate && tsc --noEmit"
@@ -0,0 +1,28 @@
1
+ # The Rindle topology for this app — the ONE topology (design 214): a `rindle-replicator`
2
+ # write-master + one `rindled` read-follower. `followers = 1` is the colocated pair, the smallest
3
+ # shape; raise it for more read replicas.
4
+ #
5
+ # A topology is a plain record of inputs; `rindle up` (local) and `rindle deploy` / `rindle render`
6
+ # (cloud) merge it onto the topology library embedded in the `rindle` binary — nothing to import
7
+ # or install. LOCAL dev reads THIS file: `rindle up` renders it and supervises the pair (see
8
+ # package.json `dev` / `fleet`). The same file drives the cloud:
9
+ #
10
+ # rindle login # once — authenticate to Rindle Cloud
11
+ # rindle deploy # provision / re-attach the managed app (writes .rindle/cloud.json)
12
+ # rindle migrate apply --remote # push migrations/*.sql to the deployed write-master
13
+ #
14
+ # `app` becomes the cloud display name and groups every component under one app in `rindle ps`.
15
+ # `followers = 1` maps to the cloud `replicated` rung (a separate write-master + one follower,
16
+ # backed up; scales 1→N in place); `followers > 1` maps to `read-scaled`. `localRetention = true`
17
+ # maps to `colocated` (one box, both processes, no streaming backup).
18
+ #
19
+ # Every follower count renders a local fleet edge (the Fly-edge stand-in;
20
+ # FOLLOWER-AFFINITY-DESIGN.md §10). `rindle exec` derives the app's read/write/ws bindings from this
21
+ # file, so changing `followers = 1` to a wider fleet requires no application configuration change.
22
+ # Set `RINDLE_DEV_CLIENT_REGION=A` to simulate a client region; the edge always mints an affinity
23
+ # placement ticket, with a fleet of one resolving trivially to its only follower.
24
+ {
25
+ profile = "replicated",
26
+ app = "__PROJECT_NAME__",
27
+ followers = 1,
28
+ }
@@ -16,6 +16,7 @@ import {
16
16
  runSharedMutation,
17
17
  scoped,
18
18
  sharedApiMutators,
19
+ SplitDaemonClient,
19
20
  } from "@rindle/api-server";
20
21
  import type {
21
22
  ApiMutator,
@@ -112,10 +113,15 @@ const apiMutators = defineApiMutators<User, ApiMutators<User>>({
112
113
 
113
114
  /** A bearer-auth'd daemon control-plane target. */
114
115
  export interface AppApiOptions {
115
- /** The daemon control-plane base URL — bearer-auth'd. */
116
+ /** The follower/read daemon control-plane base URL — bearer-auth'd. */
116
117
  daemonUrl: string;
117
118
  /** The shared bearer token for {@link daemonUrl} (daemon ↔ this tier ONLY — never reaches the browser). */
118
119
  daemonToken: string;
120
+ /** The required write-master (`rindle-replicator`) control-plane base URL. Writes + mutation
121
+ * sessions go here; reads stay on {@link daemonUrl}. */
122
+ replicatorUrl: string;
123
+ /** Bearer for {@link replicatorUrl}, if it gates its write plane. */
124
+ replicatorToken?: string;
119
125
  /** Override the daemon HTTP transport (defaults to global `fetch`). */
120
126
  fetch?: FetchLike;
121
127
  }
@@ -123,13 +129,20 @@ export interface AppApiOptions {
123
129
  /** Build the configured API server. Stateless: safe to construct per-request or once per process.
124
130
  * Reads are PUBLIC; writes require a verified identity. */
125
131
  export function createAppApi(opts: AppApiOptions): RindleApiServer<User> {
126
- const daemon = new HttpRindleDaemonClient({
132
+ const reads = new HttpRindleDaemonClient({
127
133
  baseUrl: opts.daemonUrl,
128
134
  headers: { authorization: `Bearer ${opts.daemonToken}` },
129
135
  fetch: opts.fetch,
130
136
  });
131
137
  return createRindleApiServer<User>({
132
- daemon,
138
+ daemon: new SplitDaemonClient(
139
+ new HttpRindleDaemonClient({
140
+ baseUrl: opts.replicatorUrl,
141
+ headers: opts.replicatorToken ? { authorization: `Bearer ${opts.replicatorToken}` } : undefined,
142
+ fetch: opts.fetch,
143
+ }),
144
+ reads,
145
+ ),
133
146
  schema, // drives the dialect-SQL renderer for the shared mutators' logical ops
134
147
  queries: apiQueries,
135
148
  mutators: apiMutators,
@@ -142,10 +155,16 @@ export function createAppApi(opts: AppApiOptions): RindleApiServer<User> {
142
155
  export function resolveDaemon(
143
156
  env: Record<string, string | undefined>,
144
157
  defaults: { daemonUrl: string; daemonToken: string },
145
- ): Pick<AppApiOptions, "daemonUrl" | "daemonToken"> {
158
+ ): Pick<AppApiOptions, "daemonUrl" | "daemonToken" | "replicatorUrl" | "replicatorToken"> {
159
+ const replicatorUrl = env.RINDLE_REPLICATOR_URL;
160
+ if (!replicatorUrl) {
161
+ throw new Error("RINDLE_REPLICATOR_URL is required: writes must target the replicator write-master");
162
+ }
146
163
  return {
147
164
  daemonUrl: env.RINDLE_DAEMON_URL ?? defaults.daemonUrl,
148
165
  daemonToken: env.RINDLE_DAEMON_TOKEN ?? defaults.daemonToken,
166
+ replicatorUrl,
167
+ replicatorToken: env.RINDLE_REPLICATOR_TOKEN,
149
168
  };
150
169
  }
151
170
 
@@ -60,6 +60,11 @@ async function bootClientInner() {
60
60
  import("@rindle/wasm"),
61
61
  ]);
62
62
  await initWasm(wasmUrl);
63
+ // Every deployment exposes one stable fleet ws, including a fleet of one. `rindle exec` derives
64
+ // VITE_FLEET_WS from rindle.ncl, so increasing `followers` never changes app configuration.
65
+ // VITE_DAEMON_WS is a temporary direct-follower escape hatch for focused harnesses.
66
+ const directWs = import.meta.env.VITE_DAEMON_WS?.trim();
67
+ const fleetWs = import.meta.env.VITE_FLEET_WS?.trim() ?? "ws://127.0.0.1:7650";
63
68
  return createRindleClient({
64
69
  schema,
65
70
  mutators,
@@ -71,8 +76,8 @@ async function bootClientInner() {
71
76
  // Identity per request: the dev handle header. A real app sends a verified token instead.
72
77
  headers: (): Record<string, string> => ({ "x-rindle-user": currentHandle() }),
73
78
  },
74
- // 7601 = the daemon's PUBLIC ws port (7600 is the HTTP control plane).
75
- daemon: { wsUrl: import.meta.env.VITE_DAEMON_WS ?? "ws://127.0.0.1:7601" },
79
+ // Normal app traffic always enters through the fleet edge. Direct follower access is explicit.
80
+ daemon: directWs ? { wsUrl: directWs } : { wsUrl: fleetWs, affinity: true },
76
81
  dev: { resetOnMutationGap: import.meta.env.DEV },
77
82
  onRejected: (envelope, reason) => rejectionHandler(envelope, reason),
78
83
  });
@@ -6,9 +6,10 @@ declare module "rindle-wasm-bin?url" {
6
6
  }
7
7
 
8
8
  interface ImportMetaEnv {
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. */
9
+ /** Deprecated direct-follower test/debug escape hatch. Normal apps use VITE_FLEET_WS. */
11
10
  readonly VITE_DAEMON_WS?: string;
11
+ /** Stable fleet-edge PUBLIC ws, derived by `rindle exec` for one or many followers. */
12
+ readonly VITE_FLEET_WS?: string;
12
13
  }
13
14
 
14
15
  interface ImportMeta {
@@ -27,7 +27,7 @@ export default defineConfig({
27
27
  // client-only dynamic import in src/rindle-client.ts, so it never loads in the shell pass).
28
28
  ssr: { noExternal: [/^@rindle\//] },
29
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).
30
+ // this same dev server. Browser subscriptions enter through the fleet edge derived by `rindle exec`.
31
31
  plugins: [
32
32
  // If you add TanStack's devtools panel, DISABLE console piping. The piping cross-forwards console
33
33
  // between the SSR server and the browser; a repeated log (a React warning, a Rindle fetch-retry)
@@ -1,8 +0,0 @@
1
- {
2
- "bindHost": "127.0.0.1",
3
- "db": "rindle.db",
4
- "httpPort": 7600,
5
- "wsPort": 7601,
6
- "nWorkers": 4,
7
- "tables": []
8
- }
@@ -1,19 +0,0 @@
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
- }