create-rindle 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +70 -0
- package/index.mjs +124 -0
- package/lib/scaffold.mjs +169 -0
- package/package.json +38 -0
- package/templates/minimal/README.md +72 -0
- package/templates/minimal/_gitignore +7 -0
- package/templates/minimal/migrations/0001_init.sql +21 -0
- package/templates/minimal/package.json +44 -0
- package/templates/minimal/server/app-api.ts +115 -0
- package/templates/minimal/server/auth-dev.ts +22 -0
- package/templates/minimal/server/dev.ts +148 -0
- package/templates/minimal/server/migrate.ts +47 -0
- package/templates/minimal/server/rindle-http.ts +38 -0
- package/templates/minimal/server/seed.ts +70 -0
- package/templates/minimal/shared/app-def.ts +100 -0
- package/templates/minimal/shared/auth.ts +18 -0
- package/templates/minimal/shared/schema.gen.ts +29 -0
- package/templates/minimal/src/RindleApp.tsx +47 -0
- package/templates/minimal/src/components/Composer.tsx +38 -0
- package/templates/minimal/src/components/MessageCard.queries.ts +13 -0
- package/templates/minimal/src/components/MessageCard.tsx +20 -0
- package/templates/minimal/src/components/NewRoomForm.tsx +30 -0
- package/templates/minimal/src/components/RoomCard.queries.ts +20 -0
- package/templates/minimal/src/components/RoomCard.tsx +23 -0
- package/templates/minimal/src/components/RoomView.queries.ts +26 -0
- package/templates/minimal/src/components/Toaster.tsx +31 -0
- package/templates/minimal/src/components/TopBar.tsx +36 -0
- package/templates/minimal/src/devtools.tsx +27 -0
- package/templates/minimal/src/lib/format.ts +8 -0
- package/templates/minimal/src/lib/use-handle.ts +16 -0
- package/templates/minimal/src/rindle-client.ts +94 -0
- package/templates/minimal/src/routeTree.gen.ts +147 -0
- package/templates/minimal/src/router.tsx +20 -0
- package/templates/minimal/src/routes/__root.tsx +64 -0
- package/templates/minimal/src/routes/api.rindle.mutate.tsx +16 -0
- package/templates/minimal/src/routes/api.rindle.query.tsx +16 -0
- package/templates/minimal/src/routes/api.rindle.read.tsx +16 -0
- package/templates/minimal/src/routes/index.tsx +51 -0
- package/templates/minimal/src/routes/r.$id.tsx +60 -0
- package/templates/minimal/src/ssr.ts +51 -0
- package/templates/minimal/src/styles.css +220 -0
- package/templates/minimal/src/tanstack-start.d.ts +6 -0
- package/templates/minimal/tsconfig.json +17 -0
- package/templates/minimal/vite-env.d.ts +15 -0
- package/templates/minimal/vite.config.ts +29 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// The shared CONTRACT root: the normalized schema, the relationships the views join back over, the
|
|
2
|
+
// normalization helpers, the mutator arg schemas, and the client's PREDICTED mutators. Both tiers
|
|
3
|
+
// import it — the browser for everything, the API server for the schema + normalization + the
|
|
4
|
+
// AUTHORITATIVE mutator twins (server/app-api.ts) with the same names.
|
|
5
|
+
//
|
|
6
|
+
// This module is the leaf of the contract DAG: it depends on nothing app-internal. The named root
|
|
7
|
+
// queries AND the per-component SELECTIONS they compose are co-located with their components in
|
|
8
|
+
// `src/components/*.queries.ts` (Relay-style co-location). Keeping the schema here, free of those
|
|
9
|
+
// imports, is what keeps that graph acyclic.
|
|
10
|
+
|
|
11
|
+
import { defineRelationships, newQueryBuilder, rel } from "@rindle/client";
|
|
12
|
+
import type { Row } from "@rindle/client";
|
|
13
|
+
import type { ClientRegistry, MutationTx } from "@rindle/optimistic";
|
|
14
|
+
import { z } from "zod";
|
|
15
|
+
|
|
16
|
+
// The schema is GENERATED from migrations/*.sql into ./schema.gen.ts by `rindle schema gen` — `pnpm
|
|
17
|
+
// dev` regenerates it on every migration change, so the DDL is the single source of truth. We import
|
|
18
|
+
// the tables + `schema` here and re-export them below, keeping this contract root the one import for
|
|
19
|
+
// app code.
|
|
20
|
+
import { message, room, schema } from "./schema.gen.ts";
|
|
21
|
+
|
|
22
|
+
// --------------------------------------------------------------------------- tables (generated)
|
|
23
|
+
|
|
24
|
+
export { message, room, schema };
|
|
25
|
+
|
|
26
|
+
/** One schema-bound query builder, shared by every co-located `*.queries.ts`. Each `q.<table>` access
|
|
27
|
+
* mints a fresh builder, so sharing the single instance is safe. */
|
|
28
|
+
export const q = newQueryBuilder(schema);
|
|
29
|
+
|
|
30
|
+
// --------------------------------------------------------------- row types (schema-derived)
|
|
31
|
+
|
|
32
|
+
export type Room = Row<typeof room>;
|
|
33
|
+
export type Message = Row<typeof message>;
|
|
34
|
+
|
|
35
|
+
// --------------------------------------------------------------- relationships (joins, declared once)
|
|
36
|
+
//
|
|
37
|
+
// The one join in this app, declared ONCE: a room's messages. The home page's live `countAs` and the
|
|
38
|
+
// room view both spread it instead of restating the `roomId → id` correlation.
|
|
39
|
+
export const rels = defineRelationships({
|
|
40
|
+
roomMessages: rel(room, message, { id: "roomId" }),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// --------------------------------------------------------------------------- normalization
|
|
44
|
+
|
|
45
|
+
export function normalizeName(name: string): string {
|
|
46
|
+
return name.trim().replace(/\s+/g, " ").slice(0, 80);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function normalizeBody(body: string): string {
|
|
50
|
+
return body.trim().slice(0, 4000);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** A login handle reduced to a stable slug, so the client's predicted author id matches the server's. */
|
|
54
|
+
export function normalizeSubject(raw: string): string {
|
|
55
|
+
return raw.trim().replace(/\s+/g, "-").toLowerCase().slice(0, 40) || "anon";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// --------------------------------------------------------------------------- mutator args
|
|
59
|
+
//
|
|
60
|
+
// One zod schema per mutator. The SERVER parses the UNTRUSTED wire args through it (server/app-api.ts);
|
|
61
|
+
// BOTH tiers derive the arg TYPE from it (`z.infer`). `author` rides the args for the CLIENT's
|
|
62
|
+
// prediction only — the server ALWAYS overrides it with the authenticated identity, so it is
|
|
63
|
+
// unspoofable for the authoritative write.
|
|
64
|
+
|
|
65
|
+
export const createRoomArgs = z.object({ id: z.string(), name: z.string(), createdAt: z.number() });
|
|
66
|
+
export type CreateRoomArgs = z.infer<typeof createRoomArgs>;
|
|
67
|
+
|
|
68
|
+
export const postMessageArgs = z.object({
|
|
69
|
+
id: z.string(),
|
|
70
|
+
roomId: z.string(),
|
|
71
|
+
body: z.string(),
|
|
72
|
+
author: z.string(),
|
|
73
|
+
createdAt: z.number(),
|
|
74
|
+
});
|
|
75
|
+
export type PostMessageArgs = z.infer<typeof postMessageArgs>;
|
|
76
|
+
|
|
77
|
+
// --------------------------------------------------------------------------- mutators (PREDICTED)
|
|
78
|
+
//
|
|
79
|
+
// Deterministic + replayable: every value that would otherwise come from the clock or a random source
|
|
80
|
+
// is passed in args (ids, timestamps). The SQL twins in server/app-api.ts enforce the authoritative
|
|
81
|
+
// policy (identity required, a "spam" rejection demo).
|
|
82
|
+
|
|
83
|
+
export const mutators = {
|
|
84
|
+
createRoom: (tx: MutationTx, a: CreateRoomArgs) => {
|
|
85
|
+
const name = normalizeName(a.name);
|
|
86
|
+
if (!name) return;
|
|
87
|
+
tx.insert("room", { id: a.id, name, createdAt: a.createdAt });
|
|
88
|
+
},
|
|
89
|
+
postMessage: (tx: MutationTx, a: PostMessageArgs) => {
|
|
90
|
+
const body = normalizeBody(a.body);
|
|
91
|
+
if (!body) return;
|
|
92
|
+
tx.insert("message", {
|
|
93
|
+
id: a.id,
|
|
94
|
+
roomId: a.roomId,
|
|
95
|
+
author: normalizeSubject(a.author),
|
|
96
|
+
body,
|
|
97
|
+
createdAt: a.createdAt,
|
|
98
|
+
});
|
|
99
|
+
},
|
|
100
|
+
} satisfies ClientRegistry;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// The identity SEAM — the whole contract the app depends on for "who is this?". The app owns no
|
|
2
|
+
// account lifecycle: it trusts a verified identity resolved from the inbound request and nothing more.
|
|
3
|
+
//
|
|
4
|
+
// - This template wires the DEV provider (server/auth-dev.ts): a handle off an `x-rindle-user`
|
|
5
|
+
// header, so the example runs standalone with no auth service.
|
|
6
|
+
// - To go to production, write another AuthProvider that validates a real credential (e.g. a JWT
|
|
7
|
+
// against a JWKS) and maps it to the SAME Identity shape — the API server never changes.
|
|
8
|
+
|
|
9
|
+
/** A verified identity. `subject` is the stable external id used as the author of writes. */
|
|
10
|
+
export interface Identity {
|
|
11
|
+
subject: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Resolve the inbound request's credential to a verified identity, or null when anonymous. Reads are
|
|
15
|
+
* public; only mutations require a non-null identity (server/app-api.ts). */
|
|
16
|
+
export interface AuthProvider {
|
|
17
|
+
verify(req: Request): Promise<Identity | null>;
|
|
18
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Generated by `rindle schema gen` from the daemon's introspected schema (GET /schema).
|
|
2
|
+
// Do not edit by hand — re-run the generator after each migration.
|
|
3
|
+
//
|
|
4
|
+
// Column kinds come from your declared SQL types: TEXT maps to string(), INTEGER/REAL to
|
|
5
|
+
// number(), and a column declared BOOLEAN/BOOL or JSON to boolean()/json(). The only thing the
|
|
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.
|
|
8
|
+
|
|
9
|
+
import { createSchema, number, string, table } from "@rindle/client";
|
|
10
|
+
|
|
11
|
+
export const message = table("message")
|
|
12
|
+
.columns({
|
|
13
|
+
id: string(),
|
|
14
|
+
roomId: string(),
|
|
15
|
+
author: string(),
|
|
16
|
+
body: string(),
|
|
17
|
+
createdAt: number(),
|
|
18
|
+
})
|
|
19
|
+
.primaryKey("id");
|
|
20
|
+
|
|
21
|
+
export const room = table("room")
|
|
22
|
+
.columns({
|
|
23
|
+
id: string(),
|
|
24
|
+
name: string(),
|
|
25
|
+
createdAt: number(),
|
|
26
|
+
})
|
|
27
|
+
.primaryKey("id");
|
|
28
|
+
|
|
29
|
+
export const schema = createSchema({ tables: [message, room] });
|
|
@@ -0,0 +1,47 @@
|
|
|
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.
|
|
10
|
+
|
|
11
|
+
import { useEffect, useRef, useState } from "react";
|
|
12
|
+
import type { ReactNode } from "react";
|
|
13
|
+
import { Rindle } from "@rindle/react";
|
|
14
|
+
import { OneShotBackend, Store, type DehydratedState } from "@rindle/client";
|
|
15
|
+
|
|
16
|
+
import { schema } from "../shared/app-def.ts";
|
|
17
|
+
import { bootClient } from "./rindle-client.ts";
|
|
18
|
+
|
|
19
|
+
type RindleClient = Awaited<ReturnType<typeof bootClient>>;
|
|
20
|
+
|
|
21
|
+
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>;
|
|
47
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// The message box at the foot of a room. Submitting fires `app.mutate.postMessage`, which applies
|
|
2
|
+
// OPTIMISTICALLY in the local wasm engine (the message shows instantly) and reconciles when the
|
|
3
|
+
// authority confirms — or snaps back via onRejected (e.g. a body containing "spam").
|
|
4
|
+
|
|
5
|
+
import { useState } from "react";
|
|
6
|
+
import type { FormEvent } from "react";
|
|
7
|
+
|
|
8
|
+
import { app, currentHandle } from "../rindle-client.ts";
|
|
9
|
+
|
|
10
|
+
export function Composer({ roomId }: { roomId: string }) {
|
|
11
|
+
const [body, setBody] = useState("");
|
|
12
|
+
|
|
13
|
+
function submit(e: FormEvent) {
|
|
14
|
+
e.preventDefault();
|
|
15
|
+
const text = body.trim();
|
|
16
|
+
if (!text) return;
|
|
17
|
+
const now = Date.now();
|
|
18
|
+
app.mutate.postMessage({
|
|
19
|
+
id: `msg-${now}-${Math.random().toString(36).slice(2, 7)}`,
|
|
20
|
+
roomId,
|
|
21
|
+
body: text,
|
|
22
|
+
author: currentHandle(),
|
|
23
|
+
createdAt: now,
|
|
24
|
+
});
|
|
25
|
+
setBody("");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<form className="app-composer" onSubmit={submit}>
|
|
30
|
+
<textarea value={body} onChange={(e) => setBody(e.target.value)} placeholder="Write a message…" rows={3} />
|
|
31
|
+
<div className="app-composer-actions">
|
|
32
|
+
<button type="submit" className="app-btn" disabled={!body.trim()}>
|
|
33
|
+
Send
|
|
34
|
+
</button>
|
|
35
|
+
</div>
|
|
36
|
+
</form>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Co-located with the message row in the room view. Holds just the per-row SELECTION
|
|
2
|
+
// (`MessageCardFragment`); the room view composes it into the room detail query. React-free.
|
|
3
|
+
|
|
4
|
+
import { defineFragment } from "@rindle/client";
|
|
5
|
+
import type { FragmentRef } from "@rindle/client";
|
|
6
|
+
|
|
7
|
+
import { message } from "../../shared/app-def.ts";
|
|
8
|
+
|
|
9
|
+
/** What a single message row renders. */
|
|
10
|
+
export const MessageCardFragment = defineFragment(message, (m) =>
|
|
11
|
+
m.select("id", "author", "body", "createdAt"),
|
|
12
|
+
);
|
|
13
|
+
export type MessageCardRef = FragmentRef<typeof MessageCardFragment>;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// A single message in a room: author, body, and time. Reads its data through the `MessageCard` fragment.
|
|
2
|
+
|
|
3
|
+
import { useFragment } from "@rindle/react";
|
|
4
|
+
|
|
5
|
+
import { MessageCardFragment } from "./MessageCard.queries.ts";
|
|
6
|
+
import type { MessageCardRef } from "./MessageCard.queries.ts";
|
|
7
|
+
import { formatDateTime } from "../lib/format.ts";
|
|
8
|
+
|
|
9
|
+
export function MessageCard({ message }: { message: MessageCardRef }) {
|
|
10
|
+
const data = useFragment(MessageCardFragment, message);
|
|
11
|
+
return (
|
|
12
|
+
<article className="app-message">
|
|
13
|
+
<div className="app-message-head">
|
|
14
|
+
<span className="app-message-author">{data.author}</span>
|
|
15
|
+
<span className="app-message-time">{formatDateTime(data.createdAt)}</span>
|
|
16
|
+
</div>
|
|
17
|
+
<p className="app-message-body">{data.body}</p>
|
|
18
|
+
</article>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Create a room from the home page. `app.mutate.createRoom` applies optimistically (the room appears
|
|
2
|
+
// instantly) and reconciles on confirmation — try the name "spam" to see the authority reject it and
|
|
3
|
+
// the optimistic row snap back with a toast.
|
|
4
|
+
|
|
5
|
+
import { useState } from "react";
|
|
6
|
+
import type { FormEvent } from "react";
|
|
7
|
+
|
|
8
|
+
import { app } from "../rindle-client.ts";
|
|
9
|
+
|
|
10
|
+
export function NewRoomForm() {
|
|
11
|
+
const [name, setName] = useState("");
|
|
12
|
+
|
|
13
|
+
function submit(e: FormEvent) {
|
|
14
|
+
e.preventDefault();
|
|
15
|
+
const text = name.trim();
|
|
16
|
+
if (!text) return;
|
|
17
|
+
const now = Date.now();
|
|
18
|
+
app.mutate.createRoom({ id: `room-${now}-${Math.random().toString(36).slice(2, 7)}`, name: text, createdAt: now });
|
|
19
|
+
setName("");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<form className="app-new-room" onSubmit={submit}>
|
|
24
|
+
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="New room name…" maxLength={80} />
|
|
25
|
+
<button type="submit" className="app-btn" disabled={!name.trim()}>
|
|
26
|
+
Create room
|
|
27
|
+
</button>
|
|
28
|
+
</form>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Co-located with the room card on the home page. Holds the per-card SELECTION (`RoomCardFragment`)
|
|
2
|
+
// AND the named root query over the room table (`roomsQuery`). React-free, so the API server can
|
|
3
|
+
// import it for query resolution without pulling in React.
|
|
4
|
+
|
|
5
|
+
import { defineFragment, defineQuery } from "@rindle/client";
|
|
6
|
+
import type { FragmentRef } from "@rindle/client";
|
|
7
|
+
|
|
8
|
+
import { q, rels, room } from "../../shared/app-def.ts";
|
|
9
|
+
|
|
10
|
+
/** A room row + a LIVE count of its messages (a scalar `countAs` over the whole `message` table —
|
|
11
|
+
* maintained incrementally by the engine, never polled). */
|
|
12
|
+
export const RoomCardFragment = defineFragment(room, (r) =>
|
|
13
|
+
r.select("id", "name", "createdAt").countAs("messageCount", rels.roomMessages),
|
|
14
|
+
);
|
|
15
|
+
export type RoomCardRef = FragmentRef<typeof RoomCardFragment>;
|
|
16
|
+
|
|
17
|
+
/** Every room, newest first. No args, so no validator. */
|
|
18
|
+
export const roomsQuery = defineQuery("rooms", () =>
|
|
19
|
+
q.room.orderBy("createdAt", "desc").orderBy("id", "asc").include(RoomCardFragment),
|
|
20
|
+
);
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// A single room on the home page: its name and a LIVE message count (the `countAs` stays exact as
|
|
2
|
+
// messages are posted — no polling). Reads its data through the `RoomCard` fragment.
|
|
3
|
+
|
|
4
|
+
import { Link } from "@tanstack/react-router";
|
|
5
|
+
import { useFragment } from "@rindle/react";
|
|
6
|
+
|
|
7
|
+
import { RoomCardFragment } from "./RoomCard.queries.ts";
|
|
8
|
+
import type { RoomCardRef } from "./RoomCard.queries.ts";
|
|
9
|
+
|
|
10
|
+
export function RoomCard({ room }: { room: RoomCardRef }) {
|
|
11
|
+
const data = useFragment(RoomCardFragment, room);
|
|
12
|
+
return (
|
|
13
|
+
<li className="app-room">
|
|
14
|
+
<Link to="/r/$id" params={{ id: data.id }} className="app-room-link">
|
|
15
|
+
<span className="app-room-name">{data.name}</span>
|
|
16
|
+
<span className="app-count" title={`${data.messageCount} messages`}>
|
|
17
|
+
{data.messageCount}
|
|
18
|
+
<small>messages</small>
|
|
19
|
+
</span>
|
|
20
|
+
</Link>
|
|
21
|
+
</li>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Co-located with the room-detail view. Holds the detail SELECTION (`RoomDetailFragment`) AND the
|
|
2
|
+
// named root query that fetches it on demand (`roomDetailQuery`). React-free.
|
|
3
|
+
|
|
4
|
+
import { defineFragment, defineQuery } from "@rindle/client";
|
|
5
|
+
import type { FragmentRef } from "@rindle/client";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
|
|
8
|
+
import { q, rels, room } from "../../shared/app-def.ts";
|
|
9
|
+
import { MessageCardFragment } from "./MessageCard.queries.ts";
|
|
10
|
+
|
|
11
|
+
/** What the ROOM VIEW renders: the room chrome plus its WHOLE message thread `sub`'d in oldest-first,
|
|
12
|
+
* each message via the shared `MessageCardFragment`. To cap the thread you'd add `.limit(n)` here. */
|
|
13
|
+
export const RoomDetailFragment = defineFragment(room, (r) =>
|
|
14
|
+
r
|
|
15
|
+
.select("id", "name", "createdAt")
|
|
16
|
+
.sub("messages", rels.roomMessages, (m) => m.include(MessageCardFragment).orderBy("createdAt", "asc").orderBy("id", "asc")),
|
|
17
|
+
);
|
|
18
|
+
export type RoomDetailRef = FragmentRef<typeof RoomDetailFragment>;
|
|
19
|
+
|
|
20
|
+
/** The room view's only arg: a room id. */
|
|
21
|
+
const roomIdArgs = z.string().max(80);
|
|
22
|
+
|
|
23
|
+
/** A single room by id with its WHOLE message thread (`RoomDetailFragment`). */
|
|
24
|
+
export const roomDetailQuery = defineQuery("roomDetail", (raw) => roomIdArgs.parse(raw), (id) =>
|
|
25
|
+
q.room.where.id(id).include(RoomDetailFragment).one(),
|
|
26
|
+
);
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Surfaces authority REJECTIONS as transient toasts. When a mutation is rejected (e.g. a "spam" room
|
|
2
|
+
// name or message), the optimistic layer snaps back AND `onRejected` fires — we show why.
|
|
3
|
+
|
|
4
|
+
import { useEffect, useState } from "react";
|
|
5
|
+
|
|
6
|
+
import { onRejection } from "../rindle-client.ts";
|
|
7
|
+
|
|
8
|
+
interface Toast {
|
|
9
|
+
id: number;
|
|
10
|
+
message: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function Toaster() {
|
|
14
|
+
const [toasts, setToasts] = useState<Toast[]>([]);
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
return onRejection((_envelope, reason) => {
|
|
17
|
+
const id = Date.now() + Math.random();
|
|
18
|
+
setToasts((t) => [...t, { id, message: reason }]);
|
|
19
|
+
setTimeout(() => setToasts((t) => t.filter((x) => x.id !== id)), 4000);
|
|
20
|
+
});
|
|
21
|
+
}, []);
|
|
22
|
+
return (
|
|
23
|
+
<div className="app-toaster" role="status" aria-live="polite">
|
|
24
|
+
{toasts.map((t) => (
|
|
25
|
+
<div key={t.id} className="app-toast">
|
|
26
|
+
{t.message}
|
|
27
|
+
</div>
|
|
28
|
+
))}
|
|
29
|
+
</div>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// The persistent top bar: the app wordmark + the dev identity control. "Who you are" is a handle
|
|
2
|
+
// persisted per browser; "Switch user" rewrites it. A real app would show the signed-in account from
|
|
3
|
+
// a verified token instead (see shared/auth.ts).
|
|
4
|
+
|
|
5
|
+
import { Link } from "@tanstack/react-router";
|
|
6
|
+
|
|
7
|
+
import { useCurrentHandle } from "../lib/use-handle.ts";
|
|
8
|
+
import { currentHandle, setCurrentHandle } from "../rindle-client.ts";
|
|
9
|
+
|
|
10
|
+
export function TopBar() {
|
|
11
|
+
const me = useCurrentHandle();
|
|
12
|
+
|
|
13
|
+
function switchUser() {
|
|
14
|
+
const next = prompt("Switch dev user (handle):", currentHandle());
|
|
15
|
+
if (next && next.trim()) {
|
|
16
|
+
setCurrentHandle(next.trim());
|
|
17
|
+
location.reload();
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<header className="app-topbar">
|
|
23
|
+
<Link to="/" className="app-wordmark">
|
|
24
|
+
rindle<span>starter</span>
|
|
25
|
+
</Link>
|
|
26
|
+
<div className="app-topbar-right">
|
|
27
|
+
<span className="app-whoami" title="Your dev identity">
|
|
28
|
+
signed in as <b>{me}</b>
|
|
29
|
+
</span>
|
|
30
|
+
<button type="button" className="app-btn app-btn-ghost" onClick={switchUser}>
|
|
31
|
+
Switch user
|
|
32
|
+
</button>
|
|
33
|
+
</div>
|
|
34
|
+
</header>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// The in-browser Rindle devtools pane (mutation timeline / queries inspector / delta stream), mounted
|
|
2
|
+
// ONLY in development. `import.meta.env.DEV` is a STATIC `false` in a production build, so Vite/Rollup
|
|
3
|
+
// fold the ternary to `null` and dead-code-eliminate the dynamic `import("@rindle/react-devtools")` —
|
|
4
|
+
// neither @rindle/react-devtools nor @rindle/devtools end up in the prod bundle. The client is attached
|
|
5
|
+
// to the pane in src/rindle-client.ts (also dev-gated); this file is just the UI mount.
|
|
6
|
+
|
|
7
|
+
import { lazy, Suspense, useEffect, useState } from "react";
|
|
8
|
+
|
|
9
|
+
// In production this is `null` (and the import is tree-shaken); in dev it lazily loads the panel chunk,
|
|
10
|
+
// so the devtools code is fetched only when the app actually runs in development.
|
|
11
|
+
const Panel = import.meta.env.DEV
|
|
12
|
+
? lazy(() => import("@rindle/react-devtools").then((m) => ({ default: m.RindleDevtools })))
|
|
13
|
+
: null;
|
|
14
|
+
|
|
15
|
+
export function DevTools() {
|
|
16
|
+
// Client-only: don't render (or fetch) the pane during the SSR/prerender shell pass — it has no value
|
|
17
|
+
// there. Mounting after the first client effect also keeps the server and hydration markup identical
|
|
18
|
+
// (both render nothing here), so there is never a hydration mismatch.
|
|
19
|
+
const [mounted, setMounted] = useState(false);
|
|
20
|
+
useEffect(() => setMounted(true), []);
|
|
21
|
+
if (!Panel || !mounted) return null;
|
|
22
|
+
return (
|
|
23
|
+
<Suspense fallback={null}>
|
|
24
|
+
<Panel />
|
|
25
|
+
</Suspense>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Tiny date formatting helper, shared by the views. Pure + SSR-safe (same output on server and client
|
|
2
|
+
// for a given input, so hydration matches).
|
|
3
|
+
|
|
4
|
+
const DATE_TIME = new Intl.DateTimeFormat("en", { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
|
|
5
|
+
|
|
6
|
+
export function formatDateTime(ms: number): string {
|
|
7
|
+
return DATE_TIME.format(new Date(ms));
|
|
8
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Hydration-safe hook for "who am I (this browser)?". The server renders under the SSR placeholder and
|
|
2
|
+
// so does the client's FIRST render (so the markup byte-matches); after mount it adopts the real
|
|
3
|
+
// persisted handle. Personalized UI reads this so it never causes a hydration mismatch.
|
|
4
|
+
|
|
5
|
+
import { useEffect, useState } from "react";
|
|
6
|
+
|
|
7
|
+
import { currentHandle, SSR_USER } from "../rindle-client.ts";
|
|
8
|
+
|
|
9
|
+
/** The current browser's handle — `SSR_USER` until mounted, then the persisted dev handle. */
|
|
10
|
+
export function useCurrentHandle(): string {
|
|
11
|
+
const [handle, setHandle] = useState(SSR_USER);
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
setHandle(currentHandle());
|
|
14
|
+
}, []);
|
|
15
|
+
return handle;
|
|
16
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// The whole client wire-up is ONE call — but DEFERRED to the browser. The optimistic engine is wasm
|
|
2
|
+
// (an in-process IVM engine), so it must never be constructed during the SSR/prerender shell pass.
|
|
3
|
+
// `bootClient()` lazily imports the engine + optimistic glue the first time it runs on the client and
|
|
4
|
+
// memoizes the promise; `app` is a live binding assigned once boot resolves, so the components that
|
|
5
|
+
// fire mutations (`app.mutate.*`) read the ready client at call time.
|
|
6
|
+
//
|
|
7
|
+
// Queries materialize through the API server (subscribed on the daemon's public ws); mutations flush
|
|
8
|
+
// through the client-side queue as confirmed in-order batches; rejections surface via onRejected.
|
|
9
|
+
|
|
10
|
+
import type { MutationEnvelope } from "@rindle/client";
|
|
11
|
+
|
|
12
|
+
import wasmUrl from "rindle-wasm-bin?url";
|
|
13
|
+
|
|
14
|
+
import { mutators, schema } from "../shared/app-def.ts";
|
|
15
|
+
|
|
16
|
+
// The precise client type — including the typed `mutate.*` surface — is INFERRED from the concrete
|
|
17
|
+
// `createRindleClient({ schema, mutators, … })` call in `bootClientInner`.
|
|
18
|
+
type RindleApp = Awaited<ReturnType<typeof bootClientInner>>;
|
|
19
|
+
type RejectionHandler = (envelope: MutationEnvelope, reason: string) => void;
|
|
20
|
+
|
|
21
|
+
/** The placeholder identity used before this browser's real persisted handle is known — i.e. during
|
|
22
|
+
* SSR (no `localStorage`) and the first hydration render (which must byte-match the server). */
|
|
23
|
+
export const SSR_USER = "ssr";
|
|
24
|
+
|
|
25
|
+
/** The dev "login": a handle persisted per browser. A real app puts a verified token in `api.headers`
|
|
26
|
+
* instead. SSR-safe: returns {@link SSR_USER} when there is no `localStorage` (the server render). */
|
|
27
|
+
export function currentHandle(): string {
|
|
28
|
+
if (typeof localStorage === "undefined") return SSR_USER;
|
|
29
|
+
let handle = localStorage.getItem("rindle-user");
|
|
30
|
+
if (!handle) {
|
|
31
|
+
handle = `user-${Math.random().toString(36).slice(2, 7)}`;
|
|
32
|
+
localStorage.setItem("rindle-user", handle);
|
|
33
|
+
}
|
|
34
|
+
return handle;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function setCurrentHandle(handle: string): void {
|
|
38
|
+
if (typeof localStorage === "undefined") return;
|
|
39
|
+
localStorage.setItem("rindle-user", handle);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let rejectionHandler: RejectionHandler = () => {};
|
|
43
|
+
export function onRejection(handler: RejectionHandler): () => void {
|
|
44
|
+
rejectionHandler = handler;
|
|
45
|
+
return () => {
|
|
46
|
+
if (rejectionHandler === handler) rejectionHandler = () => {};
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The live optimistic client — assigned once {@link bootClient} resolves. Components import this and
|
|
51
|
+
* call `app.mutate.*` inside event handlers, by which point boot has completed (the provider in
|
|
52
|
+
* src/RindleApp.tsx gates the whole tree on it). */
|
|
53
|
+
export let app: RindleApp;
|
|
54
|
+
|
|
55
|
+
/** Dynamically imports the wasm engine + optimistic glue (so the SSR/prerender shell never evaluates
|
|
56
|
+
* them) and constructs the optimistic client. */
|
|
57
|
+
async function bootClientInner() {
|
|
58
|
+
const [{ createRindleClient }, { initWasm }] = await Promise.all([
|
|
59
|
+
import("@rindle/optimistic"),
|
|
60
|
+
import("@rindle/wasm"),
|
|
61
|
+
]);
|
|
62
|
+
await initWasm(wasmUrl);
|
|
63
|
+
return createRindleClient({
|
|
64
|
+
schema,
|
|
65
|
+
mutators,
|
|
66
|
+
api: {
|
|
67
|
+
url: "", // same-origin: /api/rindle/* is a Start server route on this same server
|
|
68
|
+
// Identity per request: the dev handle header. A real app sends a verified token instead.
|
|
69
|
+
headers: (): Record<string, string> => ({ "x-rindle-user": currentHandle() }),
|
|
70
|
+
},
|
|
71
|
+
// 7601 = the daemon's PUBLIC ws port (7600 is the HTTP control plane).
|
|
72
|
+
daemon: { wsUrl: import.meta.env.VITE_DAEMON_WS ?? "ws://127.0.0.1:7601" },
|
|
73
|
+
onRejected: (envelope, reason) => rejectionHandler(envelope, reason),
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
let bootPromise: Promise<RindleApp> | undefined;
|
|
78
|
+
|
|
79
|
+
/** Construct the optimistic client in the browser (idempotent / memoized). */
|
|
80
|
+
export function bootClient(): Promise<RindleApp> {
|
|
81
|
+
if (!bootPromise) {
|
|
82
|
+
bootPromise = bootClientInner().then((ready) => {
|
|
83
|
+
app = ready;
|
|
84
|
+
// Dev-only: register this client with the in-browser devtools pane (mutation timeline, queries
|
|
85
|
+
// inspector, delta stream). `import.meta.env.DEV` is a static `false` in a production build, so
|
|
86
|
+
// this dynamic import of @rindle/devtools is tree-shaken out — devtools never ship to users.
|
|
87
|
+
if (import.meta.env.DEV) {
|
|
88
|
+
void import("@rindle/devtools").then(({ attachDevtools }) => attachDevtools(ready));
|
|
89
|
+
}
|
|
90
|
+
return ready;
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
return bootPromise;
|
|
94
|
+
}
|