eraspace 0.0.0-stage → 0.1.0

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,101 @@
1
+ import { signal, computed, effect, type Signal, type ReadonlySignal } from "@preact/signals";
2
+ import type { PaginationResult } from "../server/index.ts";
3
+ import type { PaginatedQueryResult } from "./types.ts";
4
+ import type { ClientImpl } from "./connection.ts";
5
+ import type { Subscription } from "./subscription.ts";
6
+
7
+ export class Pager {
8
+ readonly pages: Signal<Subscription[]> = signal([]);
9
+ private stops: (() => void)[] = [];
10
+ private sizes: number[] = [];
11
+ readonly result: ReadonlySignal<{ results: any[]; status: PaginatedQueryResult<any>["status"] }>;
12
+ private client: ClientImpl;
13
+ private fn: string;
14
+ private prefix: unknown[];
15
+ private initial: number;
16
+
17
+ constructor(client: ClientImpl, fn: string, prefix: unknown[], initial: number) {
18
+ this.client = client;
19
+ this.fn = fn;
20
+ this.prefix = prefix;
21
+ this.initial = initial;
22
+ this.result = computed(() => {
23
+ const pages = this.pages.value;
24
+ const results: any[] = [];
25
+ let loading = false;
26
+ let done = false;
27
+ for (const p of pages) {
28
+ const err = p.error.value;
29
+ if (err) throw err;
30
+ const v = p.value.value as PaginationResult<any> | undefined;
31
+ if (v === undefined) {
32
+ loading = true;
33
+ break;
34
+ }
35
+ results.push(...v.page);
36
+ done = v.isDone;
37
+ }
38
+ const status = loading
39
+ ? pages.length <= 1
40
+ ? "LoadingFirstPage"
41
+ : "LoadingMore"
42
+ : done
43
+ ? "Exhausted"
44
+ : "CanLoadMore";
45
+ return { results, status } as const;
46
+ });
47
+ }
48
+
49
+ start(): void {
50
+ if (this.pages.peek().length === 0) this.addPage(null, this.initial);
51
+ }
52
+
53
+ stop(): void {
54
+ for (const s of this.stops) s();
55
+ this.stops = [];
56
+ this.sizes = [];
57
+ for (const p of this.pages.peek()) this.client.release(p);
58
+ this.pages.value = [];
59
+ }
60
+
61
+ reset(): void {
62
+ this.stop();
63
+ this.start();
64
+ }
65
+
66
+ loadMore(n?: number): void {
67
+ const pages = this.pages.peek();
68
+ const last = pages[pages.length - 1];
69
+ const v = last?.value.peek() as PaginationResult<any> | undefined;
70
+ if (!v || v.isDone) return;
71
+ this.addPage(v.continueCursor, n ?? this.initial);
72
+ }
73
+
74
+ private addPage(cursor: string | null, numItems: number): void {
75
+ const sub = this.client.acquire(this.fn, [...this.prefix, { cursor, numItems }]);
76
+ this.client.retain(sub);
77
+ const index = this.pages.peek().length;
78
+ this.sizes.push(numItems);
79
+ this.pages.value = [...this.pages.peek(), sub];
80
+ // If this page's end cursor moves (rows inserted/deleted), re-chain the pages after it.
81
+ let lastCursor: string | undefined;
82
+ const stop = effect(() => {
83
+ const v = sub.value.value as PaginationResult<any> | undefined;
84
+ if (!v) return;
85
+ if (lastCursor !== undefined && v.continueCursor !== lastCursor) this.truncateAfter(index, v);
86
+ lastCursor = v.continueCursor;
87
+ });
88
+ this.stops.push(stop);
89
+ }
90
+
91
+ private truncateAfter(index: number, v: PaginationResult<any>): void {
92
+ const pages = this.pages.peek();
93
+ if (pages.length <= index + 1) return;
94
+ const sizes = this.sizes.slice(index + 1);
95
+ for (const p of pages.slice(index + 1)) this.client.release(p);
96
+ for (const s of this.stops.splice(index + 1)) s();
97
+ this.sizes = this.sizes.slice(0, index + 1);
98
+ this.pages.value = pages.slice(0, index + 1);
99
+ if (!v.isDone) this.addPage(v.continueCursor, sizes.reduce((a, b) => a + b, 0));
100
+ }
101
+ }
@@ -0,0 +1,41 @@
1
+ export type Patch =
2
+ | { path: (string | number)[]; value: unknown }
3
+ | { path: (string | number)[]; remove: true }
4
+ | { path: (string | number)[]; index: number; deleteCount: number; items: unknown[] };
5
+
6
+ /** Apply only to the server value. Optimistic layers are replayed afterwards. */
7
+ export function applyPatch(value: unknown, patches: Patch[]): unknown {
8
+ return patches.reduce((current, patch) => edit(current, patch.path, patch), value);
9
+ }
10
+
11
+ function edit(value: unknown, path: (string | number)[], patch: Patch): unknown {
12
+ if (path.length === 0) {
13
+ if ("index" in patch) {
14
+ if (!Array.isArray(value) || patch.index < 0 || patch.index + patch.deleteCount > value.length) {
15
+ throw new Error("Invalid array patch");
16
+ }
17
+ const next: unknown[] = [...value];
18
+ next.splice(patch.index, patch.deleteCount, ...patch.items);
19
+ return next;
20
+ }
21
+ if ("value" in patch) return patch.value;
22
+ throw new Error("Cannot remove the root value");
23
+ }
24
+ const [key, ...rest] = path;
25
+ if (Array.isArray(value) && typeof key === "number" && key >= 0 && key < value.length) {
26
+ const next: unknown[] = [...value];
27
+ if (rest.length === 0 && "remove" in patch) next.splice(key, 1);
28
+ else next[key] = edit(value[key], rest, patch);
29
+ return next;
30
+ }
31
+ if (value !== null && typeof value === "object" && !Array.isArray(value) && typeof key === "string") {
32
+ const next: Record<string, unknown> = { ...value };
33
+ if (rest.length === 0 && "remove" in patch) delete next[key];
34
+ else {
35
+ const previous = Object.hasOwn(next, key) ? next[key] : undefined;
36
+ Object.defineProperty(next, key, { value: edit(previous, rest, patch), enumerable: true, writable: true, configurable: true });
37
+ }
38
+ return next;
39
+ }
40
+ throw new Error("Invalid patch path");
41
+ }
@@ -0,0 +1,58 @@
1
+ import { Presence, type Socket, type Channel } from "phoenix";
2
+ import { signal, type Signal } from "@preact/signals";
3
+ import type { PresenceEntry } from "./types.ts";
4
+ import { stableStringify } from "./sub-key.ts";
5
+
6
+ export class RoomConn {
7
+ channel: Channel;
8
+ presence: Presence;
9
+ readonly members: Signal<PresenceEntry<any>[]> = signal([]);
10
+ readonly state: Signal<Record<string, unknown>> = signal({});
11
+ readonly self: Signal<string>;
12
+ refs = 0;
13
+ meta: Record<string, unknown> = {};
14
+ listeners = new Map<string, Set<(payload: any, from: string | undefined) => void>>();
15
+ timer: ReturnType<typeof setTimeout> | undefined;
16
+ readonly topic: string;
17
+
18
+ constructor(socket: Socket, topic: string, clientSelf: Signal<string>) {
19
+ this.topic = topic;
20
+ // The join reply carries the server-derived id; until then this is the id from the app channel, if known.
21
+ this.self = signal(clientSelf.value);
22
+ this.channel = socket.channel(topic, () => ({ meta: this.meta }));
23
+ this.presence = new Presence(this.channel);
24
+ this.presence.onSync(() => {
25
+ this.members.value = this.presence.list((id: string, entry: any) => {
26
+ const metas = (entry.metas ?? []).map(stripPhxRef);
27
+ return { id, meta: metas[0] ?? {}, metas };
28
+ });
29
+ });
30
+ this.channel.on("msg", (m: { event: string; payload: unknown; from?: string }) => {
31
+ const set = this.listeners.get(m.event);
32
+ if (set) for (const cb of [...set]) cb(m.payload, m.from);
33
+ const all = this.listeners.get("*");
34
+ if (all) for (const cb of [...all]) cb(m, m.from);
35
+ });
36
+ this.channel.on("state:full", (m: { state?: Record<string, unknown> }) => {
37
+ this.state.value = { ...(m.state ?? {}) };
38
+ });
39
+ this.channel.on("state", (m: { key: string; value: unknown }) => {
40
+ this.state.value = { ...this.state.value, [m.key]: m.value };
41
+ });
42
+ this.channel.join().receive("ok", (resp: { self?: string } | undefined) => {
43
+ if (resp && typeof resp.self === "string") this.self.value = clientSelf.value = resp.self;
44
+ });
45
+ }
46
+
47
+ setMeta(meta: Record<string, unknown>): void {
48
+ if (stableStringify(meta) === stableStringify(this.meta)) return;
49
+ this.meta = meta;
50
+ this.channel.push("meta", { meta });
51
+ }
52
+ }
53
+
54
+ function stripPhxRef(meta: any): any {
55
+ if (!meta || typeof meta !== "object") return meta;
56
+ const { phx_ref: _a, phx_ref_prev: _b, ...rest } = meta;
57
+ return rest;
58
+ }
@@ -0,0 +1,168 @@
1
+ // Client routing. preact-iso's <Router> does the matching; our <LocationProvider> feeds it app-relative paths, so
2
+ // `<Route path="/todos/:id">` matches `/<app>/todos/123`. Links and navigation add the base back.
3
+ import { h, isValidElement, toChildArray, type ComponentChildren, type ComponentType, type Context, type JSX, type VNode } from "preact";
4
+ import { useContext, useLayoutEffect, useMemo, useState } from "preact/hooks";
5
+ import { LocationProvider as IsoLocationProvider, Router as IsoRouter, useLocation as isoUseLocation, useRoute } from "preact-iso/router";
6
+ import { readConfig } from "./client.ts";
7
+
8
+ export interface LocationValue {
9
+ /** App-relative URL including the query string, e.g. `/todos/1?x=2`. */
10
+ url: string;
11
+ /** App-relative path, e.g. `/todos/1`. */
12
+ path: string;
13
+ query: Record<string, string>;
14
+ /** Navigate to an app-relative URL. */
15
+ route(url: string, replace?: boolean): void;
16
+ back(): void;
17
+ forward(): void;
18
+ }
19
+
20
+ const ctx = (IsoLocationProvider as unknown as { ctx: Context<any> }).ctx;
21
+
22
+ function normalizeBase(base: string | undefined): string {
23
+ let b = base ?? readConfig().base ?? "/";
24
+ if (!b.startsWith("/")) b = "/" + b;
25
+ if (!b.endsWith("/")) b += "/";
26
+ return b;
27
+ }
28
+
29
+ /** Strip the app base from an absolute path: `/chat/rooms/1` → `/rooms/1`. */
30
+ export function stripBase(base: string, url: string): string {
31
+ if (url.startsWith(base)) return "/" + url.slice(base.length);
32
+ const bare = base.slice(0, -1);
33
+ if (url === bare || url.startsWith(bare + "?") || url.startsWith(bare + "#")) return "/" + url.slice(bare.length);
34
+ return url;
35
+ }
36
+
37
+ /** Turn an app-relative URL into an absolute one: `/rooms/1` → `/chat/rooms/1`. */
38
+ export function withBase(base: string, to: string): string {
39
+ if (/^[a-z][a-z0-9+.-]*:/i.test(to) || to.startsWith("//")) return to;
40
+ if (to.startsWith(base)) return to;
41
+ return base + (to.startsWith("/") ? to.slice(1) : to);
42
+ }
43
+
44
+ function currentUrl(base: string): string {
45
+ if (typeof location === "undefined") return "/";
46
+ return stripBase(base, location.pathname + location.search);
47
+ }
48
+
49
+ let globalBase = "/";
50
+
51
+ /** Provides the app-relative location. `mount()` adds one around your App unless `router: false`. */
52
+ export function LocationProvider(props: { base?: string; url?: string; children?: ComponentChildren }) {
53
+ const base = normalizeBase(props.base);
54
+ globalBase = base;
55
+ const [state, setState] = useState(() => ({ url: props.url ?? currentUrl(base), wasPush: false }));
56
+
57
+ const value = useMemo(() => {
58
+ const u = new URL(state.url, "http://x");
59
+ const path = u.pathname.replace(/\/+$/g, "") || "/";
60
+ const route = (to: string, replace?: boolean) => {
61
+ const abs = withBase(base, to);
62
+ if (typeof history !== "undefined") {
63
+ if (replace) history.replaceState(null, "", abs);
64
+ else history.pushState(null, "", abs);
65
+ }
66
+ setState({ url: stripBase(base, abs), wasPush: !replace });
67
+ };
68
+ return {
69
+ url: state.url,
70
+ path,
71
+ query: Object.fromEntries(u.searchParams),
72
+ route,
73
+ back: () => history.back(),
74
+ forward: () => history.forward(),
75
+ wasPush: state.wasPush,
76
+ };
77
+ }, [state, base]);
78
+
79
+ useLayoutEffect(() => {
80
+ const onPop = () => setState({ url: currentUrl(base), wasPush: false });
81
+ const onClick = (e: MouseEvent) => {
82
+ if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
83
+ const a = (e.composedPath() as Element[]).find((el) => el.nodeName === "A") as HTMLAnchorElement | undefined;
84
+ if (!a || !a.href || a.origin !== location.origin || a.download) return;
85
+ if (a.target && !/^_?self$/i.test(a.target)) return;
86
+ const href = a.getAttribute("href") ?? "";
87
+ if (href.startsWith("#")) return;
88
+ const abs = a.pathname + a.search;
89
+ if (!(abs + "/").startsWith(base) && !abs.startsWith(base)) return;
90
+ e.preventDefault();
91
+ value.route(stripBase(base, abs + a.hash));
92
+ };
93
+ addEventListener("popstate", onPop);
94
+ addEventListener("click", onClick);
95
+ return () => {
96
+ removeEventListener("popstate", onPop);
97
+ removeEventListener("click", onClick);
98
+ };
99
+ }, [base, value]);
100
+
101
+ return h(ctx.Provider, { value }, props.children);
102
+ }
103
+
104
+ function isRouteChild(v: unknown): boolean {
105
+ const p = (v as VNode<any> | null)?.props;
106
+ return !!p && (p.path !== undefined || p.default);
107
+ }
108
+
109
+ /**
110
+ * Renders the first child `<Route>` whose `path` matches. Without route children it only provides the location,
111
+ * so both `<Router><Route .../></Router>` and Lakebed's `<Router><Routes>...</Routes></Router>` work.
112
+ */
113
+ export function Router(props: {
114
+ children?: ComponentChildren;
115
+ onRouteChange?: (url: string) => void;
116
+ onLoadStart?: (url: string) => void;
117
+ onLoadEnd?: (url: string) => void;
118
+ }) {
119
+ const hasProvider = !!useContext(ctx)?.url;
120
+ const routes = toChildArray(props.children).some(isRouteChild);
121
+ // preact-iso serializes route props; rendered VNodes contain circular parent links.
122
+ const children = useMemo(() => toChildArray(props.children).filter(isValidElement).map((child) => {
123
+ if (child.type !== Route) return child;
124
+ const { element, ...rest } = (child as VNode<RouteProps>).props;
125
+ if (element === undefined) return child;
126
+ return h(Route, { ...rest, key: child.key, component: () => element });
127
+ }), [props.children]);
128
+ const content = routes ? h(IsoRouter as ComponentType<typeof props>, { ...props, children }) : props.children;
129
+ return hasProvider ? (content as any) : h(LocationProvider, null, content);
130
+ }
131
+
132
+ /** Alias of `<Router>` (react-router style naming). */
133
+ export const Routes = Router;
134
+
135
+ export type RouteProps<P = {}> = {
136
+ path?: string;
137
+ default?: boolean;
138
+ } & ({ component: ComponentType<any>; element?: undefined } | { element: JSX.Element | null; component?: undefined }) &
139
+ P;
140
+
141
+ /** A route. Use `component={Page}` (gets `params` as props) or `element={<Page />}` (read `useParams()`). */
142
+ export function Route(props: RouteProps<Record<string, unknown>>) {
143
+ const { component, element, ...rest } = props as any;
144
+ return element !== undefined ? element : h(component, rest);
145
+ }
146
+
147
+ /** `<a>` with an app-relative `to`. */
148
+ export function Link(props: JSX.IntrinsicElements["a"] & { to?: string; href?: string }) {
149
+ const { to, href, ...rest } = props as any;
150
+ return h("a", { ...rest, href: withBase(globalBase, to ?? href ?? "/") });
151
+ }
152
+
153
+ export function useLocation(): LocationValue {
154
+ return isoUseLocation() as unknown as LocationValue;
155
+ }
156
+
157
+ /** Returns `navigate(to, { replace })` for app-relative paths. */
158
+ export function useNavigate(): (to: string, options?: { replace?: boolean }) => void {
159
+ const loc = useLocation();
160
+ return (to, options) => loc.route(to, options?.replace);
161
+ }
162
+
163
+ /** Route params of the matched `<Route path="/x/:id">`. */
164
+ export function useParams<P extends Record<string, string> = Record<string, string>>(): P {
165
+ return ((useRoute() as any)?.params ?? {}) as P;
166
+ }
167
+
168
+ export { useRoute };
@@ -0,0 +1,46 @@
1
+ import { EraspaceError } from "./errors.ts";
2
+
3
+ export interface UploadedObject {
4
+ key: string;
5
+ url: string;
6
+ size: number;
7
+ contentType: string;
8
+ name: string | null;
9
+ }
10
+
11
+ export interface StorageClient {
12
+ /** Store a file in this app's Postgres schema. v1 apps and their files are public. */
13
+ upload(body: Blob, options?: { name?: string; signal?: AbortSignal }): Promise<UploadedObject>;
14
+ url(key: string): string;
15
+ delete(key: string, options?: { signal?: AbortSignal }): Promise<void>;
16
+ }
17
+
18
+ export function createStorageClient(base: () => string): StorageClient {
19
+ function url(key?: string): string {
20
+ const path = `${base().replace(/\/+$/, "")}/_sb/storage`;
21
+ return key === undefined ? path : `${path}/${encodeURIComponent(key)}`;
22
+ }
23
+ async function check(response: Response): Promise<Record<string, unknown>> {
24
+ const payload = await response.json() as unknown;
25
+ if (!payload || typeof payload !== "object") throw new EraspaceError("StorageError", `HTTP ${response.status}`);
26
+ const result = payload as Record<string, unknown>;
27
+ if (!response.ok || result.ok !== true) throw EraspaceError.from(result.error, `Storage request failed: HTTP ${response.status}`);
28
+ return result;
29
+ }
30
+ return {
31
+ url,
32
+ async upload(body, options = {}) {
33
+ const headers = new Headers({ "content-type": body.type || "application/octet-stream" });
34
+ const name = options.name ?? (typeof File !== "undefined" && body instanceof File ? body.name : undefined);
35
+ if (name) headers.set("x-filename", encodeURIComponent(name));
36
+ const result = await check(await fetch(url(), { method: "POST", headers, body, signal: options.signal }));
37
+ if (typeof result.key !== "string" || typeof result.url !== "string" || typeof result.size !== "number" || typeof result.contentType !== "string" || (result.name !== null && typeof result.name !== "string")) {
38
+ throw new EraspaceError("StorageError", "Invalid upload response");
39
+ }
40
+ return { key: result.key, url: result.url, size: result.size, contentType: result.contentType, name: result.name };
41
+ },
42
+ async delete(key, options = {}) {
43
+ await check(await fetch(url(key), { method: "DELETE", signal: options.signal }));
44
+ },
45
+ };
46
+ }
@@ -0,0 +1,12 @@
1
+ /** Stable key for `(fn, args)`. Object keys are sorted so `{a,b}` and `{b,a}` dedupe. */
2
+ export function subKey(fn: string, args: unknown[]): string {
3
+ return fn + ":" + stableStringify(args);
4
+ }
5
+ export function stableStringify(v: unknown): string {
6
+ if (v === undefined) return "null";
7
+ if (v === null || typeof v !== "object") return JSON.stringify(v);
8
+ if (Array.isArray(v)) return "[" + v.map(stableStringify).join(",") + "]";
9
+ const o = v as Record<string, unknown>;
10
+ const keys = Object.keys(o).filter((k) => o[k] !== undefined).sort();
11
+ return "{" + keys.map((k) => JSON.stringify(k) + ":" + stableStringify(o[k])).join(",") + "}";
12
+ }
@@ -0,0 +1,51 @@
1
+ import { signal, type Signal } from "@preact/signals";
2
+ import { EraspaceError } from "./errors.ts";
3
+ import type { Patch } from "./result-diff.ts";
4
+
5
+ export interface FullResult {
6
+ value: unknown;
7
+ commitSeq: number;
8
+ revision: number;
9
+ }
10
+ export type DataMessage = { id: string } & (FullResult | {
11
+ patch: Patch[];
12
+ base: number;
13
+ revision: number;
14
+ commitSeq: number;
15
+ });
16
+
17
+ interface Layer {
18
+ mid: number;
19
+ apply: (prev: any) => any;
20
+ commitSeq?: number;
21
+ }
22
+
23
+ export class Subscription {
24
+ readonly value: Signal<any> = signal(undefined);
25
+ readonly error: Signal<EraspaceError | undefined> = signal(undefined);
26
+ server: any = undefined;
27
+ hasServer = false;
28
+ commitSeq = -1;
29
+ revision: number | undefined;
30
+ request = 0;
31
+ refs = 0;
32
+ layers: Layer[] = [];
33
+ timer: ReturnType<typeof setTimeout> | undefined;
34
+ // Resubscriptions take a new id so messages for the previous one are ignored.
35
+ id: string;
36
+ readonly fn: string;
37
+ readonly args: unknown[];
38
+ readonly key: string;
39
+ constructor(id: string, fn: string, args: unknown[], key: string) {
40
+ this.id = id;
41
+ this.fn = fn;
42
+ this.args = args;
43
+ this.key = key;
44
+ }
45
+
46
+ recompute(): void {
47
+ let v = this.server;
48
+ if (this.hasServer) for (const l of this.layers) v = l.apply(v);
49
+ this.value.value = v;
50
+ }
51
+ }
@@ -0,0 +1,125 @@
1
+ import type { ReadonlySignal } from "@preact/signals";
2
+ import type { EraspaceError } from "./errors.ts";
3
+ import type { StorageClient } from "./storage.ts";
4
+
5
+ import type {
6
+ ActionArgs,
7
+ ActionName,
8
+ ActionResult,
9
+ MutationArgs,
10
+ MutationName,
11
+ MutationResult,
12
+ PaginationOptions,
13
+ PaginationResult,
14
+ QueryArgs,
15
+ QueryName,
16
+ QueryResult,
17
+ } from "../server/index.ts";
18
+
19
+ export type Status = "connecting" | "open" | "closed";
20
+
21
+ export interface PresenceEntry<M = Record<string, unknown>> {
22
+ id: string;
23
+ meta: M;
24
+ /** All metas for this id (one per open connection). */
25
+ metas: M[];
26
+ }
27
+
28
+ /** Query names whose last argument is `PaginationOptions` and whose result is a `PaginationResult`. */
29
+ export type PaginatedQueryName<C> = {
30
+ [N in QueryName<C>]: QueryArgs<C, N> extends [...any[], PaginationOptions]
31
+ ? QueryResult<C, N> extends PaginationResult<any>
32
+ ? N
33
+ : never
34
+ : never;
35
+ }[QueryName<C>];
36
+ type PaginatedArgs<C, N extends QueryName<C>> = QueryArgs<C, N> extends [...infer P, PaginationOptions] ? P : never;
37
+ type PaginatedItem<C, N extends QueryName<C>> = QueryResult<C, N> extends PaginationResult<infer T> ? T : never;
38
+
39
+ export interface PaginatedQueryResult<T> {
40
+ /** Every loaded row, in page order. */
41
+ results: T[];
42
+ status: "LoadingFirstPage" | "CanLoadMore" | "LoadingMore" | "Exhausted";
43
+ isLoading: boolean;
44
+ loadMore(numItems?: number): void;
45
+ reset(): void;
46
+ }
47
+
48
+ /** The local cache handed to optimistic updaters. */
49
+ export interface OptimisticStore<C> {
50
+ get<N extends QueryName<C>>(name: N, args: QueryArgs<C, N>): QueryResult<C, N> | undefined;
51
+ set<N extends QueryName<C>>(name: N, args: QueryArgs<C, N>, value: QueryResult<C, N>): void;
52
+ /** Apply `updater` to the cached result of `name(...args)` if it is loaded. */
53
+ update<N extends QueryName<C>>(
54
+ name: N,
55
+ args: QueryArgs<C, N>,
56
+ updater: (prev: QueryResult<C, N>) => QueryResult<C, N>,
57
+ ): void;
58
+ }
59
+
60
+ export interface MutationOptions<C, A extends any[]> {
61
+ optimistic?: (store: OptimisticStore<C>, ...args: A) => void;
62
+ }
63
+
64
+ export interface Room<M = Record<string, unknown>, S extends Record<string, unknown> = Record<string, unknown>> {
65
+ /** Members currently in the room. */
66
+ presence: PresenceEntry<M>[];
67
+ /** This connection's public guest id. Changes on reconnect. Empty until the room join completes. */
68
+ self: string;
69
+ /** Send `event` to every other member. */
70
+ broadcast(event: string, payload?: unknown): void;
71
+ /** Listen for `event` broadcasts. Returns an unsubscribe function. Call it in an effect. */
72
+ on(event: string, cb: (payload: any, from: string | undefined) => void): () => void;
73
+ /** Ephemeral shared state (kept by the room process, never stored). */
74
+ state: Partial<S>;
75
+ setState<K extends keyof S & string>(key: K, value: S[K]): void;
76
+ }
77
+
78
+ export interface EraspaceClient<C> {
79
+ readonly storage: StorageClient;
80
+ /** Subscribe to a query. Returns `undefined` until the first result arrives, then live values. */
81
+ useQuery<N extends QueryName<C>>(name: N, ...args: QueryArgs<C, N>): QueryResult<C, N> | undefined;
82
+ /** Pass computed signals directly to JSX text nodes for updates without a component render. */
83
+ useQuerySignal<N extends QueryName<C>>(name: N, ...args: QueryArgs<C, N>): ReadonlySignal<QueryResult<C, N> | undefined>;
84
+ /** Re-render only when the selected result changes. */
85
+ useQuerySelector<N extends QueryName<C>, T>(name: N, args: QueryArgs<C, N>, select: (value: QueryResult<C, N> | undefined) => T): T;
86
+ useMutation<N extends MutationName<C>>(
87
+ name: N,
88
+ options?: MutationOptions<C, MutationArgs<C, N>>,
89
+ ): (...args: MutationArgs<C, N>) => Promise<MutationResult<C, N>>;
90
+ useAction<N extends ActionName<C>>(name: N): (...args: ActionArgs<C, N>) => Promise<ActionResult<C, N>>;
91
+ usePaginatedQuery<N extends PaginatedQueryName<C>>(
92
+ name: N,
93
+ args: PaginatedArgs<C, N>,
94
+ options?: { initialNumItems?: number },
95
+ ): PaginatedQueryResult<PaginatedItem<C, N>>;
96
+ /** Join `room` and return its members. `meta` is this tab's presence metadata (cursor, typing, name...). */
97
+ usePresence<M extends Record<string, unknown> = Record<string, unknown>>(room: string, meta?: M): PresenceEntry<M>[];
98
+ useRoom<M extends Record<string, unknown> = Record<string, unknown>, S extends Record<string, unknown> = Record<string, unknown>>(
99
+ room: string,
100
+ meta?: M,
101
+ ): Room<M, S>;
102
+
103
+ query<N extends QueryName<C>>(name: N, ...args: QueryArgs<C, N>): Promise<QueryResult<C, N>>;
104
+ mutation<N extends MutationName<C>>(name: N, ...args: MutationArgs<C, N>): Promise<MutationResult<C, N>>;
105
+ action<N extends ActionName<C>>(name: N, ...args: ActionArgs<C, N>): Promise<ActionResult<C, N>>;
106
+ /** Imperative live subscription. Returns an unsubscribe function. */
107
+ subscribe<N extends QueryName<C>>(
108
+ name: N,
109
+ args: QueryArgs<C, N>,
110
+ onValue: (value: QueryResult<C, N>) => void,
111
+ onError?: (error: EraspaceError) => void,
112
+ ): () => void;
113
+
114
+ readonly app: string;
115
+ readonly base: string;
116
+ /** This connection's public guest id. Changes on reconnect. Empty until the socket has joined. */
117
+ readonly guestId: string;
118
+ /** Socket status: `connecting`, `open` or `closed`. */
119
+ readonly status: ReadonlySignal<Status>;
120
+ /** Latest deployed version seen by this client. */
121
+ readonly version: ReadonlySignal<number>;
122
+ /** Called after each redeploy (after subscriptions are re-sent). */
123
+ onDeployed(cb: (version: number) => void): () => void;
124
+ close(): void;
125
+ }