@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.
Files changed (88) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/bin/vantage-client.js +5 -0
  3. package/dist/client-connection.d.ts +41 -0
  4. package/dist/client-connection.js +260 -0
  5. package/dist/client-core.d.ts +70 -0
  6. package/dist/client-core.js +140 -0
  7. package/dist/client-loading.d.ts +13 -0
  8. package/dist/client-loading.js +229 -0
  9. package/dist/client-runs.d.ts +18 -0
  10. package/dist/client-runs.js +88 -0
  11. package/dist/client-writes.d.ts +14 -0
  12. package/dist/client-writes.js +114 -0
  13. package/dist/client.d.ts +32 -0
  14. package/dist/client.js +61 -0
  15. package/dist/codegen/cli.d.ts +8 -0
  16. package/dist/codegen/cli.js +54 -0
  17. package/dist/codegen/emit.d.ts +3 -0
  18. package/dist/codegen/emit.js +190 -0
  19. package/dist/codegen/fetch.d.ts +4 -0
  20. package/dist/codegen/fetch.js +41 -0
  21. package/dist/codegen/meta.d.ts +13 -0
  22. package/dist/codegen/meta.js +59 -0
  23. package/dist/codegen/runs.d.ts +20 -0
  24. package/dist/codegen/runs.js +58 -0
  25. package/dist/codegen/schema.d.ts +97 -0
  26. package/dist/codegen/schema.js +23 -0
  27. package/dist/collection-cell.d.ts +69 -0
  28. package/dist/collection-cell.js +153 -0
  29. package/dist/collection.d.ts +32 -0
  30. package/dist/collection.js +122 -0
  31. package/dist/index.d.ts +8 -0
  32. package/dist/index.js +14 -0
  33. package/dist/mock-query.d.ts +9 -0
  34. package/dist/mock-query.js +75 -0
  35. package/dist/mock-runs.d.ts +61 -0
  36. package/dist/mock-runs.js +165 -0
  37. package/dist/mock-store.d.ts +78 -0
  38. package/dist/mock-store.js +158 -0
  39. package/dist/mock-transport.d.ts +16 -0
  40. package/dist/mock-transport.js +38 -0
  41. package/dist/model.d.ts +5 -0
  42. package/dist/model.js +169 -0
  43. package/dist/persist.d.ts +47 -0
  44. package/dist/persist.js +108 -0
  45. package/dist/protocol.d.ts +236 -0
  46. package/dist/protocol.js +5 -0
  47. package/dist/react.d.ts +59 -0
  48. package/dist/react.js +169 -0
  49. package/dist/reserved.d.ts +6 -0
  50. package/dist/reserved.js +13 -0
  51. package/dist/run/frames.d.ts +6 -0
  52. package/dist/run/frames.js +24 -0
  53. package/dist/run/hooks.d.ts +18 -0
  54. package/dist/run/hooks.js +87 -0
  55. package/dist/run/messages.d.ts +20 -0
  56. package/dist/run/messages.js +42 -0
  57. package/dist/run/outcome.d.ts +12 -0
  58. package/dist/run/outcome.js +27 -0
  59. package/dist/run/run.d.ts +82 -0
  60. package/dist/run/run.js +246 -0
  61. package/dist/run/storage.d.ts +3 -0
  62. package/dist/run/storage.js +20 -0
  63. package/dist/run/types.d.ts +92 -0
  64. package/dist/run/types.js +3 -0
  65. package/dist/run/variables.d.ts +26 -0
  66. package/dist/run/variables.js +70 -0
  67. package/dist/schema-meta.d.ts +91 -0
  68. package/dist/schema-meta.js +3 -0
  69. package/dist/signal.d.ts +45 -0
  70. package/dist/signal.js +163 -0
  71. package/dist/store.d.ts +45 -0
  72. package/dist/store.js +144 -0
  73. package/dist/table.d.ts +62 -0
  74. package/dist/table.js +82 -0
  75. package/dist/testing.d.ts +61 -0
  76. package/dist/testing.js +195 -0
  77. package/dist/transport.d.ts +28 -0
  78. package/dist/transport.js +32 -0
  79. package/dist/types.d.ts +159 -0
  80. package/dist/types.js +2 -0
  81. package/dist/util.d.ts +18 -0
  82. package/dist/util.js +46 -0
  83. package/package.json +54 -4
  84. package/skills/vantage-client/SKILL.md +390 -0
  85. package/skills/vantage-client/references/protocol.md +67 -0
  86. package/skills/vantage-client/references/react.md +242 -0
  87. package/skills/vantage-client/references/testing.md +173 -0
  88. package/README.md +0 -3
