create-rindle 0.2.0 → 0.4.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-rindle",
3
- "version": "0.2.0",
3
+ "version": "0.4.2",
4
4
  "license": "Apache-2.0",
5
5
  "repository": {
6
6
  "type": "git",
@@ -0,0 +1,73 @@
1
+ # AGENTS.md — working on __PROJECT_NAME__
2
+
3
+ Guidance for coding agents (and new humans). This is a **Rindle** app — an
4
+ incremental-view-maintenance (IVM) engine keeps every registered query's result
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**.
9
+
10
+ Rindle docs are served as raw markdown for LLMs: index at
11
+ <https://rindle.sh/llms.txt>, the whole app track in one file at
12
+ <https://rindle.sh/llms-app.txt>.
13
+
14
+ ## Commands
15
+
16
+ - `pnpm dev` — the only command you need: boots `rindled` (prebuilt binary from
17
+ `@rindle/cli`, no Rust toolchain), applies `migrations/*.sql`, regenerates
18
+ `shared/schema.gen.ts`, seeds, and starts Vite (the app + the `/api/rindle`
19
+ server routes). Re-applies/regenerates on every `migrations/` change.
20
+ - `pnpm typecheck` — regenerates the route tree, then `tsc --noEmit`.
21
+ - `pnpm migrate` / `pnpm seed` — one-shot apply / seed against the running daemon.
22
+
23
+ ## Rules that keep the app correct
24
+
25
+ Break one of these and the app goes subtly wrong. Treat violations as bugs in
26
+ review:
27
+
28
+ 1. **SQL is the source of truth.** Change the schema by **adding** a
29
+ `migrations/*.sql` file (additive DDL: `CREATE TABLE` / `ADD COLUMN` /
30
+ `CREATE INDEX`; every table a single primary key). **Never hand-edit
31
+ `shared/schema.gen.ts`** — it is generated from the live daemon and
32
+ overwritten on the next migration.
33
+ 2. **Predicted mutators are deterministic and replayable**
34
+ (`shared/app-def.ts`): no `Date.now()`, no `Math.random()`, no I/O — they
35
+ re-run on every rebase. Generate ids and timestamps at the callsite and pass
36
+ them in as args.
37
+ 3. **Every mutator has two halves under one name**: the client's *predicted*
38
+ mutator in `shared/app-def.ts` and the server's *authoritative* SQL twin in
39
+ `server/app-api.ts`. Add or change them together. Only `(name, args)` ever
40
+ crosses the wire — validate args hard on the server; `throw` to hard-reject
41
+ (the optimistic write snaps back on its own; write no rollback code).
42
+ 4. **Remote subscriptions must be named.** Define queries with `defineQuery` in
43
+ `src/components/*.queries.ts` and register them in `server/app-api.ts`. An
44
+ ad-hoc `store.query.…` builder resolves **locally only** — it never opens a
45
+ server subscription.
46
+ 5. **The daemon token is server-only.** It gates the private control plane
47
+ (`:7600`) and must never reach the browser; the browser holds only the
48
+ lease-gated public WebSocket (`:7601`).
49
+ 6. **Subscribe to windows, not whole tables** — order + `limit`, and ratchet
50
+ `limit` up for "load more". The engine keeps the window (and any `countAs`)
51
+ exact as rows enter and leave.
52
+ 7. **Keep `*.queries.ts` modules React-free** — no `.tsx` imports. The browser,
53
+ the API authority, and the SSR loader all import these same modules.
54
+
55
+ ## File map
56
+
57
+ | Path | What it is |
58
+ | --- | --- |
59
+ | `migrations/*.sql` | the real schema — the only place DDL lives |
60
+ | `shared/schema.gen.ts` | **generated** table schema — do not edit |
61
+ | `shared/app-def.ts` | the shared contract: relationships, query builder, predicted mutators |
62
+ | `src/components/*.queries.ts` | named queries + fragments, co-located with their components |
63
+ | `src/rindle-client.ts` | the one-call browser wire-up (`createRindleClient`) |
64
+ | `server/app-api.ts` | the authority: `registerQueries` + authoritative SQL mutators + policy |
65
+ | `src/routes/api.rindle.*.tsx` | TanStack Start server routes exposing the authority over HTTP |
66
+ | `server/dev.ts` | dev orchestration (`rindle up` + seed + Vite) |
67
+ | `src/ssr.ts` | SSR preload of the same named queries for first paint |
68
+
69
+ ## Reading more
70
+
71
+ Per-page markdown mirrors live at `https://rindle.sh/docs/<slug>.md`. Most
72
+ relevant here: `synced-app-quickstart`, `client`, `api-server`, `schema`,
73
+ `fragments`, `supported-queries-ts`, `change-model`.
@@ -12,10 +12,10 @@
12
12
  -- declared BOOLEAN/BOOL → boolean(), JSON → json(). Column ORDER matters — the IVM engine reads it
13
13
  -- back with PRAGMA table_info. `IF NOT EXISTS` keeps re-runs safe.
14
14
 
15
- CREATE TABLE IF NOT EXISTS room (id TEXT, name TEXT, createdAt REAL, PRIMARY KEY (id));
15
+ CREATE TABLE IF NOT EXISTS room (id TEXT NOT NULL, name TEXT NOT NULL, createdAt REAL NOT NULL, PRIMARY KEY (id));
16
16
  -- The room list orders newest-first.
17
17
  CREATE INDEX IF NOT EXISTS room_created ON room (createdAt DESC, id);
18
18
 
19
- CREATE TABLE IF NOT EXISTS message (id TEXT, roomId TEXT, author TEXT, body TEXT, createdAt REAL, PRIMARY KEY (id));
19
+ CREATE TABLE IF NOT EXISTS message (id TEXT NOT NULL, roomId TEXT NOT NULL, author TEXT NOT NULL, body TEXT NOT NULL, createdAt REAL NOT NULL, PRIMARY KEY (id));
20
20
  -- Forward (a room's messages, oldest-first) AND the reverse the live count uses when a message changes.
21
21
  CREATE INDEX IF NOT EXISTS message_room ON message (roomId, createdAt, id);
@@ -4,7 +4,8 @@
4
4
  // Column kinds come from your declared SQL types: TEXT maps to string(), INTEGER/REAL to
5
5
  // number(), and a column declared BOOLEAN/BOOL or JSON to boolean()/json(). The only thing the
6
6
  // SQL can't carry is a refinement *within* a kind — the element type of json<T>(), or a
7
- // string/number literal union — so re-apply those few annotations after each regen.
7
+ // string/number literal union — declare those ONCE in a hand-written module with
8
+ // refineTable(...) + refineSchema(...); they survive every regen of this file.
8
9
 
9
10
  import { createSchema, number, string, table } from "@rindle/client";
10
11
 
@@ -1,47 +1,19 @@
1
- // The SSR→SPA store handoff. Provides the Rindle store to the tree via `@rindle/react`, swapping the
2
- // backend under `useQuery` without changing a single component:
3
- //
4
- // - Server render + browser HYDRATION: a transport-less seed Store, hydrated from the SSR snapshot.
5
- // `useQuery` reads its seed, so the server and the client's first render produce identical markup
6
- // with NO engine on either side.
7
- // - After hydration: the wasm IVM engine boots (client-only dynamic import, rindle-client.ts), its
8
- // views are seeded from the same snapshot (so the swap shows the SSR rows with no flash), and the
9
- // live `subscribe` reconciles — the page is now a live SPA.
1
+ // The SSR→SPA store handoff. The handoff itself seed the render from the SSR snapshot, boot the
2
+ // wasm engine after hydration, swap the store under `useQuery` with no flash lives in `<RindleSSR>`
3
+ // (@rindle/react). This file just binds THIS app's `schema` + `bootClient` into it, so the route
4
+ // tree keeps rendering `<RindleApp ssrState={…}>` unchanged.
10
5
 
11
- import { useEffect, useRef, useState } from "react";
12
6
  import type { ReactNode } from "react";
13
- import { Rindle } from "@rindle/react";
14
- import { OneShotBackend, Store, type DehydratedState } from "@rindle/client";
7
+ import { RindleSSR } from "@rindle/react";
8
+ import type { DehydratedState } from "@rindle/client";
15
9
 
16
10
  import { schema } from "../shared/app-def.ts";
17
11
  import { bootClient } from "./rindle-client.ts";
18
12
 
19
- type RindleClient = Awaited<ReturnType<typeof bootClient>>;
20
-
21
13
  export function RindleApp({ ssrState, children }: { ssrState: DehydratedState; children: ReactNode }) {
22
- // Built ONCE from the first-render snapshot. Backs the server render AND the matching client
23
- // hydration pass — same seeds in, same markup out.
24
- const [seedStore] = useState(() => {
25
- const store = new Store(schema, new OneShotBackend());
26
- store.hydrate(ssrState);
27
- return store;
28
- });
29
-
30
- // The live wasm store — booted only in the browser, only after hydration.
31
- const [liveStore, setLiveStore] = useState<RindleClient["store"] | null>(null);
32
- const ssrStateRef = useRef(ssrState);
33
- ssrStateRef.current = ssrState;
34
- useEffect(() => {
35
- let alive = true;
36
- void bootClient().then((app) => {
37
- if (!alive) return;
38
- app.store.hydrate(ssrStateRef.current); // seed the live views so the swap doesn't flash empty
39
- setLiveStore(app.store);
40
- });
41
- return () => {
42
- alive = false;
43
- };
44
- }, []);
45
-
46
- return <Rindle store={liveStore ?? seedStore}>{children}</Rindle>;
14
+ return (
15
+ <RindleSSR schema={schema} ssrState={ssrState} boot={bootClient}>
16
+ {children}
17
+ </RindleSSR>
18
+ );
47
19
  }
@@ -70,6 +70,10 @@ async function bootClientInner() {
70
70
  },
71
71
  // 7601 = the daemon's PUBLIC ws port (7600 is the HTTP control plane).
72
72
  daemon: { wsUrl: import.meta.env.VITE_DAEMON_WS ?? "ws://127.0.0.1:7601" },
73
+ // `dev.resetOnMutationGap` postdates the published @rindle/optimistic (0.2.0); the spread-cast
74
+ // keeps this scaffold typechecking against the registry package — older clients ignore the key,
75
+ // newer ones honor it. Inline the option (and drop the cast) once a release includes it.
76
+ ...({ dev: { resetOnMutationGap: import.meta.env.DEV } } as object),
73
77
  onRejected: (envelope, reason) => rejectionHandler(envelope, reason),
74
78
  });
