@vantage-ui/client 0.0.0-stage → 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/CHANGELOG.md +46 -0
- package/bin/vantage-client.js +5 -0
- package/dist/client-connection.d.ts +41 -0
- package/dist/client-connection.js +260 -0
- package/dist/client-core.d.ts +70 -0
- package/dist/client-core.js +140 -0
- package/dist/client-loading.d.ts +13 -0
- package/dist/client-loading.js +229 -0
- package/dist/client-runs.d.ts +18 -0
- package/dist/client-runs.js +88 -0
- package/dist/client-writes.d.ts +14 -0
- package/dist/client-writes.js +114 -0
- package/dist/client.d.ts +32 -0
- package/dist/client.js +61 -0
- package/dist/codegen/cli.d.ts +8 -0
- package/dist/codegen/cli.js +54 -0
- package/dist/codegen/emit.d.ts +3 -0
- package/dist/codegen/emit.js +190 -0
- package/dist/codegen/fetch.d.ts +4 -0
- package/dist/codegen/fetch.js +41 -0
- package/dist/codegen/meta.d.ts +13 -0
- package/dist/codegen/meta.js +59 -0
- package/dist/codegen/runs.d.ts +20 -0
- package/dist/codegen/runs.js +58 -0
- package/dist/codegen/schema.d.ts +97 -0
- package/dist/codegen/schema.js +23 -0
- package/dist/collection-cell.d.ts +69 -0
- package/dist/collection-cell.js +153 -0
- package/dist/collection.d.ts +32 -0
- package/dist/collection.js +122 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +14 -0
- package/dist/mock-query.d.ts +9 -0
- package/dist/mock-query.js +75 -0
- package/dist/mock-runs.d.ts +61 -0
- package/dist/mock-runs.js +165 -0
- package/dist/mock-store.d.ts +78 -0
- package/dist/mock-store.js +158 -0
- package/dist/mock-transport.d.ts +16 -0
- package/dist/mock-transport.js +38 -0
- package/dist/model.d.ts +5 -0
- package/dist/model.js +169 -0
- package/dist/persist.d.ts +47 -0
- package/dist/persist.js +108 -0
- package/dist/protocol.d.ts +236 -0
- package/dist/protocol.js +5 -0
- package/dist/react.d.ts +59 -0
- package/dist/react.js +169 -0
- package/dist/reserved.d.ts +6 -0
- package/dist/reserved.js +13 -0
- package/dist/run/frames.d.ts +6 -0
- package/dist/run/frames.js +24 -0
- package/dist/run/hooks.d.ts +18 -0
- package/dist/run/hooks.js +87 -0
- package/dist/run/messages.d.ts +20 -0
- package/dist/run/messages.js +42 -0
- package/dist/run/outcome.d.ts +12 -0
- package/dist/run/outcome.js +27 -0
- package/dist/run/run.d.ts +82 -0
- package/dist/run/run.js +246 -0
- package/dist/run/storage.d.ts +3 -0
- package/dist/run/storage.js +20 -0
- package/dist/run/types.d.ts +92 -0
- package/dist/run/types.js +3 -0
- package/dist/run/variables.d.ts +26 -0
- package/dist/run/variables.js +70 -0
- package/dist/schema-meta.d.ts +91 -0
- package/dist/schema-meta.js +3 -0
- package/dist/signal.d.ts +45 -0
- package/dist/signal.js +163 -0
- package/dist/store.d.ts +45 -0
- package/dist/store.js +144 -0
- package/dist/table.d.ts +62 -0
- package/dist/table.js +82 -0
- package/dist/testing.d.ts +61 -0
- package/dist/testing.js +195 -0
- package/dist/transport.d.ts +28 -0
- package/dist/transport.js +32 -0
- package/dist/types.d.ts +159 -0
- package/dist/types.js +2 -0
- package/dist/util.d.ts +18 -0
- package/dist/util.js +46 -0
- package/package.json +54 -4
- package/skills/vantage-client/SKILL.md +390 -0
- package/skills/vantage-client/references/protocol.md +67 -0
- package/skills/vantage-client/references/react.md +242 -0
- package/skills/vantage-client/references/testing.md +173 -0
- package/README.md +0 -3
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/** The wire protocol this client speaks; sent in `hello`. */
|
|
2
|
+
export declare const PROTOCOL = 1;
|
|
3
|
+
/** What this client understands beyond the protocol; sent in `hello`. */
|
|
4
|
+
export declare const FEATURES: string[];
|
|
5
|
+
export type Json = null | boolean | number | string | Json[] | {
|
|
6
|
+
[key: string]: Json;
|
|
7
|
+
};
|
|
8
|
+
export type WireRecord = {
|
|
9
|
+
id: string;
|
|
10
|
+
$actions?: string[];
|
|
11
|
+
} & Record<string, unknown>;
|
|
12
|
+
export type Op = "eq" | "ne" | "gt" | "gte" | "lt" | "lte" | "in" | "not_in" | "like";
|
|
13
|
+
export type Where = [column: string, op: Op, value: unknown];
|
|
14
|
+
export type Sort = [column: string, direction: "asc" | "desc"];
|
|
15
|
+
export interface CollectionSpec {
|
|
16
|
+
table: string;
|
|
17
|
+
where?: Where[];
|
|
18
|
+
search?: string;
|
|
19
|
+
sort?: Sort;
|
|
20
|
+
via?: {
|
|
21
|
+
table: string;
|
|
22
|
+
id: string;
|
|
23
|
+
ref: string;
|
|
24
|
+
};
|
|
25
|
+
limit?: number;
|
|
26
|
+
}
|
|
27
|
+
export interface Cursor {
|
|
28
|
+
epoch: string;
|
|
29
|
+
seq: number;
|
|
30
|
+
}
|
|
31
|
+
export type ChangeEvent = {
|
|
32
|
+
k: "upsert";
|
|
33
|
+
table: string;
|
|
34
|
+
id: string;
|
|
35
|
+
record: WireRecord;
|
|
36
|
+
} | {
|
|
37
|
+
k: "removed";
|
|
38
|
+
table: string;
|
|
39
|
+
id: string;
|
|
40
|
+
} | {
|
|
41
|
+
k: "invalidated";
|
|
42
|
+
table: string;
|
|
43
|
+
};
|
|
44
|
+
export type ErrorKind = "invalid" | "not_found" | "forbidden" | "unavailable" | "conflict" | "failed" | "offline";
|
|
45
|
+
export type ServerFrame = {
|
|
46
|
+
t: "welcome";
|
|
47
|
+
id: number;
|
|
48
|
+
principal: string;
|
|
49
|
+
epoch: string;
|
|
50
|
+
seq: number;
|
|
51
|
+
schema_hash: string;
|
|
52
|
+
resync: boolean;
|
|
53
|
+
protocol?: number;
|
|
54
|
+
features?: string[];
|
|
55
|
+
}
|
|
56
|
+
/** The server swapped to a reloaded API: every collection and record interest is gone. */
|
|
57
|
+
| {
|
|
58
|
+
t: "resync";
|
|
59
|
+
epoch: string;
|
|
60
|
+
seq: number;
|
|
61
|
+
schema_hash: string;
|
|
62
|
+
} | {
|
|
63
|
+
t: "rows";
|
|
64
|
+
id: number;
|
|
65
|
+
sub: number;
|
|
66
|
+
table: string;
|
|
67
|
+
start: number;
|
|
68
|
+
ids: string[];
|
|
69
|
+
records: WireRecord[];
|
|
70
|
+
total: number | null;
|
|
71
|
+
complete: boolean;
|
|
72
|
+
seq: number;
|
|
73
|
+
} | {
|
|
74
|
+
t: "members";
|
|
75
|
+
id?: number;
|
|
76
|
+
sub: number;
|
|
77
|
+
start: number;
|
|
78
|
+
ids: string[];
|
|
79
|
+
records: WireRecord[];
|
|
80
|
+
total: number | null;
|
|
81
|
+
complete: boolean;
|
|
82
|
+
} | {
|
|
83
|
+
t: "records";
|
|
84
|
+
id: number;
|
|
85
|
+
table: string;
|
|
86
|
+
records: WireRecord[];
|
|
87
|
+
missing: string[];
|
|
88
|
+
} | {
|
|
89
|
+
t: "changed";
|
|
90
|
+
seq: number;
|
|
91
|
+
events: ChangeEvent[];
|
|
92
|
+
} | {
|
|
93
|
+
t: "flashed";
|
|
94
|
+
id: number;
|
|
95
|
+
rid: string;
|
|
96
|
+
seq: number;
|
|
97
|
+
record: WireRecord | null;
|
|
98
|
+
} | {
|
|
99
|
+
t: "rejected";
|
|
100
|
+
id: number;
|
|
101
|
+
message: string;
|
|
102
|
+
field_errors?: Record<string, string>;
|
|
103
|
+
} | {
|
|
104
|
+
t: "batch_result";
|
|
105
|
+
id: number;
|
|
106
|
+
results: ({
|
|
107
|
+
t: "flashed";
|
|
108
|
+
id: number;
|
|
109
|
+
rid: string;
|
|
110
|
+
seq: number;
|
|
111
|
+
record: WireRecord | null;
|
|
112
|
+
} | {
|
|
113
|
+
t: "rejected";
|
|
114
|
+
id: number;
|
|
115
|
+
message: string;
|
|
116
|
+
field_errors?: Record<string, string>;
|
|
117
|
+
})[];
|
|
118
|
+
}
|
|
119
|
+
/** The answer to `invoke`; the run's frames follow. */
|
|
120
|
+
| {
|
|
121
|
+
t: "result";
|
|
122
|
+
id: number;
|
|
123
|
+
run: string;
|
|
124
|
+
status: "started" | "attached" | "done";
|
|
125
|
+
} | {
|
|
126
|
+
t: "options_result";
|
|
127
|
+
id: number;
|
|
128
|
+
items: {
|
|
129
|
+
value: Json;
|
|
130
|
+
label: string;
|
|
131
|
+
}[];
|
|
132
|
+
}
|
|
133
|
+
/** `code` names errors a client acts on: `protocol`, `unknown_frame`, and a run's {@link RunCode}. */
|
|
134
|
+
| {
|
|
135
|
+
t: "error";
|
|
136
|
+
id: number;
|
|
137
|
+
kind: ErrorKind;
|
|
138
|
+
message: string;
|
|
139
|
+
code?: string;
|
|
140
|
+
} | {
|
|
141
|
+
t: "pong";
|
|
142
|
+
id: number;
|
|
143
|
+
}
|
|
144
|
+
/** The frontend the server serves changed: reload the page. */
|
|
145
|
+
| {
|
|
146
|
+
t: "reload";
|
|
147
|
+
}
|
|
148
|
+
/** `run_set` or `run_cancel` was applied. */
|
|
149
|
+
| {
|
|
150
|
+
t: "ack";
|
|
151
|
+
id: number;
|
|
152
|
+
}
|
|
153
|
+
/** What a run did; the first after `invoke` is a snapshot. */
|
|
154
|
+
| RunFrame;
|
|
155
|
+
export type RunStatus = "running" | "done" | "failed" | "cancelled";
|
|
156
|
+
export type RunCode = "conflict" | "expired" | "forbidden" | "invalid" | "limit" | "failed";
|
|
157
|
+
export interface RunFrame {
|
|
158
|
+
t: "run";
|
|
159
|
+
run: string;
|
|
160
|
+
seq: number;
|
|
161
|
+
status?: RunStatus;
|
|
162
|
+
state?: Record<string, Json>;
|
|
163
|
+
/** Each with the seq it was first sent with; older servers leave it out. */
|
|
164
|
+
messages?: {
|
|
165
|
+
name: string;
|
|
166
|
+
payload: Json;
|
|
167
|
+
seq?: number;
|
|
168
|
+
}[];
|
|
169
|
+
result?: Json;
|
|
170
|
+
error?: {
|
|
171
|
+
code: RunCode;
|
|
172
|
+
message: string;
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
export type FlashItem = {
|
|
176
|
+
table: string;
|
|
177
|
+
rid?: string;
|
|
178
|
+
patch: Record<string, unknown>;
|
|
179
|
+
};
|
|
180
|
+
export type ClientFrame = {
|
|
181
|
+
t: "hello";
|
|
182
|
+
protocol: number;
|
|
183
|
+
token?: string;
|
|
184
|
+
cursor?: Cursor;
|
|
185
|
+
schema_hash?: string;
|
|
186
|
+
features?: string[];
|
|
187
|
+
} | ({
|
|
188
|
+
t: "open_table";
|
|
189
|
+
} & CollectionSpec) | {
|
|
190
|
+
t: "viewport";
|
|
191
|
+
sub: number;
|
|
192
|
+
start: number;
|
|
193
|
+
count: number;
|
|
194
|
+
} | {
|
|
195
|
+
t: "close";
|
|
196
|
+
sub: number;
|
|
197
|
+
} | {
|
|
198
|
+
t: "open_records";
|
|
199
|
+
table: string;
|
|
200
|
+
ids: string[];
|
|
201
|
+
} | {
|
|
202
|
+
t: "interest";
|
|
203
|
+
remove: [string, string][];
|
|
204
|
+
} | ({
|
|
205
|
+
t: "flash";
|
|
206
|
+
} & FlashItem) | {
|
|
207
|
+
t: "batch";
|
|
208
|
+
items: FlashItem[];
|
|
209
|
+
} | {
|
|
210
|
+
t: "delete";
|
|
211
|
+
table: string;
|
|
212
|
+
rid: string;
|
|
213
|
+
}
|
|
214
|
+
/** Start run `run` of `action`, or attach to it; without `action`, attach only. */
|
|
215
|
+
| {
|
|
216
|
+
t: "invoke";
|
|
217
|
+
run: string;
|
|
218
|
+
action?: string;
|
|
219
|
+
rid?: string;
|
|
220
|
+
args?: Record<string, unknown>;
|
|
221
|
+
} | {
|
|
222
|
+
t: "options";
|
|
223
|
+
action: string;
|
|
224
|
+
field: string;
|
|
225
|
+
form?: Record<string, unknown>;
|
|
226
|
+
} | {
|
|
227
|
+
t: "run_set";
|
|
228
|
+
run: string;
|
|
229
|
+
name: string;
|
|
230
|
+
value: Json;
|
|
231
|
+
} | {
|
|
232
|
+
t: "run_cancel";
|
|
233
|
+
run: string;
|
|
234
|
+
} | {
|
|
235
|
+
t: "ping";
|
|
236
|
+
};
|
package/dist/protocol.js
ADDED
package/dist/react.d.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { type FunctionComponent, type ReactNode } from "react";
|
|
2
|
+
import type { VantageDb } from "./client.js";
|
|
3
|
+
import type { ActionResult, CollectionWindow, Connection, LazyCollection, LazyReference, Model, Presence, SchemaTypes, Servo } from "./types.js";
|
|
4
|
+
export { useRun, useRunMessage, useShared } from "./run/hooks.js";
|
|
5
|
+
/** Provide the client. With `fallback`, children wait for `instant` tables. */
|
|
6
|
+
export declare function VantageProvider({ db, fallback, children }: {
|
|
7
|
+
db: VantageDb<any>;
|
|
8
|
+
fallback?: ReactNode;
|
|
9
|
+
children: ReactNode;
|
|
10
|
+
}): import("react").JSX.Element;
|
|
11
|
+
export declare function useVantage<S extends SchemaTypes>(): VantageDb<S>;
|
|
12
|
+
/** Re-render `Component` when anything it read during render changes. Generic components keep their type. */
|
|
13
|
+
export declare function observer<C extends FunctionComponent<any>>(Component: C): C;
|
|
14
|
+
/** The value of `fn`, re-computed when what it reads changes. */
|
|
15
|
+
export declare function useLive<T>(fn: () => T, deps?: unknown[]): T;
|
|
16
|
+
/** A collection's rows (models), live; `error` is the server's message when it refused the collection. */
|
|
17
|
+
export declare function useCollection<T>(collection: LazyCollection<T>): {
|
|
18
|
+
items: readonly T[];
|
|
19
|
+
} & Pick<LazyCollection<T>, "loadState" | "total" | "totalExact" | "length" | "error">;
|
|
20
|
+
/** One window of a collection, for virtualised lists; `error` as in `useCollection`. */
|
|
21
|
+
export declare function useWindow<T>(collection: LazyCollection<T>, start: number, count: number): CollectionWindow<T>;
|
|
22
|
+
/**
|
|
23
|
+
* A reference's record (`undefined` until loaded), its load `status`, and
|
|
24
|
+
* `error`, the server's message when it refused to load it.
|
|
25
|
+
*/
|
|
26
|
+
export declare function useRecord<T>(reference: LazyReference<T>): {
|
|
27
|
+
value: T | undefined;
|
|
28
|
+
status: Presence;
|
|
29
|
+
error: string | null;
|
|
30
|
+
};
|
|
31
|
+
/** A draft of `model` for a form; a new draft when the model changes. */
|
|
32
|
+
export declare function useServo<M extends Model>(model: M): Servo<M>;
|
|
33
|
+
/** Wrap an action for a button: `run`, `pending`, the last `error`. */
|
|
34
|
+
export declare function useAction<A extends unknown[], R>(fn: (...args: A) => Promise<ActionResult<R>>): {
|
|
35
|
+
run: (...args: A) => Promise<ActionResult<R>>;
|
|
36
|
+
pending: boolean;
|
|
37
|
+
error: import("./types.js").ActionError | null;
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* Suspend until `target` (a model, reference or collection) has loaded
|
|
41
|
+
* `keys`; then return it. Wrap the component in <Suspense>.
|
|
42
|
+
*/
|
|
43
|
+
export declare function useHydrated<T>(target: T, keys?: readonly string[]): T;
|
|
44
|
+
/** The connection's `status`, whether it `halted` (`refused` or `protocol_mismatch`), and then the server's `error`. */
|
|
45
|
+
export declare function useConnection(): Connection & {
|
|
46
|
+
seq: number;
|
|
47
|
+
principal: string | null;
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* Start `load` when the pointer rests on (or focus reaches) an element for
|
|
51
|
+
* `delay` ms — by the click, the data is usually there.
|
|
52
|
+
* <a {...usePreloadOnIntent(() => db.bakery.ref(id).load())} href=…>
|
|
53
|
+
*/
|
|
54
|
+
export declare function usePreloadOnIntent(load: () => unknown, delay?: number): {
|
|
55
|
+
onPointerEnter: () => void;
|
|
56
|
+
onPointerLeave: () => void;
|
|
57
|
+
onFocus: () => void;
|
|
58
|
+
onBlur: () => void;
|
|
59
|
+
};
|
package/dist/react.js
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
// React bindings.
|
|
3
|
+
//
|
|
4
|
+
// `observer(Component)` tracks what a render reads (fields, references,
|
|
5
|
+
// collections) and re-renders on exactly those changes, through
|
|
6
|
+
// `useSyncExternalStore` — tear-free under concurrent rendering, correct in
|
|
7
|
+
// StrictMode. Hooks cover code that prefers explicit subscriptions.
|
|
8
|
+
import { createContext, memo, useCallback, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore, } from "react";
|
|
9
|
+
import { Reaction } from "./signal.js";
|
|
10
|
+
export { useRun, useRunMessage, useShared } from "./run/hooks.js";
|
|
11
|
+
const Context = createContext(null);
|
|
12
|
+
/** Provide the client. With `fallback`, children wait for `instant` tables. */
|
|
13
|
+
export function VantageProvider({ db, fallback, children }) {
|
|
14
|
+
const [ready, setReady] = useState(fallback === undefined);
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
let live = true;
|
|
17
|
+
void db.ready.then(() => live && setReady(true));
|
|
18
|
+
return () => {
|
|
19
|
+
live = false;
|
|
20
|
+
};
|
|
21
|
+
}, [db]);
|
|
22
|
+
return _jsx(Context.Provider, { value: db, children: ready ? children : fallback });
|
|
23
|
+
}
|
|
24
|
+
export function useVantage() {
|
|
25
|
+
const db = useContext(Context);
|
|
26
|
+
if (!db)
|
|
27
|
+
throw new Error("vantage: useVantage() outside <VantageProvider>");
|
|
28
|
+
return db;
|
|
29
|
+
}
|
|
30
|
+
/** Re-render `Component` when anything it read during render changes. Generic components keep their type. */
|
|
31
|
+
export function observer(Component) {
|
|
32
|
+
const Observed = (props) => {
|
|
33
|
+
const reaction = useRef(null);
|
|
34
|
+
reaction.current ??= new Reaction();
|
|
35
|
+
useSyncExternalStore(reaction.current.subscribe, reaction.current.getVersion, reaction.current.getVersion);
|
|
36
|
+
return reaction.current.track(() => Component(props));
|
|
37
|
+
};
|
|
38
|
+
Observed.displayName = `observer(${Component.displayName ?? Component.name ?? "Component"})`;
|
|
39
|
+
return memo(Observed);
|
|
40
|
+
}
|
|
41
|
+
/** The value of `fn`, re-computed when what it reads changes. */
|
|
42
|
+
export function useLive(fn, deps = []) {
|
|
43
|
+
const reaction = useMemo(() => new Reaction(), []);
|
|
44
|
+
const cache = useRef(null);
|
|
45
|
+
const read = useCallback(() => {
|
|
46
|
+
const c = cache.current;
|
|
47
|
+
if (!c || c.version !== reaction.version || !sameDeps(c.deps, deps)) {
|
|
48
|
+
cache.current = { version: reaction.version, value: reaction.track(fn), deps };
|
|
49
|
+
}
|
|
50
|
+
return cache.current.value;
|
|
51
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
52
|
+
}, [reaction, ...deps]);
|
|
53
|
+
return useSyncExternalStore(reaction.subscribe, read, read);
|
|
54
|
+
}
|
|
55
|
+
/** A collection's rows (models), live; `error` is the server's message when it refused the collection. */
|
|
56
|
+
export function useCollection(collection) {
|
|
57
|
+
return useLive(() => ({
|
|
58
|
+
items: collection.elements,
|
|
59
|
+
loadState: collection.loadState,
|
|
60
|
+
total: collection.total,
|
|
61
|
+
totalExact: collection.totalExact,
|
|
62
|
+
length: collection.length,
|
|
63
|
+
error: collection.error,
|
|
64
|
+
}), [key(collection)]);
|
|
65
|
+
}
|
|
66
|
+
/** One window of a collection, for virtualised lists; `error` as in `useCollection`. */
|
|
67
|
+
export function useWindow(collection, start, count) {
|
|
68
|
+
return useLive(() => collection.window(start, count), [key(collection), start, count]);
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* A reference's record (`undefined` until loaded), its load `status`, and
|
|
72
|
+
* `error`, the server's message when it refused to load it.
|
|
73
|
+
*/
|
|
74
|
+
export function useRecord(reference) {
|
|
75
|
+
return useLive(() => ({ value: reference.value, status: reference.status, error: reference.error }), [reference]);
|
|
76
|
+
}
|
|
77
|
+
/** A draft of `model` for a form; a new draft when the model changes. */
|
|
78
|
+
export function useServo(model) {
|
|
79
|
+
const servo = useMemo(() => model.edit(), [model]);
|
|
80
|
+
useLive(() => {
|
|
81
|
+
// Read the draft's tracked parts so the form re-renders as it changes.
|
|
82
|
+
void servo.$isDirty;
|
|
83
|
+
void servo.$status;
|
|
84
|
+
void servo.$errors;
|
|
85
|
+
return null;
|
|
86
|
+
}, [servo]);
|
|
87
|
+
return servo;
|
|
88
|
+
}
|
|
89
|
+
/** Wrap an action for a button: `run`, `pending`, the last `error`. */
|
|
90
|
+
export function useAction(fn) {
|
|
91
|
+
const [pending, setPending] = useState(false);
|
|
92
|
+
const [error, setError] = useState(null);
|
|
93
|
+
const latest = useRef(fn);
|
|
94
|
+
latest.current = fn;
|
|
95
|
+
const run = useCallback(async (...args) => {
|
|
96
|
+
setPending(true);
|
|
97
|
+
setError(null);
|
|
98
|
+
const result = await latest.current(...args);
|
|
99
|
+
setPending(false);
|
|
100
|
+
if (!result.ok)
|
|
101
|
+
setError(result.error);
|
|
102
|
+
return result;
|
|
103
|
+
}, []);
|
|
104
|
+
return { run, pending, error };
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Suspend until `target` (a model, reference or collection) has loaded
|
|
108
|
+
* `keys`; then return it. Wrap the component in <Suspense>.
|
|
109
|
+
*/
|
|
110
|
+
export function useHydrated(target, keys) {
|
|
111
|
+
const promise = hydrationOf(target, keys);
|
|
112
|
+
if (!promise.done)
|
|
113
|
+
throw promise.wait;
|
|
114
|
+
if (promise.error)
|
|
115
|
+
throw promise.error;
|
|
116
|
+
return (promise.value ?? target);
|
|
117
|
+
}
|
|
118
|
+
const hydrations = new WeakMap();
|
|
119
|
+
function hydrationOf(target, keys) {
|
|
120
|
+
const obj = target;
|
|
121
|
+
let perKeys = hydrations.get(obj);
|
|
122
|
+
if (!perKeys)
|
|
123
|
+
hydrations.set(obj, (perKeys = new Map()));
|
|
124
|
+
const k = (keys ?? ["*"]).join(",");
|
|
125
|
+
let h = perKeys.get(k);
|
|
126
|
+
if (!h) {
|
|
127
|
+
const entry = { wait: Promise.resolve(), done: false };
|
|
128
|
+
const start = obj.hydrate ? obj.hydrate(keys) : obj.load ? obj.load() : Promise.resolve(obj);
|
|
129
|
+
entry.wait = start.then((value) => {
|
|
130
|
+
entry.value = value;
|
|
131
|
+
entry.done = true;
|
|
132
|
+
}, (error) => {
|
|
133
|
+
entry.error = error;
|
|
134
|
+
entry.done = true;
|
|
135
|
+
});
|
|
136
|
+
perKeys.set(k, (h = entry));
|
|
137
|
+
}
|
|
138
|
+
return h;
|
|
139
|
+
}
|
|
140
|
+
/** The connection's `status`, whether it `halted` (`refused` or `protocol_mismatch`), and then the server's `error`. */
|
|
141
|
+
export function useConnection() {
|
|
142
|
+
const db = useVantage();
|
|
143
|
+
return useLive(() => ({ ...db.connection.get(), seq: db.seq.get(), principal: db.principal.get() }));
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Start `load` when the pointer rests on (or focus reaches) an element for
|
|
147
|
+
* `delay` ms — by the click, the data is usually there.
|
|
148
|
+
* <a {...usePreloadOnIntent(() => db.bakery.ref(id).load())} href=…>
|
|
149
|
+
*/
|
|
150
|
+
export function usePreloadOnIntent(load, delay = 60) {
|
|
151
|
+
const timer = useRef(undefined);
|
|
152
|
+
const latest = useRef(load);
|
|
153
|
+
latest.current = load;
|
|
154
|
+
const db = useContext(Context);
|
|
155
|
+
return useMemo(() => {
|
|
156
|
+
const start = () => {
|
|
157
|
+
clearTimeout(timer.current);
|
|
158
|
+
timer.current = setTimeout(() => void (db ? db.preload(latest.current) : latest.current()), delay);
|
|
159
|
+
};
|
|
160
|
+
const cancel = () => clearTimeout(timer.current);
|
|
161
|
+
return { onPointerEnter: start, onPointerLeave: cancel, onFocus: start, onBlur: cancel };
|
|
162
|
+
}, [db, delay]);
|
|
163
|
+
}
|
|
164
|
+
function key(collection) {
|
|
165
|
+
return JSON.stringify(collection.spec ?? null);
|
|
166
|
+
}
|
|
167
|
+
function sameDeps(a, b) {
|
|
168
|
+
return a.length === b.length && a.every((x, i) => Object.is(x, b[i]));
|
|
169
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export type Member = {
|
|
2
|
+
kind: "column" | "reference" | "action";
|
|
3
|
+
name: string;
|
|
4
|
+
};
|
|
5
|
+
/** The first member of `entity` that takes a reserved name, as a one-line message. */
|
|
6
|
+
export declare function reservedClash(entity: string, members: Member[]): string | undefined;
|
package/dist/reserved.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Names a model keeps for itself. A column, reference or row action can't
|
|
2
|
+
// take one, or it would hide the model's own member: `id`, `edit`, `patch`,
|
|
3
|
+
// `delete`, `hydrate`, and anything starting with `$` (`$status`,
|
|
4
|
+
// `$actions`, …). The id column itself is named `id`, and hidden columns
|
|
5
|
+
// aren't on the model, so neither clashes.
|
|
6
|
+
const RESERVED = new Set(["id", "edit", "patch", "delete", "hydrate"]);
|
|
7
|
+
/** The first member of `entity` that takes a reserved name, as a one-line message. */
|
|
8
|
+
export function reservedClash(entity, members) {
|
|
9
|
+
const clash = members.find((m) => RESERVED.has(m.name) || m.name.startsWith("$"));
|
|
10
|
+
if (!clash)
|
|
11
|
+
return undefined;
|
|
12
|
+
return `entity \`${entity}\`: ${clash.kind} \`${clash.name}\` clashes with a name the model reserves (id, edit, patch, delete, hydrate, $…); rename it in the API`;
|
|
13
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { RunFrame } from "../protocol.js";
|
|
2
|
+
export type FrameKind = "snapshot" | "live" | "stale" | "gap";
|
|
3
|
+
/** How to treat `frame` for a run that has applied frames up to `lastSeq` (-1: none yet). */
|
|
4
|
+
export declare function classify(frame: RunFrame, lastSeq: number): FrameKind;
|
|
5
|
+
/** A run that ended, by the frame's status. A status this client doesn't know is not an end. */
|
|
6
|
+
export declare function isFinal(frame: RunFrame): boolean;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Reading `run` frames: which are snapshots, which are live, and when one
|
|
2
|
+
// was missed. Unknown fields are ignored, so a newer server's frames still
|
|
3
|
+
// apply.
|
|
4
|
+
//
|
|
5
|
+
// The server numbers a run's frames with `seq`. The first frame after an
|
|
6
|
+
// invoke or attach is a snapshot: the whole state, the buffered messages
|
|
7
|
+
// and the status. Each later frame carries one change. A frame whose seq
|
|
8
|
+
// skips ahead means one was lost: the client attaches again for a fresh
|
|
9
|
+
// snapshot.
|
|
10
|
+
/** How to treat `frame` for a run that has applied frames up to `lastSeq` (-1: none yet). */
|
|
11
|
+
export function classify(frame, lastSeq) {
|
|
12
|
+
const snapshot = frame.status !== undefined && frame.state !== undefined && frame.messages !== undefined;
|
|
13
|
+
if (snapshot)
|
|
14
|
+
return frame.seq < lastSeq ? "stale" : "snapshot";
|
|
15
|
+
if (lastSeq < 0)
|
|
16
|
+
return "gap"; // a live frame before any snapshot
|
|
17
|
+
if (frame.seq <= lastSeq)
|
|
18
|
+
return "stale";
|
|
19
|
+
return frame.seq === lastSeq + 1 ? "live" : "gap";
|
|
20
|
+
}
|
|
21
|
+
/** A run that ended, by the frame's status. A status this client doesn't know is not an end. */
|
|
22
|
+
export function isFinal(frame) {
|
|
23
|
+
return frame.status === "done" || frame.status === "failed" || frame.status === "cancelled";
|
|
24
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ActionResult } from "../types.js";
|
|
2
|
+
import type { MessagesOf, Run, RunFactory, RunTypes } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* One run of `factory`'s action for this component: idle until `start`.
|
|
5
|
+
* With `key`, its id is kept in `sessionStorage` (per action), so after a
|
|
6
|
+
* page reload the same component attaches to the run again; a stored run
|
|
7
|
+
* the server no longer has (expired, or never yours) is forgotten and the
|
|
8
|
+
* run is back to `idle`. `run.forget()` forgets it too.
|
|
9
|
+
*/
|
|
10
|
+
export declare function useRun<T extends RunTypes>(factory: RunFactory<T>, options?: {
|
|
11
|
+
key?: string;
|
|
12
|
+
}): Run<T>;
|
|
13
|
+
type Shared<T extends RunTypes, K extends keyof T["state"]> = K extends keyof T["inputs"] ? [value: T["state"][K], set: (value: T["state"][K]) => Promise<ActionResult<undefined>>] : T["state"][K];
|
|
14
|
+
/** A run's variable, live; for an input, `[value, set]`. */
|
|
15
|
+
export declare function useShared<T extends RunTypes, K extends keyof T["state"] & string>(run: Run<T>, name: K): Shared<T, K>;
|
|
16
|
+
/** Call `fn` for each message `name` the run sends while mounted, in order; `vantage.notify` is a body's `notify(text)`. */
|
|
17
|
+
export declare function useRunMessage<T extends RunTypes, K extends keyof MessagesOf<T> & string>(run: Run<T>, name: K, fn: (payload: MessagesOf<T>[K]) => void): void;
|
|
18
|
+
export {};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// React hooks for action runs.
|
|
2
|
+
//
|
|
3
|
+
// const restock = useRun(db.run.restock_bakery, { key: `restock:${bakery}` });
|
|
4
|
+
// const progress = useShared(restock, "progress");
|
|
5
|
+
// const [confirmed, setConfirmed] = useShared(restock, "confirmed"); // an input
|
|
6
|
+
// useRunMessage(restock, "low_stock", (m) => flag(`${m.product} is low`));
|
|
7
|
+
// <button onClick={() => restock.start({ bakery })}>Restock</button>
|
|
8
|
+
//
|
|
9
|
+
// A component re-renders only for the variables it reads.
|
|
10
|
+
import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from "react";
|
|
11
|
+
import { Reaction } from "../signal.js";
|
|
12
|
+
import { forgetRun, saveRun, savedRun } from "./storage.js";
|
|
13
|
+
/**
|
|
14
|
+
* One run of `factory`'s action for this component: idle until `start`.
|
|
15
|
+
* With `key`, its id is kept in `sessionStorage` (per action), so after a
|
|
16
|
+
* page reload the same component attaches to the run again; a stored run
|
|
17
|
+
* the server no longer has (expired, or never yours) is forgotten and the
|
|
18
|
+
* run is back to `idle`. `run.forget()` forgets it too.
|
|
19
|
+
*/
|
|
20
|
+
export function useRun(factory, options = {}) {
|
|
21
|
+
const stored = options.key === undefined ? undefined : `${factory.action}.${options.key}`;
|
|
22
|
+
// Pure: StrictMode may build it twice and keep one. A new key is a new run.
|
|
23
|
+
const run = useMemo(() => factory.idle(), [factory.action, stored]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
24
|
+
const { id, status, code } = useTracked(() => ({ id: run.id, status: run.status, code: run.error?.code }), run);
|
|
25
|
+
const fromStorage = useRef(null);
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
if (!stored || fromStorage.current?.run === run)
|
|
28
|
+
return;
|
|
29
|
+
const saved = savedRun(stored);
|
|
30
|
+
fromStorage.current = { run, id: saved ?? "" };
|
|
31
|
+
if (saved && run.status === "idle")
|
|
32
|
+
run.attach(saved);
|
|
33
|
+
}, [run, stored]);
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
if (!stored)
|
|
36
|
+
return;
|
|
37
|
+
const gone = status === "failed" && (code === "forbidden" || code === "expired") && fromStorage.current?.id === id;
|
|
38
|
+
if (gone) {
|
|
39
|
+
forgetRun(stored);
|
|
40
|
+
run.forget();
|
|
41
|
+
}
|
|
42
|
+
else if (id)
|
|
43
|
+
saveRun(stored, id);
|
|
44
|
+
else if (status === "idle" && fromStorage.current?.run === run)
|
|
45
|
+
forgetRun(stored);
|
|
46
|
+
}, [run, stored, id, status, code]);
|
|
47
|
+
return run;
|
|
48
|
+
}
|
|
49
|
+
/** A run's variable, live; for an input, `[value, set]`. */
|
|
50
|
+
export function useShared(run, name) {
|
|
51
|
+
const value = useTracked(() => run.state[name], run, name);
|
|
52
|
+
const set = useCallback((next) => run.set(name, next), [run, name]);
|
|
53
|
+
return (run.isInput(name) ? [value, set] : value);
|
|
54
|
+
}
|
|
55
|
+
/** Call `fn` for each message `name` the run sends while mounted, in order; `vantage.notify` is a body's `notify(text)`. */
|
|
56
|
+
export function useRunMessage(run, name, fn) {
|
|
57
|
+
const latest = useRef(fn);
|
|
58
|
+
latest.current = fn;
|
|
59
|
+
useEffect(() => run.on(name, (payload) => latest.current(payload)), [run, name]);
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* `read()`, re-run when what it reads changes; shallowly equal results keep
|
|
63
|
+
* their identity. New `deps` give a new reaction, which tracks from scratch.
|
|
64
|
+
*/
|
|
65
|
+
function useTracked(read, ...deps) {
|
|
66
|
+
const reaction = useMemo(() => new Reaction(), deps); // eslint-disable-line react-hooks/exhaustive-deps
|
|
67
|
+
const cache = useRef(null);
|
|
68
|
+
const get = useCallback(() => {
|
|
69
|
+
const cached = cache.current;
|
|
70
|
+
if (!cached || cached.reaction !== reaction || cached.version !== reaction.version) {
|
|
71
|
+
const value = reaction.track(read);
|
|
72
|
+
const same = cached?.reaction === reaction && shallowEqual(cached.value, value);
|
|
73
|
+
cache.current = { reaction, version: reaction.version, value: same ? cached.value : value };
|
|
74
|
+
}
|
|
75
|
+
return cache.current.value;
|
|
76
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
77
|
+
}, [reaction]);
|
|
78
|
+
return useSyncExternalStore(reaction.subscribe, get, get);
|
|
79
|
+
}
|
|
80
|
+
function shallowEqual(a, b) {
|
|
81
|
+
if (Object.is(a, b))
|
|
82
|
+
return true;
|
|
83
|
+
if (!a || !b || typeof a !== "object" || typeof b !== "object")
|
|
84
|
+
return false;
|
|
85
|
+
const ka = Object.keys(a);
|
|
86
|
+
return ka.length === Object.keys(b).length && ka.every((k) => Object.is(a[k], b[k]));
|
|
87
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Json, RunFrame } from "../protocol.js";
|
|
2
|
+
type Message = {
|
|
3
|
+
name: string;
|
|
4
|
+
payload: Json;
|
|
5
|
+
};
|
|
6
|
+
export type WireMessage = NonNullable<RunFrame["messages"]>[number];
|
|
7
|
+
export declare class RunMessages {
|
|
8
|
+
private list;
|
|
9
|
+
private listeners;
|
|
10
|
+
/** The kept messages, oldest first; reactive. */
|
|
11
|
+
get(): Message[];
|
|
12
|
+
on(name: string, fn: (payload: never) => void): () => void;
|
|
13
|
+
clear(): void;
|
|
14
|
+
/** A snapshot's buffer replaces the kept messages; the listeners hear those that are `news`. */
|
|
15
|
+
replace(messages: WireMessage[], news: (message: WireMessage) => boolean): void;
|
|
16
|
+
/** A live message: kept, and heard by the listeners. */
|
|
17
|
+
received(message: WireMessage): void;
|
|
18
|
+
private notify;
|
|
19
|
+
}
|
|
20
|
+
export {};
|