@@ -0,0 +1,42 @@
1
+ // A run's messages on the client: the last ones kept (like the server's
2
+ // buffer) and the listeners `run.on` registered.
3
+ import { State } from "../signal.js";
4
+ /** A run's messages kept on the client, like the server's buffer. */
5
+ const KEPT = 100;
6
+ /** A message as `run.messages` holds it, without its wire seq. */
7
+ const stored = ({ name, payload }) => ({ name, payload });
8
+ export class RunMessages {
9
+ list = new State([]);
10
+ listeners = new Map();
11
+ /** The kept messages, oldest first; reactive. */
12
+ get() {
13
+ return this.list.get();
14
+ }
15
+ on(name, fn) {
16
+ let set = this.listeners.get(name);
17
+ if (!set)
18
+ this.listeners.set(name, (set = new Set()));
19
+ set.add(fn);
20
+ return () => void set.delete(fn);
21
+ }
22
+ clear() {
23
+ this.list.set([]);
24
+ }
25
+ /** A snapshot's buffer replaces the kept messages; the listeners hear those that are `news`. */
26
+ replace(messages, news) {
27
+ this.list.set(messages.map(stored));
28
+ for (const message of messages)
29
+ if (news(message))
30
+ this.notify(message);
31
+ }
32
+ /** A live message: kept, and heard by the listeners. */
33
+ received(message) {
34
+ const list = [...this.list.peek(), stored(message)];
35
+ this.list.set(list.length > KEPT ? list.slice(-KEPT) : list);
36
+ this.notify(message);
37
+ }
38
+ notify(message) {
39
+ for (const fn of this.listeners.get(message.name) ?? [])
40
+ fn(message.payload);
41
+ }
42
+ }
@@ -0,0 +1,12 @@
1
+ import type { ErrorKind, ServerFrame } from "../protocol.js";
2
+ import type { ActionError } from "../types.js";
3
+ import type { RunError } from "./types.js";
4
+ type Failed = {
5
+ ok: false;
6
+ error: ActionError;
7
+ };
8
+ /** A refused `run_set`/`run_cancel` reply as a failed result. */
9
+ export declare function refused(reply: ServerFrame): Failed;
10
+ /** A run's error code as an `ActionError` kind, mirroring the server's. */
11
+ export declare function kindOf(code: RunError["code"] | undefined): ErrorKind;
12
+ export {};
@@ -0,0 +1,27 @@
1
+ // A run's refusals and outcomes as `ActionResult`s.
2
+ import { fail } from "../util.js";
3
+ /** A refused `run_set`/`run_cancel` reply as a failed result. */
4
+ export function refused(reply) {
5
+ if (reply.t !== "error")
6
+ return fail("failed", `unexpected reply \`${reply.t}\``);
7
+ return fail(reply.code ? kindOf(reply.code) : reply.kind, reply.message);
8
+ }
9
+ /** A run's error code as an `ActionError` kind, mirroring the server's. */
10
+ export function kindOf(code) {
11
+ switch (code) {
12
+ case "conflict":
13
+ return "conflict";
14
+ case "expired":
15
+ return "not_found";
16
+ case "forbidden":
17
+ return "forbidden";
18
+ case "invalid":
19
+ return "invalid";
20
+ case "limit":
21
+ return "unavailable";
22
+ case "offline":
23
+ return "offline";
24
+ default:
25
+ return "failed";
26
+ }
27
+ }
@@ -0,0 +1,82 @@
1
+ import type { Json, ServerFrame } from "../protocol.js";
2
+ import type { RunMeta } from "../schema-meta.js";
3
+ import type { ActionResult } from "../types.js";
4
+ import type { Run, RunError, RunFactory, RunStatus, RunTypes, StartOptions } from "./types.js";
5
+ /** What a run needs from the client. */
6
+ export interface RunHost {
7
+ /**
8
+ * Send `invoke` for `run`: with `args` the start, without them an attach.
9
+ * Resolves `offline` without sending while the client is disconnected.
10
+ */
11
+ invoke(run: RunImpl, args: Record<string, unknown> | undefined): Promise<ServerFrame>;
12
+ request(frame: {
13
+ t: "run_set";
14
+ run: string;
15
+ name: string;
16
+ value: Json;
17
+ } | {
18
+ t: "run_cancel";
19
+ run: string;
20
+ }): Promise<ServerFrame>;
21
+ /** Route `run` frames for `run.id` to it, or stop. */
22
+ follow(run: RunImpl): void;
23
+ unfollow(run: RunImpl): void;
24
+ }
25
+ export declare class RunImpl implements Run<RunTypes> {
26
+ private host;
27
+ readonly action: string;
28
+ private rid;
29
+ private idCell;
30
+ private statusCell;
31
+ private resultCell;
32
+ private errorCell;
33
+ private variables;
34
+ private messageLog;
35
+ /** What `start` sent;`undefined` for a run this client only attached to. */
36
+ private startArgs;
37
+ /** This client started the run and no snapshot came yet: its messages are news, not history. */
38
+ private firstSnapshotIsNews;
39
+ /** An attach for a fresh snapshot is on its way; more gaps don't ask again. */
40
+ private reattaching;
41
+ private settle?;
42
+ private donePromise;
43
+ /** Resolves once the run leaves `starting`; an input written before then waits for it. */
44
+ private started;
45
+ private markStarted?;
46
+ readonly state: Readonly<Record<string, unknown>>;
47
+ constructor(host: RunHost, action: string, rid: string | undefined, meta: RunMeta);
48
+ get id(): string;
49
+ get status(): RunStatus;
50
+ get result(): unknown;
51
+ get error(): RunError | null;
52
+ get messages(): never;
53
+ get done(): Promise<ActionResult<unknown>>;
54
+ start(args?: object, options?: StartOptions): this;
55
+ attach(run: string): this;
56
+ forget(): void;
57
+ on(name: string, fn: (payload: never) => void): () => void;
58
+ set(name: string, value: unknown): Promise<ActionResult<undefined>>;
59
+ cancel(): Promise<ActionResult<undefined>>;
60
+ private begin;
61
+ /** Send the start, or an attach for a run this client didn't start. */
62
+ private send;
63
+ private reattach;
64
+ /** Send `invoke`; a reply that arrives after the run moved to another id is ignored. */
65
+ private request;
66
+ /** Back to `idle`; whoever still waits on the previous run hears `reason`. */
67
+ private reset;
68
+ private setStatus;
69
+ private answered;
70
+ private finish;
71
+ private finishWith;
72
+ /**
73
+ * Whether a snapshot's message is news to the listeners. In the first
74
+ * snapshot of a run this client started, all are. In a later one (after
75
+ * a reconnect, a resync or a missed frame) those past the last applied
76
+ * seq are: they went out while this client wasn't following. The first
77
+ * snapshot after `attach` or a reload is history.
78
+ */
79
+ private isNews;
80
+ }
81
+ /** The factory for `action`: `start`, `attach`, `idle`. */
82
+ export declare function runFactory<T extends RunTypes>(host: RunHost, action: string, rid: string | undefined, meta: RunMeta): RunFactory<T>;
@@ -0,0 +1,246 @@
1
+ // One run of an action, as the client follows it: its status, shared
2
+ // variables, messages, outcome, and the input writes it sends.
3
+ //
4
+ // A run survives a lost connection and a resync: an invoke that got no
5
+ // answer is sent again with the same id and args after the welcome (the
6
+ // server attaches instead of running twice), and a running run attaches
7
+ // again for a fresh snapshot.
8
+ import { State, batch } from "../signal.js";
9
+ import { fail, uuidv7 } from "../util.js";
10
+ import { classify, isFinal } from "./frames.js";
11
+ import { RunMessages } from "./messages.js";
12
+ import { kindOf, refused } from "./outcome.js";
13
+ import { RunVariables } from "./variables.js";
14
+ export class RunImpl {
15
+ host;
16
+ action;
17
+ rid;
18
+ idCell = new State("");
19
+ statusCell = new State("idle");
20
+ resultCell = new State(undefined);
21
+ errorCell = new State(null);
22
+ variables;
23
+ messageLog = new RunMessages();
24
+ /** What `start` sent;`undefined` for a run this client only attached to. */
25
+ startArgs;
26
+ /** This client started the run and no snapshot came yet: its messages are news, not history. */
27
+ firstSnapshotIsNews = false;
28
+ /** An attach for a fresh snapshot is on its way; more gaps don't ask again. */
29
+ reattaching = false;
30
+ settle;
31
+ donePromise;
32
+ /** Resolves once the run leaves `starting`; an input written before then waits for it. */
33
+ started;
34
+ markStarted;
35
+ /** @internal The seq of the last frame applied; -1 before the first snapshot. */
36
+ lastSeq = -1;
37
+ state;
38
+ constructor(host, action, rid, meta) {
39
+ this.host = host;
40
+ this.action = action;
41
+ this.rid = rid;
42
+ this.variables = new RunVariables(meta);
43
+ this.state = this.variables.state;
44
+ this.reset("");
45
+ }
46
+ get id() {
47
+ return this.idCell.get();
48
+ }
49
+ get status() {
50
+ return this.statusCell.get();
51
+ }
52
+ get result() {
53
+ return this.resultCell.get();
54
+ }
55
+ get error() {
56
+ return this.errorCell.get();
57
+ }
58
+ get messages() {
59
+ return this.messageLog.get();
60
+ }
61
+ get done() {
62
+ return this.donePromise;
63
+ }
64
+ /** @internal The record a row action runs on. */
65
+ get recordId() {
66
+ return this.rid;
67
+ }
68
+ /** @internal Whether the client writes `name` (an `input: true` variable). */
69
+ isInput(name) {
70
+ return this.variables.isInput(name);
71
+ }
72
+ start(args, options = {}) {
73
+ this.begin(options.run ?? uuidv7(), { ...(args ?? {}) });
74
+ this.firstSnapshotIsNews = true;
75
+ this.send();
76
+ return this;
77
+ }
78
+ attach(run) {
79
+ this.begin(run, undefined);
80
+ this.reattach();
81
+ return this;
82
+ }
83
+ forget() {
84
+ this.host.unfollow(this);
85
+ this.reset("the run was forgotten");
86
+ }
87
+ /** @internal After a welcome or a resync: send an unanswered invoke again, or attach for a fresh snapshot. */
88
+ resume() {
89
+ const status = this.statusCell.peek();
90
+ if (status === "starting")
91
+ this.send();
92
+ else if (status === "running") {
93
+ this.reattaching = false;
94
+ this.reattach();
95
+ }
96
+ }
97
+ on(name, fn) {
98
+ return this.messageLog.on(name, fn);
99
+ }
100
+ async set(name, value) {
101
+ if (this.statusCell.peek() === "starting")
102
+ await this.started;
103
+ const id = this.idCell.peek();
104
+ if (!id || this.statusCell.peek() !== "running")
105
+ return fail("conflict", "the run isn't running");
106
+ const reply = await this.variables.write(name, value, () => this.host.request({ t: "run_set", run: id, name, value: value }));
107
+ return reply.t === "ack" ? { ok: true, value: undefined } : refused(reply);
108
+ }
109
+ async cancel() {
110
+ const id = this.idCell.peek();
111
+ if (!id)
112
+ return fail("conflict", "the run hasn't started");
113
+ const reply = await this.host.request({ t: "run_cancel", run: id });
114
+ return reply.t === "ack" ? { ok: true, value: undefined } : refused(reply);
115
+ }
116
+ /** @internal Apply a `run` frame; after a missed one, attach again (once) for a fresh snapshot. */
117
+ apply(frame) {
118
+ const kind = classify(frame, this.lastSeq);
119
+ if (kind === "stale")
120
+ return;
121
+ if (kind === "gap") {
122
+ if (!this.reattaching)
123
+ this.reattach();
124
+ return;
125
+ }
126
+ batch(() => {
127
+ for (const [name, value] of Object.entries(frame.state ?? {}))
128
+ this.variables.fromServer(name, value);
129
+ if (kind === "snapshot") {
130
+ this.reattaching = false;
131
+ this.messageLog.replace(frame.messages ?? [], (message) => this.isNews(message));
132
+ this.firstSnapshotIsNews = false;
133
+ }
134
+ else {
135
+ for (const message of frame.messages ?? [])
136
+ this.messageLog.received(message);
137
+ }
138
+ this.lastSeq = frame.seq;
139
+ if (frame.status === "running")
140
+ this.setStatus("running");
141
+ if (isFinal(frame))
142
+ this.finish(frame);
143
+ });
144
+ }
145
+ begin(id, args) {
146
+ this.host.unfollow(this);
147
+ this.reset("the run was restarted");
148
+ this.startArgs = args;
149
+ this.idCell.set(id);
150
+ this.statusCell.set("starting");
151
+ this.host.follow(this);
152
+ }
153
+ /** Send the start, or an attach for a run this client didn't start. */
154
+ send() {
155
+ this.request(this.startArgs);
156
+ }
157
+ reattach() {
158
+ this.reattaching = true;
159
+ this.request(undefined);
160
+ }
161
+ /** Send `invoke`; a reply that arrives after the run moved to another id is ignored. */
162
+ request(args) {
163
+ const id = this.idCell.peek();
164
+ void this.host.invoke(this, args).then((reply) => {
165
+ if (this.idCell.peek() === id)
166
+ this.answered(reply);
167
+ });
168
+ }
169
+ /** Back to `idle`; whoever still waits on the previous run hears `reason`. */
170
+ reset(reason) {
171
+ this.settle?.(fail("conflict", reason));
172
+ this.markStarted?.();
173
+ batch(() => {
174
+ this.variables.reset();
175
+ this.messageLog.clear();
176
+ this.resultCell.set(undefined);
177
+ this.errorCell.set(null);
178
+ this.idCell.set("");
179
+ this.statusCell.set("idle");
180
+ });
181
+ this.startArgs = undefined;
182
+ this.firstSnapshotIsNews = false;
183
+ this.reattaching = false;
184
+ this.lastSeq = -1;
185
+ this.donePromise = new Promise((resolve) => (this.settle = resolve));
186
+ this.started = new Promise((resolve) => (this.markStarted = resolve));
187
+ }
188
+ setStatus(status) {
189
+ this.statusCell.set(status);
190
+ if (status !== "starting" && status !== "idle")
191
+ this.markStarted?.();
192
+ }
193
+ answered(reply) {
194
+ if (reply.t === "result") {
195
+ if (reply.status !== "done" && this.statusCell.peek() === "starting")
196
+ this.setStatus("running");
197
+ return;
198
+ }
199
+ // Not answered: the run may well be going. It's sent again after the welcome.
200
+ if (reply.t !== "error" || reply.kind === "offline")
201
+ return;
202
+ this.reattaching = false;
203
+ const code = reply.code ?? "failed";
204
+ this.finishWith("failed", undefined, { code, message: reply.message });
205
+ }
206
+ finish(frame) {
207
+ const error = frame.error ? { code: frame.error.code, message: frame.error.message } : null;
208
+ this.finishWith(frame.status, frame.result, error);
209
+ }
210
+ finishWith(status, result, error) {
211
+ batch(() => {
212
+ this.resultCell.set(result);
213
+ this.errorCell.set(error);
214
+ this.setStatus(status);
215
+ });
216
+ this.host.unfollow(this);
217
+ if (status === "done")
218
+ this.settle?.({ ok: true, value: result });
219
+ else if (status === "cancelled")
220
+ this.settle?.(fail("conflict", "the run was cancelled"));
221
+ else
222
+ this.settle?.(fail(kindOf(error?.code), error?.message ?? "the run failed"));
223
+ }
224
+ /**
225
+ * Whether a snapshot's message is news to the listeners. In the first
226
+ * snapshot of a run this client started, all are. In a later one (after
227
+ * a reconnect, a resync or a missed frame) those past the last applied
228
+ * seq are: they went out while this client wasn't following. The first
229
+ * snapshot after `attach` or a reload is history.
230
+ */
231
+ isNews(message) {
232
+ if (this.firstSnapshotIsNews)
233
+ return true;
234
+ return this.lastSeq >= 0 && message.seq !== undefined && message.seq > this.lastSeq;
235
+ }
236
+ }
237
+ /** The factory for `action`: `start`, `attach`, `idle`. */
238
+ export function runFactory(host, action, rid, meta) {
239
+ const make = () => new RunImpl(host, action, rid, meta);
240
+ return {
241
+ action,
242
+ start: (...args) => make().start(...args),
243
+ attach: (run) => make().attach(run),
244
+ idle: make,
245
+ };
246
+ }
@@ -0,0 +1,3 @@
1
+ export declare function savedRun(key: string): string | undefined;
2
+ export declare function saveRun(key: string, run: string): void;
3
+ export declare function forgetRun(key: string): void;
@@ -0,0 +1,20 @@
1
+ // Where `useRun(…, { key })` keeps a run's id across page reloads: the
2
+ // tab's `sessionStorage`, or nowhere when there is none (Node, tests).
3
+ const PREFIX = "vantage.run.";
4
+ function session() {
5
+ try {
6
+ return globalThis.sessionStorage ?? undefined;
7
+ }
8
+ catch {
9
+ return undefined; // storage disabled (privacy mode, sandboxed frame)
10
+ }
11
+ }
12
+ export function savedRun(key) {
13
+ return session()?.getItem(PREFIX + key) ?? undefined;
14
+ }
15
+ export function saveRun(key, run) {
16
+ session()?.setItem(PREFIX + key, run);
17
+ }
18
+ export function forgetRun(key) {
19
+ session()?.removeItem(PREFIX + key);
20
+ }
@@ -0,0 +1,92 @@
1
+ import type { Json, RunCode } from "../protocol.js";
2
+ import type { ActionResult } from "../types.js";
3
+ /**
4
+ * One action's run types, as `generate` emits them: what it takes (`args`),
5
+ * the variables it shares (`state`), the ones a client may write (`inputs`)
6
+ * and the messages it sends.
7
+ */
8
+ export interface RunTypes {
9
+ args: object;
10
+ state: object;
11
+ inputs: object;
12
+ messages: object;
13
+ }
14
+ /**
15
+ * `idle` before `start`; `starting` until the server answers; then the
16
+ * server's `running`, `done`, `failed` or `cancelled`.
17
+ */
18
+ export type RunStatus = "idle" | "starting" | "running" | "done" | "failed" | "cancelled";
19
+ /** Why a run was refused or failed. `offline`: the client wasn't connected. */
20
+ export interface RunError {
21
+ readonly code: RunCode | "offline";
22
+ readonly message: string;
23
+ }
24
+ /** The messages the framework sends for any action: a body's `notify(text)` and `log(text)`. */
25
+ export interface FrameworkMessages {
26
+ "vantage.notify": {
27
+ text: string;
28
+ };
29
+ "vantage.log": {
30
+ text: string;
31
+ };
32
+ }
33
+ /** Every message a run of `T` may send: its own and the framework's. */
34
+ export type MessagesOf<T extends RunTypes> = T["messages"] & FrameworkMessages;
35
+ /** A message a run sent: one the action declares, or a framework one (`vantage.notify`, `vantage.log`). */
36
+ export type RunMessageOf<M> = {
37
+ [K in keyof M & string]: {
38
+ readonly name: K;
39
+ readonly payload: M[K];
40
+ };
41
+ }[keyof M & string] | {
42
+ readonly name: `vantage.${string}`;
43
+ readonly payload: Json;
44
+ };
45
+ export interface StartOptions {
46
+ /** The run id; a new UUIDv7 when not given. Invoking an id again attaches to that run instead of starting another. */
47
+ run?: string;
48
+ }
49
+ export type StartArgs<A> = {} extends A ? [args?: A, options?: StartOptions] : [args: A, options?: StartOptions];
50
+ /** `db.run.<action>` and `model.$run.<action>`: starts runs of one action, or attaches to one. */
51
+ export interface RunFactory<T extends RunTypes = RunTypes> {
52
+ readonly action: string;
53
+ start(...args: StartArgs<T["args"]>): Run<T>;
54
+ /** Follow a run started earlier (by this page before a reload, or another tab). */
55
+ attach(run: string): Run<T>;
56
+ /** A run that hasn't started: `start` it later. */
57
+ idle(): Run<T>;
58
+ }
59
+ /** One run of an action. Every getter is reactive. */
60
+ export interface Run<T extends RunTypes = RunTypes> {
61
+ readonly action: string;
62
+ /** The run id; empty while `idle`. */
63
+ readonly id: string;
64
+ readonly status: RunStatus;
65
+ /** The shared variables, as the server last sent them (an input shows your write at once). */
66
+ readonly state: Readonly<T["state"]>;
67
+ /** The body's return value, once `done`. Sent as the body returns it, not filtered by the API's column whitelists. */
68
+ readonly result: unknown;
69
+ readonly error: RunError | null;
70
+ /** The messages so far (the last 100), oldest first; after an attach, the run's buffer. */
71
+ readonly messages: readonly RunMessageOf<T["messages"]>[];
72
+ /**
73
+ * Call `fn` for each message `name` that arrives from now on, the
74
+ * framework's `vantage.notify` and `vantage.log` included. A run this
75
+ * client started also delivers what it sent before the first snapshot;
76
+ * after an attach, earlier messages are only in `messages`. Returns the
77
+ * unsubscribe.
78
+ */
79
+ on<K extends keyof MessagesOf<T> & string>(name: K, fn: (payload: MessagesOf<T>[K]) => void): () => void;
80
+ /** Write an input variable; shown at once, rolled back if the server refuses. */
81
+ set<K extends keyof T["inputs"] & string>(name: K, value: T["inputs"][K]): Promise<ActionResult<undefined>>;
82
+ /** Stop the run at its next wait, sleep or host call. */
83
+ cancel(): Promise<ActionResult<undefined>>;
84
+ /** Start this run (again, with a new id when it had one); returns it. */
85
+ start(...args: StartArgs<T["args"]>): Run<T>;
86
+ /** Follow run `run` in place of whatever this one was. */
87
+ attach(run: string): Run<T>;
88
+ /** Stop following and go back to `idle`; the server's run is not touched. `start` doesn't need it first. */
89
+ forget(): void;
90
+ /** Settles once the run is over: the result, or why it failed, was cancelled or was refused. */
91
+ readonly done: Promise<ActionResult<unknown>>;
92
+ }
@@ -0,0 +1,3 @@
1
+ // The public types of action runs: a run factory per action, and the run
2
+ // it starts or attaches to.
3
+ export {};
@@ -0,0 +1,26 @@
1
+ import type { ServerFrame } from "../protocol.js";
2
+ import type { RunMeta } from "../schema-meta.js";
3
+ export declare class RunVariables {
4
+ private meta;
5
+ private cells;
6
+ /** The values the server last sent; an input rolls back to these. */
7
+ private server;
8
+ /** Input writes in flight, per variable, and the ones the server changed meanwhile. */
9
+ private pending;
10
+ private changedWhilePending;
11
+ /** The variables as `run.state`: reading one tracks its signal. */
12
+ readonly state: Readonly<Record<string, unknown>>;
13
+ constructor(meta: RunMeta);
14
+ /** Whether the client writes `name` (an `input: true` variable). */
15
+ isInput(name: string): boolean;
16
+ /** Back to the declared defaults; writes still in flight roll back to them. */
17
+ reset(): void;
18
+ /** A value the server sent; it shows unless a write of `name` is in flight. */
19
+ fromServer(name: string, value: unknown): void;
20
+ /**
21
+ * Show `value` at once and `send` it. When the last write of `name` in
22
+ * flight is refused, or the server moved on meanwhile, its value wins.
23
+ */
24
+ write(name: string, value: unknown, send: () => Promise<ServerFrame>): Promise<ServerFrame>;
25
+ private cell;
26
+ }
@@ -0,0 +1,70 @@
1
+ // A run's shared variables on the client: one signal each, so a component
2
+ // re-renders only for what it reads, and the optimistic input writes that
3
+ // roll back to the server's value when refused or overtaken.
4
+ import { State } from "../signal.js";
5
+ export class RunVariables {
6
+ meta;
7
+ cells = new Map();
8
+ /** The values the server last sent; an input rolls back to these. */
9
+ server = new Map();
10
+ /** Input writes in flight, per variable, and the ones the server changed meanwhile. */
11
+ pending = new Map();
12
+ changedWhilePending = new Set();
13
+ /** The variables as `run.state`: reading one tracks its signal. */
14
+ state;
15
+ constructor(meta) {
16
+ this.meta = meta;
17
+ const declared = () => meta.state ?? {};
18
+ this.state = new Proxy({}, {
19
+ get: (_, name) => (typeof name === "string" ? this.cell(name).get() : undefined),
20
+ has: (_, name) => typeof name === "string" && name in declared(),
21
+ ownKeys: () => Object.keys(declared()),
22
+ getOwnPropertyDescriptor: (_, name) => (typeof name === "string" && name in declared() ? { enumerable: true, configurable: true, value: this.cell(name).peek() } : undefined),
23
+ });
24
+ }
25
+ /** Whether the client writes `name` (an `input: true` variable). */
26
+ isInput(name) {
27
+ return this.meta.state?.[name]?.input === true;
28
+ }
29
+ /** Back to the declared defaults; writes still in flight roll back to them. */
30
+ reset() {
31
+ this.pending.clear();
32
+ this.changedWhilePending.clear();
33
+ for (const [name, info] of Object.entries(this.meta.state ?? {}))
34
+ this.fromServer(name, info.default);
35
+ }
36
+ /** A value the server sent; it shows unless a write of `name` is in flight. */
37
+ fromServer(name, value) {
38
+ this.server.set(name, value);
39
+ if (this.pending.has(name))
40
+ this.changedWhilePending.add(name);
41
+ else
42
+ this.cell(name).set(value);
43
+ }
44
+ /**
45
+ * Show `value` at once and `send` it. When the last write of `name` in
46
+ * flight is refused, or the server moved on meanwhile, its value wins.
47
+ */
48
+ async write(name, value, send) {
49
+ const cell = this.cell(name);
50
+ cell.set(value);
51
+ this.pending.set(name, (this.pending.get(name) ?? 0) + 1);
52
+ const reply = await send();
53
+ const left = (this.pending.get(name) ?? 1) - 1;
54
+ if (left > 0) {
55
+ this.pending.set(name, left);
56
+ return reply;
57
+ }
58
+ this.pending.delete(name);
59
+ if (reply.t !== "ack" || this.changedWhilePending.has(name))
60
+ cell.set(this.server.get(name));
61
+ this.changedWhilePending.delete(name);
62
+ return reply;
63
+ }
64
+ cell(name) {
65
+ let cell = this.cells.get(name);
66
+ if (!cell)
67
+ this.cells.set(name, (cell = new State(this.meta.state?.[name]?.default)));
68
+ return cell;
69
+ }
70
+ }