75
79
  }
@@ -36,16 +36,11 @@ const readInProcess: OneShotQueryFn = async ({ name, args }): Promise<OneShotRes
36
36
  };
37
37
 
38
38
  /** Preload the given NAMED queries through the authority and return the dehydrated first-paint cache to
39
- * embed in the HTML. Call from a route loader (server only). A failed read degrades to no seed for
40
- * that query (the live engine fills it in after hydration) rather than breaking the whole page. */
39
+ * embed in the HTML. Call from a route loader (server only). `preloadAll` degrades a failed read to no
40
+ * seed for that query (the live engine fills it in after hydration) rather than breaking the whole page. */
41
41
  export async function preloadRindle(queries: Array<Query<any, any, any>>): Promise<DehydratedState> {
42
- const server = createServerStore(schema, { query: readInProcess });
43
- await Promise.all(
44
- queries.map((query) =>
45
- server.preload(query).catch((err) => {
46
- console.error("[ssr] preload failed; rendering this query without its first-paint seed:", err instanceof Error ? err.message : err);
47
- }),
48
- ),
49
- );
50
- return server.dehydrate();
42
+ return createServerStore(schema, { query: readInProcess }).preloadAll(queries, {
43
+ onError: (_query, err) =>
44
+ console.error("[ssr] preload failed; rendering this query without its first-paint seed:", err instanceof Error ? err.message : err),
45
+ });
51
46
  }