create-rindle 0.3.1 → 0.4.3

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.3.1",
3
+ "version": "0.4.3",
4
4
  "license": "Apache-2.0",
5
5
  "repository": {
6
6
  "type": "git",
@@ -30,15 +30,21 @@ review:
30
30
  `CREATE INDEX`; every table a single primary key). **Never hand-edit
31
31
  `shared/schema.gen.ts`** — it is generated from the live daemon and
32
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).
33
+ 2. **Mutators are one isomorphic body, deterministic and replayable**
34
+ (`shared/app-def.ts`): a generator that `yield`s logical ops
35
+ (`yield tx.insert(...)`), paired with its zod arg schema via
36
+ `shared(args, gen)`. No `Date.now()`, no `Math.random()`, no I/O — the client
37
+ re-invokes the body on every rebase. Generate ids and timestamps at the
38
+ callsite and pass them in as args; the acting user is `ctx.user` (injected
39
+ per tier), never an arg.
40
+ 3. **The server drives the same body** `sharedApiMutators` in
41
+ `server/app-api.ts` auto-wraps the whole registry (parse the untrusted wire
42
+ args through each mutator's `.args`, inject the authenticated principal,
43
+ render every op to SQL). Add an explicit entry ONLY for server-only
44
+ authority the client must not predict (a policy guard, a raw `tx.exec`
45
+ relational gate). Only `(name, args)` ever crosses the wire; `throw` to
46
+ hard-reject (the optimistic write snaps back on its own; write no rollback
47
+ code).
42
48
  4. **Remote subscriptions must be named.** Define queries with `defineQuery` in
43
49
  `src/components/*.queries.ts` and register them in `server/app-api.ts`. An
44
50
  ad-hoc `store.query.…` builder resolves **locally only** — it never opens a
@@ -58,10 +64,10 @@ review:
58
64
  | --- | --- |
59
65
  | `migrations/*.sql` | the real schema — the only place DDL lives |
60
66
  | `shared/schema.gen.ts` | **generated** table schema — do not edit |
61
- | `shared/app-def.ts` | the shared contract: relationships, query builder, predicted mutators |
67
+ | `shared/app-def.ts` | the shared contract: relationships, query builder, isomorphic mutators |
62
68
  | `src/components/*.queries.ts` | named queries + fragments, co-located with their components |
63
69
  | `src/rindle-client.ts` | the one-call browser wire-up (`createRindleClient`) |
64
- | `server/app-api.ts` | the authority: `registerQueries` + authoritative SQL mutators + policy |
70
+ | `server/app-api.ts` | the authority: `registerQueries` + `sharedApiMutators` + server-only policy |
65
71
  | `src/routes/api.rindle.*.tsx` | TanStack Start server routes exposing the authority over HTTP |
66
72
  | `server/dev.ts` | dev orchestration (`rindle up` + seed + Vite) |
67
73
  | `src/ssr.ts` | SSR preload of the same named queries for first paint |
@@ -11,8 +11,9 @@ Three tiers, same as the Rindle flagship examples:
11
11
  - **Browser** — a TanStack Start SPA whose data layer *is* Rindle. The wasm IVM engine runs
12
12
  in-process: reads resolve locally and instantly, writes apply optimistically and reconcile on
13
13
  confirmation. Views are co-located Relay-style fragments (`src/components/*.queries.ts`).
