@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
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.4.3 — 2026-10-13
|
|
4
|
+
|
|
5
|
+
- A run delivers to `on`/`useRunMessage` the messages sent while the connection was down or across a `resync`, from the server's `seq` on each message.
|
|
6
|
+
|
|
7
|
+
## 0.4.2 — 2026-10-13
|
|
8
|
+
|
|
9
|
+
- MockServer fixture `actions` also accept a project action's YAML key with dashes (`check-overdue` for `check_overdue`); naming one action both ways throws.
|
|
10
|
+
- Docs: fixture action keys are wire names, handlers are typed with `ActionHandler` and `MockRunControl`, and `run.start()` on a finished run begins a new run.
|
|
11
|
+
- Docs: rebuilding a list from `run.messages` after a reload, `start()` with no argument, and why `XFields` columns are nullable.
|
|
12
|
+
|
|
13
|
+
## 0.4.1 — 2026-10-13
|
|
14
|
+
|
|
15
|
+
- `db.reconnect()` connects again after `refused`, calling `token` afresh, for a sign-in form.
|
|
16
|
+
- The generated file's header says how it is regenerated: by the web dev container from `schema.json`, or with `npx vantage-client generate`.
|
|
17
|
+
|
|
18
|
+
## 0.4.0 — 2026-10-13
|
|
19
|
+
|
|
20
|
+
- Actions run as runs: `db.run.<action>` and `model.$run.<action>` give `start(args, { run? })`, `attach(id)` and `idle()`; a `Run` has reactive `status`, `state`, `result`, `error` and `messages`, plus `on`, `set` (inputs, optimistic), `cancel` and `done`. Breaking: `db.run.x(input)` is now `db.run.x.start(input)`.
|
|
21
|
+
- Row and toolbar action methods run the action and wait for its end; they no longer return the server's record.
|
|
22
|
+
- React: `useRun(factory, { key })` (re-attaches after a page reload), `useShared` and `useRunMessage`.
|
|
23
|
+
- `generate` emits `<Action>Run` (`args`, `state`, `inputs`, `messages`) per action and `$run` on models; the runtime schema carries each action's `state` and `messages`.
|
|
24
|
+
- `hello` sends `features: ["runs:1"]`; a server without it fails runs with a clear message. An unanswered `start` is sent again with the same id after a reconnect or `resync`, and running runs attach again; unknown frames, fields and statuses are ignored.
|
|
25
|
+
- `run.on`/`useRunMessage` take `vantage.notify` and `vantage.log`; `run.forget()` returns a run to `idle`; `useRun` keys are scoped to the action.
|
|
26
|
+
- Removed: `db.jobs`, `stopJob`, `useJobs`, the `Job` type and `ActionResult.job`.
|
|
27
|
+
- MockServer runs action handlers as runs (`(row, args, run)`), with `server.runs`.
|
|
28
|
+
|
|
29
|
+
## 0.3.0 — 2026-10-13
|
|
30
|
+
|
|
31
|
+
- Connects to `/v1/ws` on the page's origin by default; outside a browser a `url` is required.
|
|
32
|
+
- `hello` sends `protocol: 1` and the `token` option.
|
|
33
|
+
- `db.connection` is `{ status, halted, error? }`: `online`/`offline` became `open`/`closed`, plus final `refused` and `protocol_mismatch` (`halted`); `db.ready` settles on them and resolves with the connection. `useConnection()` returns `halted` too.
|
|
34
|
+
- `resync` reopens what is shown without flashing rows; refusals land on `error` and are not retried.
|
|
35
|
+
- `useRecord` returns `{ value, status, error }`; collections and windows carry `error`.
|
|
36
|
+
- Windows add `total`, `totalExact`, `settled`, `length`, `lengthExact` and `whenLoaded()`; collections and `useCollection` add `totalExact`.
|
|
37
|
+
- The same collection description gives the same object while it is in use.
|
|
38
|
+
- Removed: `filter`/`find`/`some`/`every` on collections, and the root exports `Reaction`, `track`, `untracked`, `WebSocketTransport`, `defaultUrl`, `PROTOCOL`, `uuidv7` and `DraftFields`. `Vantage` is exported as a type only, and internal members are left out of the published types.
|
|
39
|
+
- `observer` keeps a generic component's type.
|
|
40
|
+
- MockServer adds `opened` and `refuseWrites`/`allowWrites`.
|
|
41
|
+
- Edits queued while offline are sent on reconnect in the same page only; a reload loses them.
|
|
42
|
+
- Draft members are `$`-prefixed (`$flash`, `$isDirty`, `$status`, `$errors`, `$dirty`, `$revert`); columns, references and row actions with reserved names are refused.
|
|
43
|
+
- `npx vantage-client generate --from <url | file>` replaces the Rust `generate-ts`; it reads the running server's `/v1/schema` directly.
|
|
44
|
+
- Development runs on local Node 22+; no Docker.
|
|
45
|
+
- The generated `schema` carries `sortable`, `filter`, `defaultSort` (`{ column, direction }`) and each enum column's values; labels in generated comments are escaped.
|
|
46
|
+
- Ships an ESM build with types, the `generate` CLI, and the `vantage-client` skill.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { ClientRuns } from "./client-runs.js";
|
|
2
|
+
import type { Connection } from "./types.js";
|
|
3
|
+
export declare class ClientConnection extends ClientRuns {
|
|
4
|
+
/** Bumps on every connect: events from an earlier socket are ignored. */
|
|
5
|
+
private attempt;
|
|
6
|
+
/** `reconnect()` calls waiting for the attempt to open or be refused. */
|
|
7
|
+
private reconnecting;
|
|
8
|
+
/**
|
|
9
|
+
* Connect again after the server refused this client, calling `token`
|
|
10
|
+
* afresh: what a sign-in form does once the user has entered a new token.
|
|
11
|
+
* Collections and records the refusal failed get another try. Resolves
|
|
12
|
+
* like `ready` does, once the new connection has loaded the `instant`
|
|
13
|
+
* tables or has been refused again. Only `refused` reconnects; in any
|
|
14
|
+
* other state this resolves with the connection as it is.
|
|
15
|
+
*/
|
|
16
|
+
reconnect(): Promise<Connection>;
|
|
17
|
+
/** Answer the `reconnect()` calls waiting on this attempt. */
|
|
18
|
+
private settleReconnect;
|
|
19
|
+
protected start(): Promise<void>;
|
|
20
|
+
private connect;
|
|
21
|
+
private hello;
|
|
22
|
+
/**
|
|
23
|
+
* Reopen every collection that is pinned or shown, and send the record
|
|
24
|
+
* loads queued meanwhile. With `reload`, loaded records outside the shown
|
|
25
|
+
* collections are fetched again too. Rows on screen stay until their fresh
|
|
26
|
+
* copies arrive; records already loading keep their request.
|
|
27
|
+
*/
|
|
28
|
+
private reopen;
|
|
29
|
+
/** The server swapped to a reloaded API: follow its new journal and reopen what is shown. */
|
|
30
|
+
private resync;
|
|
31
|
+
private checkSchema;
|
|
32
|
+
/**
|
|
33
|
+
* A final answer from the server: stop, don't reconnect, and settle
|
|
34
|
+
* everything waiting (`ready`, collection and record loads, queued writes)
|
|
35
|
+
* with `error`, writes queued offline included.
|
|
36
|
+
*/
|
|
37
|
+
private halt;
|
|
38
|
+
private lost;
|
|
39
|
+
private receive;
|
|
40
|
+
private applyChange;
|
|
41
|
+
}
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
// The connection lifecycle: connect and `hello`, reconnect with backoff,
|
|
2
|
+
// the final `refused`/`protocol_mismatch` states, `resync` after an API
|
|
3
|
+
// reload, and the server-pushed frames (`changed`, `members`, `run`,
|
|
4
|
+
// `reload`).
|
|
5
|
+
import { ClientRuns } from "./client-runs.js";
|
|
6
|
+
import { FEATURES, PROTOCOL } from "./protocol.js";
|
|
7
|
+
import { batch } from "./signal.js";
|
|
8
|
+
export class ClientConnection extends ClientRuns {
|
|
9
|
+
/** Bumps on every connect: events from an earlier socket are ignored. */
|
|
10
|
+
attempt = 0;
|
|
11
|
+
/** `reconnect()` calls waiting for the attempt to open or be refused. */
|
|
12
|
+
reconnecting = [];
|
|
13
|
+
/**
|
|
14
|
+
* Connect again after the server refused this client, calling `token`
|
|
15
|
+
* afresh: what a sign-in form does once the user has entered a new token.
|
|
16
|
+
* Collections and records the refusal failed get another try. Resolves
|
|
17
|
+
* like `ready` does, once the new connection has loaded the `instant`
|
|
18
|
+
* tables or has been refused again. Only `refused` reconnects; in any
|
|
19
|
+
* other state this resolves with the connection as it is.
|
|
20
|
+
*/
|
|
21
|
+
reconnect() {
|
|
22
|
+
if (this.connection.peek().status !== "refused")
|
|
23
|
+
return Promise.resolve(this.connection.peek());
|
|
24
|
+
const settled = new Promise((resolve) => this.reconnecting.push(resolve));
|
|
25
|
+
this.closed = false;
|
|
26
|
+
this.retry = 0;
|
|
27
|
+
for (const cell of this.collections.values())
|
|
28
|
+
cell.reset();
|
|
29
|
+
for (const cell of this.store.allCells())
|
|
30
|
+
if (cell.presence.peek() === "error")
|
|
31
|
+
cell.presence.set("none");
|
|
32
|
+
this.sweeper = setInterval(() => this.sweep(), 5_000);
|
|
33
|
+
this.connect();
|
|
34
|
+
return settled;
|
|
35
|
+
}
|
|
36
|
+
/** Answer the `reconnect()` calls waiting on this attempt. */
|
|
37
|
+
settleReconnect() {
|
|
38
|
+
const connection = this.connection.peek();
|
|
39
|
+
for (const resolve of this.reconnecting.splice(0))
|
|
40
|
+
resolve(connection);
|
|
41
|
+
}
|
|
42
|
+
async start() {
|
|
43
|
+
if (this.storage) {
|
|
44
|
+
const saved = await this.storage.load().catch(() => undefined);
|
|
45
|
+
if (saved) {
|
|
46
|
+
batch(() => {
|
|
47
|
+
for (const { table, record } of saved.records) {
|
|
48
|
+
if (this.schema.tables[table]?.persist)
|
|
49
|
+
this.store.cell(table, record.id).applyServer(record, "stale");
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
this.cursor = saved.cursor ?? undefined;
|
|
53
|
+
this.outbox = saved.outbox ?? [];
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
this.connect();
|
|
57
|
+
}
|
|
58
|
+
connect() {
|
|
59
|
+
if (this.closed)
|
|
60
|
+
return;
|
|
61
|
+
this.setStatus("connecting");
|
|
62
|
+
const attempt = ++this.attempt;
|
|
63
|
+
const current = () => attempt === this.attempt;
|
|
64
|
+
this.transport.connect({
|
|
65
|
+
open: () => current() && void this.hello(),
|
|
66
|
+
message: (frame) => current() && this.receive(frame),
|
|
67
|
+
close: (code, reason) => current() && this.lost(code, reason),
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
async hello() {
|
|
71
|
+
let token;
|
|
72
|
+
try {
|
|
73
|
+
token = typeof this.opts.token === "function" ? await this.opts.token() : this.opts.token;
|
|
74
|
+
}
|
|
75
|
+
catch (e) {
|
|
76
|
+
return this.halt("refused", e instanceof Error ? e.message : String(e));
|
|
77
|
+
}
|
|
78
|
+
const welcome = await this.request({ t: "hello", protocol: PROTOCOL, token, cursor: this.cursor, schema_hash: this.schema.hash, features: FEATURES }, true);
|
|
79
|
+
if (welcome.t === "error" && welcome.kind === "offline")
|
|
80
|
+
return; // lost mid-hello: the reconnect says hello again
|
|
81
|
+
if (welcome.t === "error" && welcome.code === "protocol")
|
|
82
|
+
return this.halt("protocol_mismatch", welcome.message);
|
|
83
|
+
if (welcome.t !== "welcome")
|
|
84
|
+
return this.halt("refused", welcome.t === "error" ? welcome.message : `unexpected reply \`${welcome.t}\``);
|
|
85
|
+
if (welcome.protocol !== undefined && welcome.protocol !== PROTOCOL) {
|
|
86
|
+
return this.halt("protocol_mismatch", `the server speaks protocol ${welcome.protocol}, this client ${PROTOCOL}`);
|
|
87
|
+
}
|
|
88
|
+
this.checkSchema(welcome.schema_hash);
|
|
89
|
+
this.retry = 0;
|
|
90
|
+
this.cursor = { epoch: welcome.epoch, seq: welcome.seq };
|
|
91
|
+
this.principal.set(welcome.principal);
|
|
92
|
+
this.features = new Set(welcome.features ?? []);
|
|
93
|
+
this.setStatus("open");
|
|
94
|
+
this.resumeRuns();
|
|
95
|
+
// Bootstrap: instant tables load whole and stay open.
|
|
96
|
+
const instant = Object.entries(this.schema.tables)
|
|
97
|
+
.filter(([, meta]) => meta.load === "instant")
|
|
98
|
+
.map(([name]) => this.collectionCell({ table: name }));
|
|
99
|
+
for (const cell of instant)
|
|
100
|
+
cell.pinned = true;
|
|
101
|
+
await this.reopen(welcome.resync);
|
|
102
|
+
this.markReady();
|
|
103
|
+
this.settleReconnect();
|
|
104
|
+
// Edits made offline go out now, in order.
|
|
105
|
+
const queued = this.flashQueue.splice(0);
|
|
106
|
+
if (queued.length) {
|
|
107
|
+
this.flashQueue = queued;
|
|
108
|
+
void this.flush();
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Reopen every collection that is pinned or shown, and send the record
|
|
113
|
+
* loads queued meanwhile. With `reload`, loaded records outside the shown
|
|
114
|
+
* collections are fetched again too. Rows on screen stay until their fresh
|
|
115
|
+
* copies arrive; records already loading keep their request.
|
|
116
|
+
*/
|
|
117
|
+
async reopen(reload) {
|
|
118
|
+
const shown = [...this.collections.values()].filter((c) => !c.refused && (c.pinned || c.wanted()));
|
|
119
|
+
await Promise.all(shown.map((c) => c.open()));
|
|
120
|
+
if (reload) {
|
|
121
|
+
const inCollections = new Set(shown.flatMap((c) => c.ids.peek().map((id) => `${c.spec.table}\u0000${id}`)));
|
|
122
|
+
const held = new Map();
|
|
123
|
+
for (const cell of this.store.allCells()) {
|
|
124
|
+
if (cell.presence.peek() === "loaded" && !inCollections.has(`${cell.table}\u0000${cell.id}`)) {
|
|
125
|
+
held.set(cell.table, [...(held.get(cell.table) ?? []), cell.id]);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
for (const [table, ids] of held)
|
|
129
|
+
void this.reloadRecords(table, ids);
|
|
130
|
+
}
|
|
131
|
+
if (this.loadQueue.size)
|
|
132
|
+
void this.flushLoads();
|
|
133
|
+
}
|
|
134
|
+
/** The server swapped to a reloaded API: follow its new journal and reopen what is shown. */
|
|
135
|
+
resync(frame) {
|
|
136
|
+
this.checkSchema(frame.schema_hash);
|
|
137
|
+
this.generation++;
|
|
138
|
+
this.cursor = { epoch: frame.epoch, seq: frame.seq };
|
|
139
|
+
this.seq.set(frame.seq);
|
|
140
|
+
this.persistCursor();
|
|
141
|
+
this.bySub.clear();
|
|
142
|
+
// The reloaded API may allow what the old one refused: collections and records get one more try.
|
|
143
|
+
for (const cell of this.collections.values())
|
|
144
|
+
cell.reset();
|
|
145
|
+
for (const cell of this.store.allCells())
|
|
146
|
+
if (cell.presence.peek() === "error")
|
|
147
|
+
cell.presence.set("none");
|
|
148
|
+
void this.reopen(true);
|
|
149
|
+
// An invoke answered around the reload may not stream; asking again does.
|
|
150
|
+
this.resumeRuns();
|
|
151
|
+
}
|
|
152
|
+
checkSchema(hash) {
|
|
153
|
+
if (hash !== this.schema.hash) {
|
|
154
|
+
console.warn(`vantage: client types were generated from schema ${this.schema.hash}, server runs ${hash} — regenerate vantage.gen.ts`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* A final answer from the server: stop, don't reconnect, and settle
|
|
159
|
+
* everything waiting (`ready`, collection and record loads, queued writes)
|
|
160
|
+
* with `error`, writes queued offline included.
|
|
161
|
+
*/
|
|
162
|
+
halt(status, error) {
|
|
163
|
+
this.setStatus(status, error);
|
|
164
|
+
this.close();
|
|
165
|
+
const kind = status === "refused" ? "forbidden" : "unavailable";
|
|
166
|
+
for (const { resolve } of this.flashQueue.splice(0))
|
|
167
|
+
resolve({ t: "error", id: 0, kind, message: error });
|
|
168
|
+
for (const cell of this.collections.values()) {
|
|
169
|
+
const state = cell.loadState.peek();
|
|
170
|
+
// Unloaded cells, and windows still waiting for rows (a viewport in flight).
|
|
171
|
+
if (state === "idle" || state === "loading" || cell.waitingForWindow)
|
|
172
|
+
cell.failed(error);
|
|
173
|
+
}
|
|
174
|
+
for (const [table, ids] of this.loadQueue) {
|
|
175
|
+
for (const id of ids) {
|
|
176
|
+
this.store.cell(table, id).refused(error);
|
|
177
|
+
this.wake(table, id);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
this.loadQueue.clear();
|
|
181
|
+
this.markReady();
|
|
182
|
+
this.settleReconnect();
|
|
183
|
+
}
|
|
184
|
+
lost(code, reason) {
|
|
185
|
+
for (const resolve of this.requests.values())
|
|
186
|
+
resolve({ t: "error", id: 0, kind: "offline", message: "connection lost" });
|
|
187
|
+
this.requests.clear();
|
|
188
|
+
for (const cell of this.collections.values())
|
|
189
|
+
cell.sub = null;
|
|
190
|
+
this.bySub.clear();
|
|
191
|
+
const halted = this.haltedWith !== undefined;
|
|
192
|
+
// 1008 (policy violation): the reloaded API refuses this client's token.
|
|
193
|
+
if (code === 1008 && !halted)
|
|
194
|
+
return this.halt("refused", reason || "the server refused this client");
|
|
195
|
+
if (halted)
|
|
196
|
+
return;
|
|
197
|
+
this.setStatus("closed");
|
|
198
|
+
if (this.closed)
|
|
199
|
+
return;
|
|
200
|
+
const delay = Math.min(10_000, 200 * 2 ** this.retry++);
|
|
201
|
+
setTimeout(() => this.connect(), delay);
|
|
202
|
+
}
|
|
203
|
+
receive(frame) {
|
|
204
|
+
if ("id" in frame && frame.id && this.requests.has(frame.id)) {
|
|
205
|
+
const resolve = this.requests.get(frame.id);
|
|
206
|
+
this.requests.delete(frame.id);
|
|
207
|
+
resolve(frame);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
// Frames of a type this client doesn't know (a newer server's) fall through.
|
|
211
|
+
switch (frame.t) {
|
|
212
|
+
case "changed":
|
|
213
|
+
batch(() => frame.events.forEach((e) => this.applyChange(e)));
|
|
214
|
+
if (frame.seq && this.cursor) {
|
|
215
|
+
this.cursor = { ...this.cursor, seq: Math.max(this.cursor.seq, frame.seq) };
|
|
216
|
+
this.seq.set(this.cursor.seq);
|
|
217
|
+
this.persistCursor();
|
|
218
|
+
}
|
|
219
|
+
break;
|
|
220
|
+
case "members":
|
|
221
|
+
this.applyMembers(frame);
|
|
222
|
+
break;
|
|
223
|
+
case "resync":
|
|
224
|
+
this.resync(frame);
|
|
225
|
+
break;
|
|
226
|
+
case "run":
|
|
227
|
+
this.onRun(frame);
|
|
228
|
+
break;
|
|
229
|
+
case "reload":
|
|
230
|
+
if (this.opts.onReload)
|
|
231
|
+
this.opts.onReload();
|
|
232
|
+
else if (typeof globalThis.location?.reload === "function")
|
|
233
|
+
globalThis.location.reload();
|
|
234
|
+
break;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
applyChange(event) {
|
|
238
|
+
if (event.k === "upsert") {
|
|
239
|
+
const cell = this.store.cell(event.table, event.id);
|
|
240
|
+
cell.applyServer(event.record);
|
|
241
|
+
this.persistLater(cell);
|
|
242
|
+
}
|
|
243
|
+
else if (event.k === "removed") {
|
|
244
|
+
const cell = this.store.peek(event.table, event.id);
|
|
245
|
+
if (cell) {
|
|
246
|
+
cell.markRemoved();
|
|
247
|
+
this.persistLater(cell);
|
|
248
|
+
}
|
|
249
|
+
for (const c of this.collections.values())
|
|
250
|
+
if (c.spec.table === event.table)
|
|
251
|
+
c.drop(event.id);
|
|
252
|
+
}
|
|
253
|
+
else {
|
|
254
|
+
for (const c of this.collections.values()) {
|
|
255
|
+
if (c.spec.table === event.table && c.sub !== null)
|
|
256
|
+
void this.openCollection(c);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { Vantage, VantageOptions } from "./client.js";
|
|
2
|
+
import type { CollectionCell } from "./collection-cell.js";
|
|
3
|
+
import type { Collection } from "./collection.js";
|
|
4
|
+
import { type AnyModel } from "./model.js";
|
|
5
|
+
import type { ClientFrame, Cursor, FlashItem, ServerFrame } from "./protocol.js";
|
|
6
|
+
import type { Storage } from "./persist.js";
|
|
7
|
+
import { State } from "./signal.js";
|
|
8
|
+
import { type RecordCell } from "./store.js";
|
|
9
|
+
import type { TableHandle } from "./table.js";
|
|
10
|
+
import { type Transport } from "./transport.js";
|
|
11
|
+
import type { Connection, ConnectionStatus, SchemaMeta } from "./types.js";
|
|
12
|
+
type Pending = (frame: ServerFrame) => void;
|
|
13
|
+
export declare class ClientCore {
|
|
14
|
+
readonly connection: State<Connection>;
|
|
15
|
+
readonly principal: State<string | null>;
|
|
16
|
+
readonly seq: State<number>;
|
|
17
|
+
/**
|
|
18
|
+
* Resolves with the connection once `instant` tables have loaded for the
|
|
19
|
+
* first time, or once the server refused the client for good (`halted`).
|
|
20
|
+
*/
|
|
21
|
+
readonly ready: Promise<Connection>;
|
|
22
|
+
readonly schema: SchemaMeta;
|
|
23
|
+
protected transport: Transport;
|
|
24
|
+
protected opts: VantageOptions;
|
|
25
|
+
protected storage?: Storage;
|
|
26
|
+
protected models: Map<RecordCell, AnyModel>;
|
|
27
|
+
protected classes: Map<string, new (cell: RecordCell) => AnyModel>;
|
|
28
|
+
protected collections: Map<string, CollectionCell>;
|
|
29
|
+
/** One Collection per description, kept while one of its cells is. */
|
|
30
|
+
protected interned: Map<string, Collection>;
|
|
31
|
+
protected bySub: Map<number, CollectionCell>;
|
|
32
|
+
protected handles: Map<string, TableHandle<any, any, any>>;
|
|
33
|
+
protected requests: Map<number, Pending>;
|
|
34
|
+
protected nextId: number;
|
|
35
|
+
protected cursor?: Cursor;
|
|
36
|
+
protected loadQueue: Map<string, Set<string>>;
|
|
37
|
+
protected loadWaiters: Map<string, (() => void)[]>;
|
|
38
|
+
protected flashQueue: {
|
|
39
|
+
item: FlashItem;
|
|
40
|
+
resolve: (frame: ServerFrame) => void;
|
|
41
|
+
}[];
|
|
42
|
+
protected outbox: FlashItem[];
|
|
43
|
+
protected undoStack: {
|
|
44
|
+
table: string;
|
|
45
|
+
id: string;
|
|
46
|
+
before: Record<string, unknown>;
|
|
47
|
+
after: Record<string, unknown>;
|
|
48
|
+
}[];
|
|
49
|
+
protected retry: number;
|
|
50
|
+
/** Bumps on every `resync`: replies to frames sent before it are stale. */
|
|
51
|
+
protected generation: number;
|
|
52
|
+
protected closed: boolean;
|
|
53
|
+
protected sweeper?: ReturnType<typeof setInterval>;
|
|
54
|
+
private resolveReady;
|
|
55
|
+
protected persistTimer?: ReturnType<typeof setTimeout>;
|
|
56
|
+
protected dirtyCells: Set<RecordCell>;
|
|
57
|
+
constructor(options: VantageOptions);
|
|
58
|
+
/** Settle `ready` with the connection as it is now; later calls change nothing. */
|
|
59
|
+
protected markReady(): void;
|
|
60
|
+
/** This client as the typed `Vantage` the models and collections hold. */
|
|
61
|
+
protected get db(): Vantage<any>;
|
|
62
|
+
close(): void;
|
|
63
|
+
protected get online(): boolean;
|
|
64
|
+
protected setStatus(status: ConnectionStatus, error?: string): void;
|
|
65
|
+
protected send(frame: ClientFrame): void;
|
|
66
|
+
protected persistLater(cell: RecordCell): void;
|
|
67
|
+
protected persistCursor(): void;
|
|
68
|
+
private modelClass;
|
|
69
|
+
}
|
|
70
|
+
export {};
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// The client's state and plumbing: the transport and request routing, the
|
|
2
|
+
// connection status, persistence, and the model instances. The layers on
|
|
3
|
+
// top (loading, writes, connection) and `Vantage` itself extend it.
|
|
4
|
+
import { buildModelClass } from "./model.js";
|
|
5
|
+
import { State } from "./signal.js";
|
|
6
|
+
import { Store } from "./store.js";
|
|
7
|
+
import { WebSocketTransport, defaultUrl } from "./transport.js";
|
|
8
|
+
export class ClientCore {
|
|
9
|
+
/** @internal */ store = new Store();
|
|
10
|
+
connection = new State({ status: "connecting", halted: false });
|
|
11
|
+
principal = new State(null);
|
|
12
|
+
seq = new State(0);
|
|
13
|
+
/**
|
|
14
|
+
* Resolves with the connection once `instant` tables have loaded for the
|
|
15
|
+
* first time, or once the server refused the client for good (`halted`).
|
|
16
|
+
*/
|
|
17
|
+
ready;
|
|
18
|
+
schema;
|
|
19
|
+
transport;
|
|
20
|
+
opts;
|
|
21
|
+
storage;
|
|
22
|
+
models = new Map();
|
|
23
|
+
classes = new Map();
|
|
24
|
+
collections = new Map();
|
|
25
|
+
/** One Collection per description, kept while one of its cells is. */
|
|
26
|
+
interned = new Map();
|
|
27
|
+
bySub = new Map();
|
|
28
|
+
handles = new Map();
|
|
29
|
+
requests = new Map();
|
|
30
|
+
nextId = 1;
|
|
31
|
+
cursor;
|
|
32
|
+
loadQueue = new Map();
|
|
33
|
+
loadWaiters = new Map();
|
|
34
|
+
flashQueue = [];
|
|
35
|
+
outbox = [];
|
|
36
|
+
undoStack = [];
|
|
37
|
+
retry = 0;
|
|
38
|
+
/** Bumps on every `resync`: replies to frames sent before it are stale. */
|
|
39
|
+
generation = 0;
|
|
40
|
+
closed = false;
|
|
41
|
+
sweeper;
|
|
42
|
+
resolveReady;
|
|
43
|
+
persistTimer;
|
|
44
|
+
dirtyCells = new Set();
|
|
45
|
+
constructor(options) {
|
|
46
|
+
this.opts = options;
|
|
47
|
+
this.schema = options.schema;
|
|
48
|
+
this.storage = options.storage;
|
|
49
|
+
this.transport = options.transport ?? new WebSocketTransport(options.url ?? defaultUrl());
|
|
50
|
+
this.ready = new Promise((resolve) => (this.resolveReady = resolve));
|
|
51
|
+
}
|
|
52
|
+
/** Settle `ready` with the connection as it is now; later calls change nothing. */
|
|
53
|
+
markReady() {
|
|
54
|
+
this.resolveReady(this.connection.peek());
|
|
55
|
+
}
|
|
56
|
+
/** This client as the typed `Vantage` the models and collections hold. */
|
|
57
|
+
get db() {
|
|
58
|
+
return this;
|
|
59
|
+
}
|
|
60
|
+
/** @internal */ get keepAliveMs() {
|
|
61
|
+
return this.opts.keepAliveMs ?? 30_000;
|
|
62
|
+
}
|
|
63
|
+
close() {
|
|
64
|
+
this.closed = true;
|
|
65
|
+
clearInterval(this.sweeper);
|
|
66
|
+
this.transport.close();
|
|
67
|
+
}
|
|
68
|
+
get online() {
|
|
69
|
+
return this.connection.peek().status === "open";
|
|
70
|
+
}
|
|
71
|
+
/** @internal The server's final refusal (`refused`, `protocol_mismatch`), once there is one. */
|
|
72
|
+
get haltedWith() {
|
|
73
|
+
const { status, error, halted } = this.connection.peek();
|
|
74
|
+
return halted ? (error ?? status) : undefined;
|
|
75
|
+
}
|
|
76
|
+
setStatus(status, error) {
|
|
77
|
+
const now = this.connection.peek();
|
|
78
|
+
if (now.status === status && now.error === error)
|
|
79
|
+
return;
|
|
80
|
+
const halted = status === "refused" || status === "protocol_mismatch";
|
|
81
|
+
this.connection.set(error === undefined ? { status, halted } : { status, halted, error });
|
|
82
|
+
}
|
|
83
|
+
/** @internal */ request(frame, force = false) {
|
|
84
|
+
if (!force && !this.online)
|
|
85
|
+
return Promise.resolve({ t: "error", id: 0, kind: "offline", message: "not connected" });
|
|
86
|
+
const id = this.nextId++;
|
|
87
|
+
return new Promise((resolve) => {
|
|
88
|
+
this.requests.set(id, resolve);
|
|
89
|
+
this.transport.send({ ...frame, id });
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
send(frame) {
|
|
93
|
+
this.transport.send(frame);
|
|
94
|
+
}
|
|
95
|
+
// ------------------------------------------------------------ persistence
|
|
96
|
+
persistLater(cell) {
|
|
97
|
+
if (!this.storage || !this.schema.tables[cell.table]?.persist)
|
|
98
|
+
return;
|
|
99
|
+
this.dirtyCells.add(cell);
|
|
100
|
+
clearTimeout(this.persistTimer);
|
|
101
|
+
this.persistTimer = setTimeout(() => {
|
|
102
|
+
const cells = [...this.dirtyCells];
|
|
103
|
+
this.dirtyCells.clear();
|
|
104
|
+
void this.storage.save({
|
|
105
|
+
records: cells.map((c) => ({ table: c.table, id: c.id, record: c.presence.peek() === "missing" ? null : c.baseline })),
|
|
106
|
+
cursor: this.cursor,
|
|
107
|
+
});
|
|
108
|
+
}, 250);
|
|
109
|
+
}
|
|
110
|
+
persistCursor() {
|
|
111
|
+
if (this.storage && this.cursor)
|
|
112
|
+
void this.storage.save({ cursor: this.cursor });
|
|
113
|
+
}
|
|
114
|
+
// ------------------------------------------------------------ models
|
|
115
|
+
/** @internal The one model instance for (table, id). */
|
|
116
|
+
model(table, id) {
|
|
117
|
+
const cell = this.store.cell(table, id);
|
|
118
|
+
let model = this.models.get(cell);
|
|
119
|
+
if (!model) {
|
|
120
|
+
const Class = this.modelClass(table);
|
|
121
|
+
model = new Class(cell);
|
|
122
|
+
this.models.set(cell, model);
|
|
123
|
+
}
|
|
124
|
+
return model;
|
|
125
|
+
}
|
|
126
|
+
/** @internal */ meta(table) {
|
|
127
|
+
const meta = this.schema.tables[table];
|
|
128
|
+
if (!meta)
|
|
129
|
+
throw new Error(`vantage: unknown table \`${table}\``);
|
|
130
|
+
return meta;
|
|
131
|
+
}
|
|
132
|
+
modelClass(table) {
|
|
133
|
+
let Class = this.classes.get(table);
|
|
134
|
+
if (!Class) {
|
|
135
|
+
Class = buildModelClass(this.db, table, this.meta(table));
|
|
136
|
+
this.classes.set(table, Class);
|
|
137
|
+
}
|
|
138
|
+
return Class;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { ClientCore } from "./client-core.js";
|
|
2
|
+
import type { ServerFrame } from "./protocol.js";
|
|
3
|
+
export declare class ClientLoading extends ClientCore {
|
|
4
|
+
protected flushLoads(): Promise<void>;
|
|
5
|
+
protected wake(table: string, id: string): void;
|
|
6
|
+
/** Close the cell's sub on the server and forget it, so its `members` no longer reach the cell. */
|
|
7
|
+
private dropSub;
|
|
8
|
+
protected applyMembers(frame: Extract<ServerFrame, {
|
|
9
|
+
t: "members";
|
|
10
|
+
}>): void;
|
|
11
|
+
/** Close collections and drop records nothing has shown for a while. */
|
|
12
|
+
protected sweep(): void;
|
|
13
|
+
}
|