create-rindle 0.1.6 → 0.3.1
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 +15 -1
- package/package.json +1 -1
- package/templates/minimal/AGENTS.md +73 -0
- package/templates/minimal/src/components/MessageCard.tsx +2 -0
- package/templates/minimal/src/components/RoomCard.tsx +2 -0
- package/templates/minimal/src/components/RoomView.queries.ts +3 -1
- package/templates/minimal/src/rindle-client.ts +4 -0
- package/templates/minimal/src/routes/index.tsx +4 -7
- package/templates/minimal/src/routes/r.$id.tsx +3 -6
package/README.md
CHANGED
|
@@ -22,7 +22,8 @@ pnpm dev
|
|
|
22
22
|
A minimal but real three-tier Rindle app (a tiny forum-of-rooms with **live message counts**):
|
|
23
23
|
|
|
24
24
|
- **Browser** — a TanStack Start SPA whose data layer is Rindle's in-process wasm IVM engine: local
|
|
25
|
-
instant reads, optimistic writes,
|
|
25
|
+
instant reads, optimistic writes, fragment-rooted `useRoot` reads, and a dev-only Rindle devtools
|
|
26
|
+
pane.
|
|
26
27
|
- **API server** — the authority: named-query resolution, authoritative SQL mutators, and policy (a
|
|
27
28
|
`"spam"` rejection demo shows the optimistic snap-back + toast).
|
|
28
29
|
- **Daemon** (`rindled`) — owns the SQLite data + live incremental views, streamed to subscribers.
|
|
@@ -34,6 +35,19 @@ the dev loop), so the TypeScript can't drift from the DDL.
|
|
|
34
35
|
The prebuilt `rindle` + `rindled` binaries come from `@rindle/cli` (per-platform, installed as a dev
|
|
35
36
|
dependency) — **no Rust toolchain required**.
|
|
36
37
|
|
|
38
|
+
## Fragment flow in the template
|
|
39
|
+
|
|
40
|
+
The generated routes use the current co-located fragment pattern:
|
|
41
|
+
|
|
42
|
+
- `src/components/*.queries.ts` defines each component's `defineFragment` beside the named
|
|
43
|
+
`defineQuery` that roots it.
|
|
44
|
+
- The home route preloads `roomsQuery()` for SSR, then calls
|
|
45
|
+
`useRoot(roomsQuery, RoomCardFragment)` to receive opaque room refs.
|
|
46
|
+
- Row components call `useFragment(RoomCardFragment, room)` to open narrow local reads without a
|
|
47
|
+
new server subscription.
|
|
48
|
+
- The room detail route calls `useRoot(roomDetailQuery, id)` when the route itself owns the root
|
|
49
|
+
fields and passes child message refs down with `fragmentKey(ref)` list keys.
|
|
50
|
+
|
|
37
51
|
## Usage
|
|
38
52
|
|
|
39
53
|
```
|
package/package.json
CHANGED
|
@@ -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`.
|
|
@@ -8,6 +8,8 @@ import { formatDateTime } from "../lib/format.ts";
|
|
|
8
8
|
|
|
9
9
|
export function MessageCard({ message }: { message: MessageCardRef }) {
|
|
10
10
|
const data = useFragment(MessageCardFragment, message);
|
|
11
|
+
if (!data) return null;
|
|
12
|
+
|
|
11
13
|
return (
|
|
12
14
|
<article className="app-message">
|
|
13
15
|
<div className="app-message-head">
|
|
@@ -9,6 +9,8 @@ import type { RoomCardRef } from "./RoomCard.queries.ts";
|
|
|
9
9
|
|
|
10
10
|
export function RoomCard({ room }: { room: RoomCardRef }) {
|
|
11
11
|
const data = useFragment(RoomCardFragment, room);
|
|
12
|
+
if (!data) return null;
|
|
13
|
+
|
|
12
14
|
return (
|
|
13
15
|
<li className="app-room">
|
|
14
16
|
<Link to="/r/$id" params={{ id: data.id }} className="app-room-link">
|
|
@@ -13,7 +13,9 @@ import { MessageCardFragment } from "./MessageCard.queries.ts";
|
|
|
13
13
|
export const RoomDetailFragment = defineFragment(room, (r) =>
|
|
14
14
|
r
|
|
15
15
|
.select("id", "name", "createdAt")
|
|
16
|
-
.sub("messages", rels.roomMessages, (m) =>
|
|
16
|
+
.sub("messages", rels.roomMessages, MessageCardFragment, (m) =>
|
|
17
|
+
m.orderBy("createdAt", "asc").orderBy("id", "asc")
|
|
18
|
+
),
|
|
17
19
|
);
|
|
18
20
|
export type RoomDetailRef = FragmentRef<typeof RoomDetailFragment>;
|
|
19
21
|
|
|
@@ -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
|
}
|
|
@@ -2,12 +2,11 @@
|
|
|
2
2
|
// room. Its loader seeds the rooms query for first paint (SSR); after hydration the wasm engine owns
|
|
3
3
|
// the live read — post a message in any room and its count here updates with no polling.
|
|
4
4
|
|
|
5
|
-
import { useMemo } from "react";
|
|
6
5
|
import { createFileRoute } from "@tanstack/react-router";
|
|
7
|
-
import {
|
|
6
|
+
import { fragmentKey, useRoot } from "@rindle/react";
|
|
8
7
|
import type { DehydratedState } from "@rindle/client";
|
|
9
8
|
|
|
10
|
-
import { roomsQuery } from "../components/RoomCard.queries.ts";
|
|
9
|
+
import { RoomCardFragment, roomsQuery } from "../components/RoomCard.queries.ts";
|
|
11
10
|
import { RoomCard } from "../components/RoomCard.tsx";
|
|
12
11
|
import { NewRoomForm } from "../components/NewRoomForm.tsx";
|
|
13
12
|
|
|
@@ -23,9 +22,7 @@ export const Route = createFileRoute("/")({
|
|
|
23
22
|
});
|
|
24
23
|
|
|
25
24
|
function Home() {
|
|
26
|
-
const
|
|
27
|
-
const rooms = useQuery(query);
|
|
28
|
-
const status = useQueryStatus(query);
|
|
25
|
+
const [rooms, { status }] = useRoot(roomsQuery, RoomCardFragment);
|
|
29
26
|
const loading = status !== "complete" && rooms.length === 0;
|
|
30
27
|
|
|
31
28
|
return (
|
|
@@ -42,7 +39,7 @@ function Home() {
|
|
|
42
39
|
) : (
|
|
43
40
|
<ul className="app-rooms">
|
|
44
41
|
{rooms.map((room) => (
|
|
45
|
-
<RoomCard key={room
|
|
42
|
+
<RoomCard key={fragmentKey(room)} room={room} />
|
|
46
43
|
))}
|
|
47
44
|
</ul>
|
|
48
45
|
)}
|
|
@@ -2,9 +2,8 @@
|
|
|
2
2
|
// single detail query is seeded by the loader for first paint; after hydration the wasm engine owns
|
|
3
3
|
// the live read and every new message streams in.
|
|
4
4
|
|
|
5
|
-
import { useMemo } from "react";
|
|
6
5
|
import { Link, createFileRoute } from "@tanstack/react-router";
|
|
7
|
-
import {
|
|
6
|
+
import { fragmentKey, useRoot } from "@rindle/react";
|
|
8
7
|
import type { DehydratedState } from "@rindle/client";
|
|
9
8
|
|
|
10
9
|
import { roomDetailQuery } from "../components/RoomView.queries.ts";
|
|
@@ -22,9 +21,7 @@ export const Route = createFileRoute("/r/$id")({
|
|
|
22
21
|
|
|
23
22
|
function RoomView() {
|
|
24
23
|
const { id } = Route.useParams();
|
|
25
|
-
const
|
|
26
|
-
const room = useQuery(query);
|
|
27
|
-
const status = useQueryStatus(query);
|
|
24
|
+
const [room, { status }] = useRoot(roomDetailQuery, id);
|
|
28
25
|
|
|
29
26
|
if (!room) {
|
|
30
27
|
return (
|
|
@@ -50,7 +47,7 @@ function RoomView() {
|
|
|
50
47
|
{messages.length === 0 ? (
|
|
51
48
|
<p className="app-empty">No messages yet — say something below.</p>
|
|
52
49
|
) : (
|
|
53
|
-
messages.map((message) => <MessageCard key={message
|
|
50
|
+
messages.map((message) => <MessageCard key={fragmentKey(message)} message={message} />)
|
|
54
51
|
)}
|
|
55
52
|
</div>
|
|
56
53
|
|