14
- - **API authority** (`server/app-api.ts`) — resolves named queries to ASTs, runs the authoritative SQL
15
- mutators, and enforces policy: reads are public, writes require an identity, and a `"spam"` name/body
14
+ - **API authority** (`server/app-api.ts`) — resolves named queries to ASTs, drives the **same
15
+ isomorphic mutators** the browser predicted (their logical ops rendered to SQL), and enforces
16
+ policy: reads are public, writes require an identity, and a `"spam"` name/body
16
17
  is rejected (you'll see the optimistic write snap back + a toast). It's host-agnostic: the browser
17
18
  reaches it through TanStack Start **server routes** (`src/routes/api.rindle.{query,read,mutate}.tsx`,
18
19
  via `server/rindle-http.ts`) that run in the same server as the app, and SSR calls the very same
@@ -60,10 +61,10 @@ of `vite build` and never ship to production.
60
61
  |---|---|
61
62
  | `migrations/*.sql` | the schema — **source of truth** |
62
63
  | `shared/schema.gen.ts` | generated `@rindle/client` schema (don't edit by hand) |
63
- | `shared/app-def.ts` | the contract root: schema re-export, relationships, mutators |
64
+ | `shared/app-def.ts` | the contract root: schema re-export, relationships, isomorphic mutators |
64
65
  | `shared/auth.ts` | the identity seam (`AuthProvider`) |
65
66
  | `server/dev.ts` | one-command dev orchestrator |
66
- | `server/app-api.ts` | the authority: query resolution, SQL mutators, policy (host-agnostic) |
67
+ | `server/app-api.ts` | the authority: query resolution, `sharedApiMutators`, policy (host-agnostic) |
67
68
  | `server/rindle-http.ts` | adapts the authority to a Web Request (the Start API routes call it) |
68
69
  | `src/routes/api.rindle.*.tsx` | the three API endpoints as Start server routes (the browser's API) |
69
70
  | `src/ssr.ts` | first-paint preload — calls the authority in-process |
@@ -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);
@@ -1,19 +1,41 @@
1
1
  // The app authority, runtime-AGNOSTIC. The serverless-shaped tier's actual logic: it resolves named
2
- // queries to ASTs (the daemon mints opaque leases), runs the AUTHORITATIVE mutators into approved SQL,
3
- // enforces policy (identity required to write, a "spam" rejection demo), and talks to the daemon over
4
- // the private bearer-auth'd control plane.
2
+ // queries to ASTs (the daemon mints opaque leases), drives the SAME isomorphic mutators the browser
3
+ // predicted (shared/app-def.ts) with their ops rendered to approved SQL, enforces policy (identity
4
+ // required to write, a "spam" rejection demo), and talks to the daemon over the private bearer-auth'd
5
+ // control plane.
5
6
  //
6
7
  // It is deliberately free of any host: server/rindle-http.ts adapts it to a Web Request for the Start
7
8
  // API routes (src/routes/api.rindle.*.tsx) the browser calls, and the SSR loader (src/ssr.ts) calls the
8
9
  // SAME factory in-process. The only per-host inputs are the daemon's control-plane URL + token and the
9
10
  // AuthProvider.
10
11
 
11
- import { createRindleApiServer, defineApiMutators, registerQueries } from "@rindle/api-server";
12
- import type { ApiMutators, MutationContext, RindleApiServer, SqlMutationTx } from "@rindle/api-server";
12
+ import {
13
+ createRindleApiServer,
14
+ defineApiMutators,
15
+ registerQueries,
16
+ runSharedMutation,
17
+ sharedApiMutators,
18
+ } from "@rindle/api-server";
19
+ import type {
20
+ ApiMutator,
21
+ ApiMutators,
22
+ MutationContext,
23
+ MutatorCtx,
24
+ RindleApiServer,
25
+ ServerMutationTx,
26
+ SharedMutatorWithArgs,
27
+ } from "@rindle/api-server";
13
28
  import { HttpRindleDaemonClient } from "@rindle/daemon-client";
14
29
  import type { FetchLike } from "@rindle/daemon-client";
15
30
 
16
- import { createRoomArgs, normalizeBody, normalizeName, normalizeSubject, postMessageArgs } from "../shared/app-def.ts";
31
+ import {
32
+ mutators as sharedMutators,
33
+ normalizeBody,
34
+ normalizeName,
35
+ normalizeSubject,
36
+ postMessageArgs,
37
+ schema,
38
+ } from "../shared/app-def.ts";
17
39
  import type { Identity } from "../shared/auth.ts";
18
40
  import { roomsQuery } from "../src/components/RoomCard.queries.ts";
19
41
  import { roomDetailQuery } from "../src/components/RoomView.queries.ts";
@@ -26,23 +48,50 @@ export type User = Identity | undefined;
26
48
  // can't smuggle a garbage arg in.
27
49
  const apiQueries = registerQueries<User>([roomsQuery, roomDetailQuery]);
28
50
 
51
+ // MUTATORS ARE ISOMORPHIC — defined once in shared/app-def.ts and auto-driven here by
52
+ // `sharedApiMutators`: for each mutator it parses the untrusted wire args (the mutator's co-located
53
+ // `.args` schema), injects the AUTHENTICATED principal as `ctx.user` (`sharedCtx` — never a client
54
+ // arg), and drives the SAME body the client predicted, rendering every yielded op to SQL. A mutator
55
+ // whose server run needs no authority beyond that triad needs no entry here at all.
56
+ //
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): the "spam" policy guard (so the demo's
59
+ // rejection path stays exercised end to end) and postMessage's room-exists gate (relational SQL a
60
+ // keyed op can't express — the raw `tx.exec` escape hatch).
61
+
62
+ /** The `MutatorCtx` a shared body sees on the server: the AUTHENTICATED subject (throws if absent —
63
+ * a business rejection). Never a client-supplied author. */
64
+ function sharedCtx(ctx: MutationContext<User>): MutatorCtx {
65
+ return { user: requireUser(ctx.user).subject };
66
+ }
67
+
68
+ /** Layer a server-only arg policy onto a shared mutator (throw → hard reject), then drive the SAME
69
+ * body the client predicts. */
70
+ function withGuard<A>(gen: SharedMutatorWithArgs<A>, guard: (a: A) => void): ApiMutator<User, unknown> {
71
+ return (tx, raw, ctx) => {
72
+ const a = gen.args.parse(raw);
73
+ guard(a);
74
+ return runSharedMutation(gen, a, sharedCtx(ctx), tx);
75
+ };
76
+ }
77
+
29
78
  const apiMutators = defineApiMutators<User, ApiMutators<User>>({
30
- createRoom: (tx: SqlMutationTx, raw: unknown, ctx: MutationContext<User>) => {
31
- const a = createRoomArgs.parse(raw);
32
- requireUser(ctx.user);
33
- const name = cleanName(a.name);
34
- tx.exec("INSERT OR IGNORE INTO room (id, name, createdAt) VALUES (?, ?, ?)", [a.id, name, a.createdAt]);
35
- },
36
- postMessage: (tx: SqlMutationTx, raw: unknown, ctx: MutationContext<User>) => {
79
+ ...sharedApiMutators(sharedMutators, sharedCtx),
80
+
81
+ // (a) a server-only policy on the shared body: an empty or "spam" name throws BEFORE any write.
82
+ createRoom: withGuard(sharedMutators.createRoom, (a) => void cleanName(a.name)),
83
+
84
+ // (b) the raw-SQL escape hatch: insert only into a room that exists, so a racing client can't post
85
+ // into a deleted room. The client predicts the plain shared insert and it snaps back if this
86
+ // no-ops. `author` is the VERIFIED subject, never a wire arg.
87
+ postMessage: async (tx: ServerMutationTx, raw: unknown, ctx: MutationContext<User>) => {
37
88
  const a = postMessageArgs.parse(raw);
38
- const who = requireUser(ctx.user);
89
+ const author = sharedCtx(ctx).user;
39
90
  const body = cleanBody(a.body);
40
- // Insert only into a room that exists — a racing client can't post into a deleted room. `author`
41
- // is the VERIFIED subject, never the wire arg.
42
91
  tx.exec(
43
92
  `INSERT INTO message (id, roomId, author, body, createdAt)
44
93
  SELECT ?, ?, ?, ?, ? WHERE EXISTS (SELECT 1 FROM room WHERE id = ?)`,
45
- [a.id, a.roomId, who.subject, body, a.createdAt, a.roomId],
94
+ [a.id, a.roomId, author, body, a.createdAt, a.roomId],
46
95
  );
47
96
  },
48
97
  });
@@ -67,6 +116,7 @@ export function createAppApi(opts: AppApiOptions): RindleApiServer<User> {
67
116
  });
68
117
  return createRindleApiServer<User>({
69
118
  daemon,
119
+ schema, // drives the dialect-SQL renderer for the shared mutators' logical ops
70
120
  queries: apiQueries,
71
121
  mutators: apiMutators,
72
122
  authorizeQuery: () => true, // public reads
@@ -1,16 +1,17 @@
1
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.
2
+ // normalization helpers, and the ISOMORPHIC mutators one generator body per mutator, driven by
3
+ // BOTH tiers. The browser drives each body synchronously (the optimistic prediction); the API server
4
+ // drives the SAME body inside an authoritative transaction, rendering its ops to SQL
5
+ // (server/app-api.ts).
5
6
  //
6
7
  // This module is the leaf of the contract DAG: it depends on nothing app-internal. The named root
7
8
  // queries AND the per-component SELECTIONS they compose are co-located with their components in
8
9
  // `src/components/*.queries.ts` (Relay-style co-location). Keeping the schema here, free of those
9
10
  // imports, is what keeps that graph acyclic.
10
11
 
11
- import { defineRelationships, newQueryBuilder, rel } from "@rindle/client";
12
- import type { Row } from "@rindle/client";
13
- import type { ClientRegistry, MutationTx } from "@rindle/optimistic";
12
+ import { defineRelationships, newQueryBuilder, rel, shared } from "@rindle/client";
13
+ import type { IsoTx, MutationGen, MutatorCtx, Row } from "@rindle/client";
14
+ import type { ClientRegistry } from "@rindle/optimistic";
14
15
  import { z } from "zod";
15
16
 
16
17
  // The schema is GENERATED from migrations/*.sql into ./schema.gen.ts by `rindle schema gen` — `pnpm
@@ -57,10 +58,11 @@ export function normalizeSubject(raw: string): string {
57
58
 
58
59
  // --------------------------------------------------------------------------- mutator args
59
60
  //
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.
61
+ // One zod schema per mutator, co-located with its body via `shared(args, gen)`. The SERVER parses the
62
+ // UNTRUSTED wire args through it (server/app-api.ts); BOTH tiers derive the arg TYPE from it
63
+ // (`z.infer`). NB: the AUTHOR is NOT an arg — it is the acting principal (`ctx.user`), injected by
64
+ // each tier's driver (the client's local handle for the prediction; the server's AUTHENTICATED
65
+ // identity for the authoritative run), so it is unspoofable over the wire.
64
66
 
65
67
  export const createRoomArgs = z.object({ id: z.string(), name: z.string(), createdAt: z.number() });
66
68
  export type CreateRoomArgs = z.infer<typeof createRoomArgs>;
@@ -69,32 +71,36 @@ export const postMessageArgs = z.object({
69
71
  id: z.string(),
70
72
  roomId: z.string(),
71
73
  body: z.string(),
72
- author: z.string(),
73
74
  createdAt: z.number(),
74
75
  });
75
76
  export type PostMessageArgs = z.infer<typeof postMessageArgs>;
76
77
 
77
- // --------------------------------------------------------------------------- mutators (PREDICTED)
78
+ // --------------------------------------------------------------------------- mutators (ISOMORPHIC)
78
79
  //
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).
80
+ // ONE body per mutator, shared by both tiers. Each is a GENERATOR: it `yield`s logical write ops
81
+ // (`yield tx.insert(...)`) instead of touching a database, so the SAME function runs synchronously
82
+ // against the browser's wasm engine (the optimistic prediction) AND asynchronously against a live
83
+ // transaction on the server, each op rendered to SQL. Deterministic + replayable: every value that
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 runs INSIDE the body, so both tiers
86
+ // normalize identically; the server layers its own policy on top (identity required, a "spam"
87
+ // rejection demo, the room-exists guard — server/app-api.ts).
82
88
 
83
89
  export const mutators = {
84
- createRoom: (tx: MutationTx, a: CreateRoomArgs) => {
90
+ createRoom: shared(createRoomArgs, function* (tx: IsoTx, a: CreateRoomArgs): MutationGen {
85
91
  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) => {
92
+ if (!name) return; // a no-op prediction is fine; the server's guard hard-rejects
93
+ yield tx.insertIgnore("room", { id: a.id, name, createdAt: a.createdAt });
94
+ }),
95
+ postMessage: shared(postMessageArgs, function* (tx: IsoTx, a: PostMessageArgs, ctx: MutatorCtx): MutationGen {
90
96
  const body = normalizeBody(a.body);
91
97
  if (!body) return;
92
- tx.insert("message", {
98
+ yield tx.insert("message", {
93
99
  id: a.id,
94
100
  roomId: a.roomId,
95
- author: normalizeSubject(a.author),
101
+ author: normalizeSubject(ctx.user),
96
102
  body,
97
103
  createdAt: a.createdAt,
98
104
  });
99
- },
105
+ }),
100
106
  } satisfies ClientRegistry;
@@ -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
  }
@@ -5,7 +5,7 @@
5
5
  import { useState } from "react";
6
6
  import type { FormEvent } from "react";
7
7
 
8
- import { app, currentHandle } from "../rindle-client.ts";
8
+ import { app } from "../rindle-client.ts";
9
9
 
10
10
  export function Composer({ roomId }: { roomId: string }) {
11
11
  const [body, setBody] = useState("");
@@ -15,11 +15,11 @@ export function Composer({ roomId }: { roomId: string }) {
15
15
  const text = body.trim();
16
16
  if (!text) return;
17
17
  const now = Date.now();
18
+ // The author is NOT an arg — it's the acting principal (ctx.user), injected by each tier.
18
19
  app.mutate.postMessage({
19
20
  id: `msg-${now}-${Math.random().toString(36).slice(2, 7)}`,
20
21
  roomId,
21
22
  body: text,
22
- author: currentHandle(),
23
23
  createdAt: now,
24
24
  });
25
25
  setBody("");
@@ -11,7 +11,7 @@ import type { MutationEnvelope } from "@rindle/client";
11
11
 
12
12
  import wasmUrl from "rindle-wasm-bin?url";
13
13
 
14
- import { mutators, schema } from "../shared/app-def.ts";
14
+ import { mutators, normalizeSubject, schema } from "../shared/app-def.ts";
15
15
 
16
16
  // The precise client type — including the typed `mutate.*` surface — is INFERRED from the concrete
17
17
  // `createRindleClient({ schema, mutators, … })` call in `bootClientInner`.
@@ -63,6 +63,9 @@ async function bootClientInner() {
63
63
  return createRindleClient({
64
64
  schema,
65
65
  mutators,
66
+ // The acting principal a mutator sees as ctx.user — the prediction's author. The server injects
67
+ // its OWN verified identity for the authoritative run (server/app-api.ts sharedCtx).
68
+ user: () => normalizeSubject(currentHandle()),
66
69
  api: {
67
70
  url: "", // same-origin: /api/rindle/* is a Start server route on this same server
68
71
  // Identity per request: the dev handle header. A real app sends a verified token instead.
@@ -70,10 +73,7 @@ async function bootClientInner() {
70
73
  },
71
74
  // 7601 = the daemon's PUBLIC ws port (7600 is the HTTP control plane).
72
75
  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),
76
+ dev: { resetOnMutationGap: import.meta.env.DEV },
77
77
  onRejected: (envelope, reason) => rejectionHandler(envelope, reason),
78
78
  });
79
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
  }