snapback4 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -0
- package/dist/contract.d.ts +99 -0
- package/dist/contract.js +12 -0
- package/dist/mock.d.ts +34 -0
- package/dist/mock.js +129 -0
- package/dist/react.d.ts +25 -0
- package/dist/react.js +113 -0
- package/package.json +49 -0
package/README.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# snapback4
|
|
2
|
+
|
|
3
|
+
The client for Snapback 4, one of two parallel candidates for the successor
|
|
4
|
+
to Snapback 2 (see LLP 3000 in the repository). Version 0.0.x is week one:
|
|
5
|
+
this package carries the **contract** every screen is written against, a
|
|
6
|
+
**labelled mock client** so an application can be written before the
|
|
7
|
+
mechanism exists, and the **React hooks** over the contract. Nothing here
|
|
8
|
+
talks to a server yet; the replica, the outbox and the interpreter arrive in
|
|
9
|
+
weeks two and three.
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
import { isReady, type Read, type Write } from "snapback4/contract";
|
|
13
|
+
import { createMockClient, page } from "snapback4/mock";
|
|
14
|
+
import { SnapbackProvider, useQuery, useMutation } from "snapback4/react";
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
The contract fits on an index card. A read is `{ data, complete, fresh,
|
|
18
|
+
since?, next? } | { loading } | { denied }`; a row this device changed and
|
|
19
|
+
the server has not confirmed carries `pending: true`; a write is `pending |
|
|
20
|
+
sent | failed(why)`; the app's link is `online | offline | signed-out`.
|
|
21
|
+
Everything else is the runtime's business.
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/** An opaque page position minted by whoever evaluated the page. Pass it back unchanged. */
|
|
2
|
+
export type Cursor = string & {
|
|
3
|
+
readonly __cursor: "Cursor";
|
|
4
|
+
};
|
|
5
|
+
/** The authenticated subject a rule sees as `viewer`. */
|
|
6
|
+
export type Principal = string & {
|
|
7
|
+
readonly __principal: "Principal";
|
|
8
|
+
};
|
|
9
|
+
/** Why something was refused. `family` and `code` are stable; `rewrite` compiles. */
|
|
10
|
+
export interface Refusal {
|
|
11
|
+
readonly code: string;
|
|
12
|
+
readonly family: string;
|
|
13
|
+
readonly message: string;
|
|
14
|
+
readonly site?: string;
|
|
15
|
+
readonly rewrite?: string;
|
|
16
|
+
readonly guide?: string;
|
|
17
|
+
readonly rule?: string;
|
|
18
|
+
readonly retryable?: boolean;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* A read. `complete` is false beyond a sync horizon or at an online-only site.
|
|
22
|
+
* `fresh` is transport truth: the value reflects the server now. When it does
|
|
23
|
+
* not, `since` is the wall-clock time (ms) of the newest server state it does
|
|
24
|
+
* reflect, so a screen can say "as of three minutes ago".
|
|
25
|
+
*/
|
|
26
|
+
export type Read<T> = {
|
|
27
|
+
readonly data: T;
|
|
28
|
+
readonly complete: boolean;
|
|
29
|
+
readonly fresh: boolean;
|
|
30
|
+
readonly since?: number;
|
|
31
|
+
readonly next?: Cursor | null;
|
|
32
|
+
} | {
|
|
33
|
+
readonly loading: true;
|
|
34
|
+
} | {
|
|
35
|
+
readonly denied: Refusal;
|
|
36
|
+
};
|
|
37
|
+
/** A row this device changed and the server has not yet confirmed carries `pending: true`. */
|
|
38
|
+
export interface Pending {
|
|
39
|
+
readonly pending?: boolean;
|
|
40
|
+
}
|
|
41
|
+
/** A write. Three outcomes, all terminal or durable. `id` is client-minted and stable across replay. */
|
|
42
|
+
export type Write = {
|
|
43
|
+
readonly state: "pending";
|
|
44
|
+
readonly id: string;
|
|
45
|
+
} | {
|
|
46
|
+
readonly state: "sent";
|
|
47
|
+
readonly id: string;
|
|
48
|
+
readonly seq: number;
|
|
49
|
+
} | {
|
|
50
|
+
readonly state: "failed";
|
|
51
|
+
readonly id: string;
|
|
52
|
+
readonly why: Refusal;
|
|
53
|
+
};
|
|
54
|
+
/** The app's relationship to its server. */
|
|
55
|
+
export type Link = "online" | "offline" | "signed-out";
|
|
56
|
+
/** A named operation. `Args` and `Result` are inferred from the authored program. */
|
|
57
|
+
export interface Op<Args, Result> {
|
|
58
|
+
readonly kind: "query" | "mutation";
|
|
59
|
+
readonly name: string;
|
|
60
|
+
readonly __args?: Args;
|
|
61
|
+
readonly __result?: Result;
|
|
62
|
+
}
|
|
63
|
+
export type Query<Args, Result> = Op<Args, Result> & {
|
|
64
|
+
readonly kind: "query";
|
|
65
|
+
};
|
|
66
|
+
export type Mutation<Args, Result> = Op<Args, Result> & {
|
|
67
|
+
readonly kind: "mutation";
|
|
68
|
+
};
|
|
69
|
+
export type ArgsOf<O> = O extends Op<infer A, unknown> ? A : never;
|
|
70
|
+
export type ResultOf<O> = O extends Op<unknown, infer R> ? R : never;
|
|
71
|
+
/** A live read: the current value and a way to hear it change. */
|
|
72
|
+
export interface Observed<T> {
|
|
73
|
+
getSnapshot(): Read<T>;
|
|
74
|
+
subscribe(notify: () => void): () => void;
|
|
75
|
+
close(): void;
|
|
76
|
+
}
|
|
77
|
+
/** The whole client, as a screen sees it. Everything else is `why`. */
|
|
78
|
+
export interface Client {
|
|
79
|
+
query<A, R>(op: Query<A, R>, args: A): Promise<Read<R>>;
|
|
80
|
+
observe<A, R>(op: Query<A, R>, args: A): Observed<R>;
|
|
81
|
+
mutate<A, R>(op: Mutation<A, R>, args: A): Promise<Write>;
|
|
82
|
+
link(): Link;
|
|
83
|
+
onLink(notify: (link: Link) => void): () => void;
|
|
84
|
+
viewer(): Principal | null;
|
|
85
|
+
close(): Promise<void>;
|
|
86
|
+
}
|
|
87
|
+
export declare function isReady<T>(read: Read<T>): read is {
|
|
88
|
+
readonly data: T;
|
|
89
|
+
readonly complete: boolean;
|
|
90
|
+
readonly fresh: boolean;
|
|
91
|
+
readonly since?: number;
|
|
92
|
+
readonly next?: Cursor | null;
|
|
93
|
+
};
|
|
94
|
+
export declare function isLoading<T>(read: Read<T>): read is {
|
|
95
|
+
readonly loading: true;
|
|
96
|
+
};
|
|
97
|
+
export declare function isDenied<T>(read: Read<T>): read is {
|
|
98
|
+
readonly denied: Refusal;
|
|
99
|
+
};
|
package/dist/contract.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// The index card (LLP 3000 §2; LLP 2000.000 §8). These are the only words a
|
|
2
|
+
// screen may need. The mechanism is built to fit them; a screen that needs a
|
|
3
|
+
// word not here is a defect in the mechanism, not a gap in the card.
|
|
4
|
+
export function isReady(read) {
|
|
5
|
+
return "data" in read;
|
|
6
|
+
}
|
|
7
|
+
export function isLoading(read) {
|
|
8
|
+
return "loading" in read;
|
|
9
|
+
}
|
|
10
|
+
export function isDenied(read) {
|
|
11
|
+
return "denied" in read;
|
|
12
|
+
}
|
package/dist/mock.d.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Client, Cursor, Link, Principal, Refusal } from "./contract.ts";
|
|
2
|
+
export type Row = Record<string, unknown> & {
|
|
3
|
+
id: string;
|
|
4
|
+
pending?: boolean;
|
|
5
|
+
};
|
|
6
|
+
export type Tables = Record<string, Map<string, Row>>;
|
|
7
|
+
/** A query function may return a page: rows plus the cursor of the next page. */
|
|
8
|
+
export type Page<R> = {
|
|
9
|
+
readonly page: true;
|
|
10
|
+
readonly data: R;
|
|
11
|
+
readonly next: Cursor | null;
|
|
12
|
+
};
|
|
13
|
+
export declare function page<R>(data: R, next: Cursor | null): Page<R>;
|
|
14
|
+
export interface MockOps {
|
|
15
|
+
readonly queries: Record<string, (tables: Tables, args: never, viewer: Principal | null) => unknown>;
|
|
16
|
+
/** Returns the ids of rows it created or changed, or a Refusal. */
|
|
17
|
+
readonly mutations: Record<string, (tables: Tables, args: never, viewer: Principal | null, mint: () => string) => readonly string[] | Refusal>;
|
|
18
|
+
}
|
|
19
|
+
export interface MockClient extends Client {
|
|
20
|
+
readonly tables: Tables;
|
|
21
|
+
setLink(link: Link): void;
|
|
22
|
+
/** Settle every write the fake server has admitted. */
|
|
23
|
+
settle(): Promise<void>;
|
|
24
|
+
}
|
|
25
|
+
export interface MockOptions {
|
|
26
|
+
readonly viewer: Principal | null;
|
|
27
|
+
readonly tables: Tables;
|
|
28
|
+
readonly ops: MockOps;
|
|
29
|
+
readonly link?: Link;
|
|
30
|
+
readonly commitDelayMs?: number;
|
|
31
|
+
}
|
|
32
|
+
/** MOCK id: time-ordered and unique per process; the real client mints 128-bit ids. */
|
|
33
|
+
export declare function mockId(): string;
|
|
34
|
+
export declare function createMockClient(options: MockOptions): MockClient;
|
package/dist/mock.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// MOCK — labelled as LLP 2000 §4 requires. No server, no store, no
|
|
2
|
+
// interpreter, no transport: operations are plain functions over in-memory
|
|
3
|
+
// tables, commits settle after a virtual delay, and the link is a switch the
|
|
4
|
+
// app flips. Its only job is to let the reference application be written
|
|
5
|
+
// against the card before the mechanism exists. Nothing here is measured.
|
|
6
|
+
export function page(data, next) {
|
|
7
|
+
return { page: true, data, next };
|
|
8
|
+
}
|
|
9
|
+
function isPage(value) {
|
|
10
|
+
return typeof value === "object" && value !== null && value.page === true;
|
|
11
|
+
}
|
|
12
|
+
let minted = 0;
|
|
13
|
+
/** MOCK id: time-ordered and unique per process; the real client mints 128-bit ids. */
|
|
14
|
+
export function mockId() {
|
|
15
|
+
minted += 1;
|
|
16
|
+
return `m${Date.now().toString(36)}-${minted.toString(36)}`;
|
|
17
|
+
}
|
|
18
|
+
export function createMockClient(options) {
|
|
19
|
+
const tables = options.tables;
|
|
20
|
+
let link = options.link ?? "online";
|
|
21
|
+
let seq = 0;
|
|
22
|
+
let version = 0;
|
|
23
|
+
let lastServer = Date.now();
|
|
24
|
+
const listeners = new Set();
|
|
25
|
+
const linkListeners = new Set();
|
|
26
|
+
const queue = [];
|
|
27
|
+
const delay = options.commitDelayMs ?? 30;
|
|
28
|
+
const bump = () => { version += 1; for (const l of listeners)
|
|
29
|
+
l(); };
|
|
30
|
+
const isRefusal = (v) => typeof v === "object" && v !== null && "code" in v && "family" in v;
|
|
31
|
+
const evaluate = (op, args) => {
|
|
32
|
+
const fn = options.ops.queries[op.name];
|
|
33
|
+
if (!fn)
|
|
34
|
+
return { denied: { code: "E_OP", family: "op", message: `unknown query ${op.name}` } };
|
|
35
|
+
if (link === "signed-out")
|
|
36
|
+
return { denied: { code: "E_AUTH", family: "auth", message: "sign in to read" } };
|
|
37
|
+
const value = fn(tables, args, options.viewer);
|
|
38
|
+
const fresh = link === "online";
|
|
39
|
+
if (isPage(value))
|
|
40
|
+
return fresh ? { data: value.data, complete: true, fresh, next: value.next } : { data: value.data, complete: true, fresh, since: lastServer, next: value.next };
|
|
41
|
+
return fresh ? { data: value, complete: true, fresh } : { data: value, complete: true, fresh, since: lastServer };
|
|
42
|
+
};
|
|
43
|
+
function settleOne(entry) {
|
|
44
|
+
seq += 1;
|
|
45
|
+
for (const id of entry.touched) {
|
|
46
|
+
const row = tables[entry.table]?.get(id);
|
|
47
|
+
if (row)
|
|
48
|
+
delete row.pending;
|
|
49
|
+
}
|
|
50
|
+
lastServer = Date.now();
|
|
51
|
+
entry.resolve({ state: "sent", id: entry.id, seq });
|
|
52
|
+
}
|
|
53
|
+
const client = {
|
|
54
|
+
tables,
|
|
55
|
+
async query(op, args) {
|
|
56
|
+
return evaluate(op, args);
|
|
57
|
+
},
|
|
58
|
+
observe(op, args) {
|
|
59
|
+
let seen = -1;
|
|
60
|
+
let snapshot = { loading: true };
|
|
61
|
+
const subs = new Set();
|
|
62
|
+
const listener = () => { for (const s of subs)
|
|
63
|
+
s(); };
|
|
64
|
+
listeners.add(listener);
|
|
65
|
+
return {
|
|
66
|
+
getSnapshot() {
|
|
67
|
+
if (seen !== version) {
|
|
68
|
+
seen = version;
|
|
69
|
+
snapshot = evaluate(op, args);
|
|
70
|
+
}
|
|
71
|
+
return snapshot;
|
|
72
|
+
},
|
|
73
|
+
subscribe(notify) { subs.add(notify); return () => subs.delete(notify); },
|
|
74
|
+
close() { listeners.delete(listener); subs.clear(); },
|
|
75
|
+
};
|
|
76
|
+
},
|
|
77
|
+
async mutate(op, args) {
|
|
78
|
+
const fn = options.ops.mutations[op.name];
|
|
79
|
+
const id = mockId();
|
|
80
|
+
if (!fn)
|
|
81
|
+
return { state: "failed", id, why: { code: "E_OP", family: "op", message: `unknown mutation ${op.name}` } };
|
|
82
|
+
if (link === "signed-out")
|
|
83
|
+
return { state: "failed", id, why: { code: "E_AUTH", family: "auth", message: "sign in to write" } };
|
|
84
|
+
const outcome = fn(tables, args, options.viewer, () => id);
|
|
85
|
+
if (isRefusal(outcome))
|
|
86
|
+
return { state: "failed", id, why: outcome };
|
|
87
|
+
const table = op.name.split(".")[0] ?? "";
|
|
88
|
+
for (const rowId of outcome) {
|
|
89
|
+
const row = tables[table]?.get(rowId);
|
|
90
|
+
if (row)
|
|
91
|
+
row.pending = true;
|
|
92
|
+
}
|
|
93
|
+
bump();
|
|
94
|
+
if (link === "offline") {
|
|
95
|
+
// Durable admission without a server: the write stays pending until the link returns.
|
|
96
|
+
return await new Promise((resolve) => {
|
|
97
|
+
queue.push({ id, touched: outcome, table, resolve: () => { } });
|
|
98
|
+
resolve({ state: "pending", id });
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
return await new Promise((resolve) => {
|
|
102
|
+
const entry = { id, touched: outcome, table, resolve };
|
|
103
|
+
setTimeout(() => { settleOne(entry); bump(); }, delay);
|
|
104
|
+
});
|
|
105
|
+
},
|
|
106
|
+
link: () => link,
|
|
107
|
+
onLink(notify) { linkListeners.add(notify); return () => linkListeners.delete(notify); },
|
|
108
|
+
viewer: () => options.viewer,
|
|
109
|
+
async close() { listeners.clear(); linkListeners.clear(); },
|
|
110
|
+
setLink(next) {
|
|
111
|
+
link = next;
|
|
112
|
+
for (const l of linkListeners)
|
|
113
|
+
l(next);
|
|
114
|
+
if (next === "online")
|
|
115
|
+
void client.settle();
|
|
116
|
+
bump();
|
|
117
|
+
},
|
|
118
|
+
async settle() {
|
|
119
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
120
|
+
while (queue.length > 0) {
|
|
121
|
+
const entry = queue.shift();
|
|
122
|
+
if (entry)
|
|
123
|
+
settleOne(entry);
|
|
124
|
+
}
|
|
125
|
+
bump();
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
return client;
|
|
129
|
+
}
|
package/dist/react.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import type { Client, Cursor, Link, Mutation, Principal, Query, Read, Write } from "./contract.ts";
|
|
3
|
+
export declare function SnapbackProvider({ client, children }: {
|
|
4
|
+
client: Client;
|
|
5
|
+
children: ReactNode;
|
|
6
|
+
}): import("react").JSX.Element;
|
|
7
|
+
export declare function useClient(): Client;
|
|
8
|
+
/** One live read. Re-renders when the value changes; never throws. */
|
|
9
|
+
export declare function useQuery<A, R>(op: Query<A, R>, args: A): Read<R>;
|
|
10
|
+
export declare function usePage<A extends {
|
|
11
|
+
c?: Cursor | null;
|
|
12
|
+
}, R extends readonly unknown[]>(op: Query<A, R>, args: Omit<A, "c">): {
|
|
13
|
+
items: R[number][];
|
|
14
|
+
hasMore: boolean;
|
|
15
|
+
read: Read<R>;
|
|
16
|
+
loadMore: () => void;
|
|
17
|
+
};
|
|
18
|
+
/** A write. `run` never throws; `last` is the most recent outcome. */
|
|
19
|
+
export declare function useMutation<A, R>(op: Mutation<A, R>): {
|
|
20
|
+
run: (args: A) => Promise<Write>;
|
|
21
|
+
last: Write | undefined;
|
|
22
|
+
inFlight: boolean;
|
|
23
|
+
};
|
|
24
|
+
export declare function useLink(): Link;
|
|
25
|
+
export declare function useViewer(): Principal | null;
|
package/dist/react.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
// The React arm: one provider, five hooks, nothing a screen must reconcile.
|
|
3
|
+
// Every hook returns a value from the card (contract.ts) and nothing else.
|
|
4
|
+
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
|
5
|
+
import { isReady } from "./contract.js";
|
|
6
|
+
const ClientContext = createContext(null);
|
|
7
|
+
export function SnapbackProvider({ client, children }) {
|
|
8
|
+
return _jsx(ClientContext.Provider, { value: client, children: children });
|
|
9
|
+
}
|
|
10
|
+
export function useClient() {
|
|
11
|
+
const client = useContext(ClientContext);
|
|
12
|
+
if (!client)
|
|
13
|
+
throw new Error("useClient: no <SnapbackProvider> above this component");
|
|
14
|
+
return client;
|
|
15
|
+
}
|
|
16
|
+
const LOADING = { loading: true };
|
|
17
|
+
/** One live read. Re-renders when the value changes; never throws. */
|
|
18
|
+
export function useQuery(op, args) {
|
|
19
|
+
const client = useClient();
|
|
20
|
+
const key = JSON.stringify(args);
|
|
21
|
+
const observed = useMemo(() => client.observe(op, args), [client, op, key]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
22
|
+
useEffect(() => () => observed.close(), [observed]);
|
|
23
|
+
return useSyncExternalStore(observed.subscribe, observed.getSnapshot, () => LOADING);
|
|
24
|
+
}
|
|
25
|
+
/** A page resource: the pages a screen has asked for, concatenated, and a way to ask for one more. */
|
|
26
|
+
class Pages {
|
|
27
|
+
client;
|
|
28
|
+
op;
|
|
29
|
+
args;
|
|
30
|
+
observed = [];
|
|
31
|
+
cursors = [null];
|
|
32
|
+
subs = new Set();
|
|
33
|
+
cache = null;
|
|
34
|
+
unsubscribe = [];
|
|
35
|
+
constructor(client, op, args) {
|
|
36
|
+
this.client = client;
|
|
37
|
+
this.op = op;
|
|
38
|
+
this.args = args;
|
|
39
|
+
this.open(null);
|
|
40
|
+
}
|
|
41
|
+
open(c) {
|
|
42
|
+
const observed = this.client.observe(this.op, { ...this.args, c });
|
|
43
|
+
this.observed.push(observed);
|
|
44
|
+
this.unsubscribe.push(observed.subscribe(() => this.notify()));
|
|
45
|
+
}
|
|
46
|
+
notify() { this.cache = null; for (const s of this.subs)
|
|
47
|
+
s(); }
|
|
48
|
+
subscribe = (notify) => { this.subs.add(notify); return () => { this.subs.delete(notify); }; };
|
|
49
|
+
getSnapshot = () => {
|
|
50
|
+
if (this.cache)
|
|
51
|
+
return this.cache;
|
|
52
|
+
const reads = this.observed.map((o) => o.getSnapshot());
|
|
53
|
+
const items = [];
|
|
54
|
+
let hasMore = false;
|
|
55
|
+
let last = LOADING;
|
|
56
|
+
for (const read of reads) {
|
|
57
|
+
last = read;
|
|
58
|
+
if (!isReady(read))
|
|
59
|
+
break;
|
|
60
|
+
items.push(...read.data);
|
|
61
|
+
hasMore = read.next != null;
|
|
62
|
+
}
|
|
63
|
+
this.cache = { items, hasMore, read: last };
|
|
64
|
+
return this.cache;
|
|
65
|
+
};
|
|
66
|
+
loadMore = () => {
|
|
67
|
+
const last = this.observed[this.observed.length - 1]?.getSnapshot();
|
|
68
|
+
if (!last || !isReady(last) || last.next == null)
|
|
69
|
+
return;
|
|
70
|
+
if (this.cursors.includes(last.next))
|
|
71
|
+
return;
|
|
72
|
+
this.cursors.push(last.next);
|
|
73
|
+
this.open(last.next);
|
|
74
|
+
this.notify();
|
|
75
|
+
};
|
|
76
|
+
close() { for (const u of this.unsubscribe)
|
|
77
|
+
u(); for (const o of this.observed)
|
|
78
|
+
o.close(); }
|
|
79
|
+
}
|
|
80
|
+
export function usePage(op, args) {
|
|
81
|
+
const client = useClient();
|
|
82
|
+
const key = JSON.stringify(args);
|
|
83
|
+
const pages = useMemo(() => new Pages(client, op, args), [client, op, key]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
84
|
+
useEffect(() => () => pages.close(), [pages]);
|
|
85
|
+
const snapshot = useSyncExternalStore(pages.subscribe, pages.getSnapshot, pages.getSnapshot);
|
|
86
|
+
return { items: snapshot.items, hasMore: snapshot.hasMore, read: snapshot.read, loadMore: pages.loadMore };
|
|
87
|
+
}
|
|
88
|
+
/** A write. `run` never throws; `last` is the most recent outcome. */
|
|
89
|
+
export function useMutation(op) {
|
|
90
|
+
const client = useClient();
|
|
91
|
+
const [last, setLast] = useState(undefined);
|
|
92
|
+
const [inFlight, setInFlight] = useState(false);
|
|
93
|
+
const alive = useRef(true);
|
|
94
|
+
useEffect(() => () => { alive.current = false; }, []);
|
|
95
|
+
const run = useCallback(async (args) => {
|
|
96
|
+
setInFlight(true);
|
|
97
|
+
const outcome = await client.mutate(op, args);
|
|
98
|
+
if (alive.current) {
|
|
99
|
+
setLast(outcome);
|
|
100
|
+
setInFlight(false);
|
|
101
|
+
}
|
|
102
|
+
return outcome;
|
|
103
|
+
}, [client, op]);
|
|
104
|
+
return { run, last, inFlight };
|
|
105
|
+
}
|
|
106
|
+
export function useLink() {
|
|
107
|
+
const client = useClient();
|
|
108
|
+
const subscribe = useCallback((notify) => client.onLink(() => notify()), [client]);
|
|
109
|
+
return useSyncExternalStore(subscribe, client.link, client.link);
|
|
110
|
+
}
|
|
111
|
+
export function useViewer() {
|
|
112
|
+
return useClient().viewer();
|
|
113
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "snapback4",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Snapback 4 client: the contract (the card), a labelled mock client, and React hooks. Week one of LLP 3000; nothing here talks to a server yet.",
|
|
6
|
+
"exports": {
|
|
7
|
+
"./contract": {
|
|
8
|
+
"types": "./dist/contract.d.ts",
|
|
9
|
+
"import": "./dist/contract.js"
|
|
10
|
+
},
|
|
11
|
+
"./mock": {
|
|
12
|
+
"types": "./dist/mock.d.ts",
|
|
13
|
+
"import": "./dist/mock.js"
|
|
14
|
+
},
|
|
15
|
+
"./react": {
|
|
16
|
+
"types": "./dist/react.d.ts",
|
|
17
|
+
"import": "./dist/react.js"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"scripts": {
|
|
21
|
+
"build": "tsc -p tsconfig.build.json",
|
|
22
|
+
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@types/react": "19.3.0",
|
|
26
|
+
"@types/react-dom": "19.3.0",
|
|
27
|
+
"react": "19.3.0",
|
|
28
|
+
"react-dom": "19.3.0",
|
|
29
|
+
"typescript": "5.9.3"
|
|
30
|
+
},
|
|
31
|
+
"peerDependencies": {
|
|
32
|
+
"react": "^19.0.0"
|
|
33
|
+
},
|
|
34
|
+
"license": "MIT",
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "https://github.com/expo/snapback.git",
|
|
38
|
+
"directory": "snapback4/packages/snapback4"
|
|
39
|
+
},
|
|
40
|
+
"files": [
|
|
41
|
+
"dist",
|
|
42
|
+
"README.md"
|
|
43
|
+
],
|
|
44
|
+
"peerDependenciesMeta": {
|
|
45
|
+
"react": {
|
|
46
|
+
"optional": true
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|