@rindle/optimistic 0.1.0-rc.5

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.
@@ -0,0 +1,61 @@
1
+ // The connection's stable clientID — kept in its own (wasm-free) module so it can be unit-tested
2
+ // without pulling the engine in. A per-ORIGIN base (localStorage) shared by every tab and kept
3
+ // across reloads, a per-TAB suffix (sessionStorage) that survives a reload within a tab but is
4
+ // distinct per tab, and a per-LOAD instance suffix that keeps two clients in one tab distinct.
5
+ // Without these two tabs (or two clients in one tab) would share a clientID and collide on the
6
+ // server's per-clientID mid sequence (a confirmed-mid-ahead-of-issued throw, OPTIMISTIC-WRITES §8.2).
7
+
8
+ /** The per-ORIGIN base id (localStorage): one logical identity shared by every tab and kept
9
+ * across reloads. */
10
+ const CLIENT_ID_KEY = "rindle-client-id";
11
+ /** The per-TAB suffix (sessionStorage): survives a reload within a tab but is distinct per tab. */
12
+ const TAB_ID_KEY = "rindle-tab-id";
13
+
14
+ /** Read-or-mint a value in a web Storage area, tolerating its absence (SSR, privacy mode). Returns
15
+ * `undefined` when the area is unavailable so the caller can pick a fallback. */
16
+ function persistedId(area: "localStorage" | "sessionStorage", key: string, mint: () => string): string | undefined {
17
+ try {
18
+ const storage = (globalThis as unknown as Record<string, Storage | undefined>)[area];
19
+ if (!storage) return undefined;
20
+ const existing = storage.getItem(key);
21
+ if (existing) return existing;
22
+ const fresh = mint();
23
+ storage.setItem(key, fresh);
24
+ return fresh;
25
+ } catch {
26
+ return undefined;
27
+ }
28
+ }
29
+
30
+ /** Per-PAGE-LOAD instance counter. The FIRST client created in a tab keeps the bare tab suffix (so a
31
+ * reload resumes its mid sequence and adds no row); each ADDITIONAL client constructed in the same
32
+ * load gets a distinct instance suffix, so two clients in one tab never share a mid stream. Held in
33
+ * memory so it resets on reload — the sole client then reclaims the bare, reload-stable suffix. */
34
+ let tabInstance = 0;
35
+
36
+ /** Test-only: reset the per-load instance counter to simulate a fresh page load. Deliberately NOT
37
+ * re-exported from the package barrel. */
38
+ export function resetTabInstanceForTests(): void {
39
+ tabInstance = 0;
40
+ }
41
+
42
+ /** The connection's stable clientID. A per-origin base (localStorage) keeps one logical identity
43
+ * across reloads; a per-tab suffix (sessionStorage) gives each tab its OWN mid/lmid stream; a
44
+ * per-load instance suffix keeps two clients constructed in ONE tab from sharing that stream. Falls
45
+ * back to a fresh random id when web storage is unavailable.
46
+ *
47
+ * Note: an explicit "duplicate tab" copies sessionStorage, so the clone briefly shares its source's
48
+ * suffix until reloaded — the one residual case of the shared-clientID collision. */
49
+ export function stableClientID(): string {
50
+ const base = persistedId("localStorage", CLIENT_ID_KEY, () => crypto.randomUUID());
51
+ if (!base) return crypto.randomUUID(); // no localStorage: session-scoped, unique per load
52
+ // With localStorage but no sessionStorage, mint a fresh suffix each load so tabs still differ.
53
+ const tab =
54
+ persistedId("sessionStorage", TAB_ID_KEY, () => crypto.randomUUID().slice(0, 8)) ??
55
+ crypto.randomUUID().slice(0, 8);
56
+ // Instance 0 keeps the bare tab suffix (reload-stable, no extra row); extra concurrent clients in
57
+ // one tab get `.N` so they can't collide on the server's per-clientID mid sequence.
58
+ const instance = tabInstance++;
59
+ const suffix = instance === 0 ? tab : `${tab}.${instance}`;
60
+ return `${base}-${suffix}`;
61
+ }
package/src/client.ts ADDED
@@ -0,0 +1,156 @@
1
+ // createRindleClient — the one-call client for the daemon/serverless topology:
2
+ //
3
+ // const app = await createRindleClient({ schema, mutators, api: { url }, daemon: { wsUrl } });
4
+ // const view = app.store.materialize(queries.allIssues());
5
+ // app.mutate.createIssue({ id: 1, title: "hi", score: 0 });
6
+ //
7
+ // It wires what the pieces otherwise wire by hand: the wasm engine init, a stable per-tab clientID,
8
+ // the ws transport to the daemon, lease resolution through the API server's query route,
9
+ // and the mutation queue flushing confirmed in-order batches through the mutate route
10
+ // (rejection reasons surface via `onRejected`).
11
+
12
+ import type { ColsMap, MutationEnvelope, Schema } from "@rindle/client";
13
+ import {
14
+ RemoteOptimisticSource,
15
+ WsTransport,
16
+ createQueuedMutationSender,
17
+ } from "@rindle/remote";
18
+ import type { PushOutcome, RemoteOptimisticConnection, Transport } from "@rindle/remote";
19
+ import { initWasm } from "@rindle/wasm";
20
+
21
+ import type { OptimisticBackend } from "./backend.ts";
22
+ import type { ClientRegistry, MutationTx } from "./backend.ts";
23
+ import { stableClientID } from "./client-id.ts";
24
+ import { createOptimisticStore, type MutateFn } from "./index.ts";
25
+ import type { Store } from "@rindle/client";
26
+
27
+ /** Must mirror `DEFAULT_RINDLE_API_ROUTES` in `@rindle/api-server` (the app-wire contract;
28
+ * duplicated so the browser bundle doesn't import the server package). */
29
+ const DEFAULT_ROUTES = { query: "/api/rindle/query", mutate: "/api/rindle/mutate" } as const;
30
+
31
+ export type HeadersInit = Record<string, string>;
32
+
33
+ export interface RindleClientOptions<S extends ColsMap, R extends ClientRegistry> {
34
+ schema: Schema<S>;
35
+ /** The PREDICTED mutators (the API server holds the authoritative twins by name). */
36
+ mutators: R;
37
+ /** The app API server: named queries resolve to leases here, mutations push here. */
38
+ api: {
39
+ url: string;
40
+ routes?: { query?: string; mutate?: string };
41
+ /** Extra headers per request (auth). A function is re-evaluated per call. */
42
+ headers?: HeadersInit | (() => HeadersInit | Promise<HeadersInit>);
43
+ fetch?: typeof fetch;
44
+ };
45
+ /** Where the live subscription ws connects.
46
+ * - `{ wsUrl }` — a static endpoint (single daemon), opened eagerly; in a routed deploy this is
47
+ * the SSR-injected bootstrap endpoint (READ-ROUTER-DESIGN.md §2.4). A routed lease naming a
48
+ * different follower migrates the connection there.
49
+ * - `{ wsUrl }` omitted — pure-lazy: the first lease's `wsEndpoint` opens the connection (a
50
+ * routed SPA with no SSR bootstrap).
51
+ * - `{ transport }` — a pre-built transport (tests / in-process); fixed, no migration. */
52
+ daemon: { wsUrl?: string } | { transport: Transport };
53
+ /** Stable client identity. Default: a per-origin base (localStorage) plus per-tab and per-instance
54
+ * suffixes, so each tab — and each client instance within a tab — gets its own mid sequence yet a
55
+ * reload keeps it; falls back to a fresh random id when web storage is unavailable. Pass a value
56
+ * to override. */
57
+ clientID?: string;
58
+ /** A policy rejection's reason (the prediction's snap-back rides the lmid release). */
59
+ onRejected?: (envelope: MutationEnvelope, reason: string) => void;
60
+ queue?: { maxBatch?: number; retryDelayMs?: (attempt: number) => number };
61
+ }
62
+
63
+ export interface RindleClient<S extends ColsMap, R extends ClientRegistry> {
64
+ store: Store<S>;
65
+ backend: OptimisticBackend<S>;
66
+ /** Call `mutate.foo(args)` for a normal optimistic write, or `mutate.foo.folded(opts, args)` for a
67
+ * debounced, last-value-wins folded write (FOLDED-MUTATIONS-DESIGN §3). */
68
+ mutate: { [K in keyof R]: MutateFn<Parameters<R[K]>[1]> };
69
+ /** Drain every outstanding fold immediately (FOLDED-MUTATIONS-DESIGN §3) — also fired on
70
+ * `beforeunload`/`pagehide` so a fold in flight isn't lost on navigation. */
71
+ flushFolds(): void;
72
+ clientID: string;
73
+ close(): void;
74
+ }
75
+
76
+ export async function createRindleClient<S extends ColsMap, R extends ClientRegistry>(
77
+ opts: RindleClientOptions<S, R>,
78
+ ): Promise<RindleClient<S, R>> {
79
+ await initWasm();
80
+
81
+ const clientID = opts.clientID ?? stableClientID();
82
+ const routes = { ...DEFAULT_ROUTES, ...opts.api.routes };
83
+ const fetchImpl = opts.api.fetch ?? ((...args: Parameters<typeof fetch>) => fetch(...args));
84
+ const base = opts.api.url.replace(/\/$/, "");
85
+
86
+ const post = async (path: string, payload: unknown): Promise<unknown> => {
87
+ const extra = typeof opts.api.headers === "function" ? await opts.api.headers() : (opts.api.headers ?? {});
88
+ const res = await fetchImpl(`${base}${path}`, {
89
+ method: "POST",
90
+ headers: { "content-type": "application/json", ...extra },
91
+ body: JSON.stringify(payload),
92
+ });
93
+ if (!res.ok) throw new Error(`rindle api ${path} failed: ${res.status} ${await res.text()}`);
94
+ return res.json();
95
+ };
96
+
97
+ // Reads-leg connection: a fixed transport (tests/in-process) or a replaceable connection built
98
+ // from `wsUrl` (eager when present, lazy when omitted). A routed lease's `wsEndpoint` migrates it.
99
+ const connection: RemoteOptimisticConnection =
100
+ "transport" in opts.daemon
101
+ ? { transport: opts.daemon.transport }
102
+ : { factory: (endpoint) => new WsTransport(endpoint), endpoint: opts.daemon.wsUrl };
103
+
104
+ const source = new RemoteOptimisticSource(connection, clientID, {
105
+ resolveSubscribe: async ({ remote }) => {
106
+ // Send the stable `clientId` so the api-server/router can use it as the anonymous routing key
107
+ // (READ-ROUTER-DESIGN.md §2.2); read back the follower's `wsEndpoint` for placement.
108
+ const out = (await post(routes.query, { name: remote.name, args: remote.args, clientId: clientID })) as {
109
+ leaseToken: string;
110
+ wsEndpoint?: string;
111
+ };
112
+ return { leaseToken: out.leaseToken, wsEndpoint: out.wsEndpoint };
113
+ },
114
+ pushMutation: createQueuedMutationSender({
115
+ maxBatch: opts.queue?.maxBatch,
116
+ retryDelayMs: opts.queue?.retryDelayMs,
117
+ onRejected: opts.onRejected,
118
+ send: async (envelopes) => {
119
+ const outcomes = (await post(routes.mutate, { envelopes })) as Array<{ accepted: boolean; reason?: string }>;
120
+ return outcomes.map((o): PushOutcome => ({ accepted: o.accepted, reason: o.reason }));
121
+ },
122
+ }),
123
+ });
124
+
125
+ const { store, backend, mutate } = createOptimisticStore(opts.schema, source, opts.mutators, { clientID });
126
+
127
+ // Drain folds before the tab goes away (FOLDED-MUTATIONS-DESIGN §0.1/§3): a fold deliberately
128
+ // holds its server write through the debounce window, so an unclean navigation would otherwise
129
+ // lose the applied-locally tail. `pagehide`/`beforeunload` is the last-chance flush; `close`
130
+ // also drains. (Best-effort: an unclean crash still loses the tail — an accepted cost, §0.1.)
131
+ const flushFolds = () => backend.flushFolds();
132
+ // Structural typing — this package has no DOM lib, but the browser global carries these.
133
+ const target = globalThis as unknown as {
134
+ addEventListener?: (type: string, listener: () => void) => void;
135
+ removeEventListener?: (type: string, listener: () => void) => void;
136
+ };
137
+ target.addEventListener?.("pagehide", flushFolds);
138
+ target.addEventListener?.("beforeunload", flushFolds);
139
+
140
+ return {
141
+ store,
142
+ backend,
143
+ mutate,
144
+ flushFolds,
145
+ clientID,
146
+ close: () => {
147
+ flushFolds();
148
+ target.removeEventListener?.("pagehide", flushFolds);
149
+ target.removeEventListener?.("beforeunload", flushFolds);
150
+ source.close();
151
+ },
152
+ };
153
+ }
154
+
155
+ export type { ClientRegistry, MutationTx };
156
+ export type { MutateFn } from "./index.ts";
package/src/index.ts ADDED
@@ -0,0 +1,68 @@
1
+ // @rindle/optimistic — optimistic writes over the local-first wasm engine
2
+ // (OPTIMISTIC-WRITES-DESIGN.md): named client mutators (invoked optimistically,
3
+ // RE-INVOKED on every rebase), the cv-buffered coherent release (§8.5), and the
4
+ // engine-side fork/rebase reconcile cycle (§1.3) behind the ordinary `Backend` seam.
5
+
6
+ import { Store } from "@rindle/client";
7
+ import type { ColsMap, Schema, OptimisticSource } from "@rindle/client";
8
+
9
+ import {
10
+ OptimisticBackend,
11
+ type ClientRegistry,
12
+ type FoldHandle,
13
+ type FoldOptions,
14
+ type OptimisticBackendOptions,
15
+ } from "./backend.ts";
16
+
17
+ /** One entry of the {@link createOptimisticStore} `mutate` facade: call it for a normal optimistic
18
+ * write (returns the `mid`), or `.folded(opts, args)` for a debounced, last-value-wins folded write
19
+ * (returns a {@link FoldHandle} — the `mid` is assigned at flush, FOLDED-MUTATIONS-DESIGN §3). */
20
+ export type MutateFn<Args> = ((args: Args) => number) & {
21
+ folded(opts: FoldOptions, args: Args): FoldHandle;
22
+ };
23
+
24
+ export { OptimisticBackend } from "./backend.ts";
25
+ export type {
26
+ ClientMutator,
27
+ ClientRegistry,
28
+ FoldClock,
29
+ FoldHandle,
30
+ FoldOptions,
31
+ KeyedRow,
32
+ MutationTx,
33
+ OptimisticBackendOptions,
34
+ ResultType,
35
+ } from "./backend.ts";
36
+ export type { MutationEnvelope, OptimisticSource, ProgressFrame } from "@rindle/client";
37
+
38
+ // The one-call client for the daemon/serverless topology (API server + daemon ws).
39
+ export { createRindleClient } from "./client.ts";
40
+ export type { RindleClient, RindleClientOptions } from "./client.ts";
41
+ export { stableClientID } from "./client-id.ts";
42
+
43
+ /** A {@link Store} over an {@link OptimisticBackend}, plus the named-mutator entry
44
+ * (`mutate.createIssue(args)` — the §9 dream shape) and the §6 lifecycle surface. */
45
+ export function createOptimisticStore<S extends ColsMap, R extends ClientRegistry>(
46
+ schema: Schema<S>,
47
+ source: OptimisticSource,
48
+ registry: R,
49
+ opts: OptimisticBackendOptions,
50
+ ): {
51
+ store: Store<S>;
52
+ backend: OptimisticBackend<S>;
53
+ mutate: { [K in keyof R]: MutateFn<Parameters<R[K]>[1]> };
54
+ } {
55
+ const backend = new OptimisticBackend(schema, source, registry, opts);
56
+ const store = new Store(schema, backend);
57
+ const mutate = new Proxy(
58
+ {},
59
+ {
60
+ get: (_t, name: string) => {
61
+ const fn = ((args: unknown) => backend.invoke(name, args)) as MutateFn<unknown>;
62
+ fn.folded = (foldOpts: FoldOptions, args: unknown) => backend.invokeFolded(name, foldOpts, args);
63
+ return fn;
64
+ },
65
+ },
66
+ ) as { [K in keyof R]: MutateFn<Parameters<R[K]>[1]> };
67
+ return { store, backend, mutate };
68
+ }