@vantage-ui/client 0.4.4 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/dist/client-connection.d.ts +22 -5
- package/dist/client-connection.js +89 -17
- package/dist/client-core.d.ts +12 -2
- package/dist/client-core.js +18 -0
- package/dist/client-loading.js +7 -5
- package/dist/client-writes.js +5 -5
- package/dist/collection.js +9 -4
- package/dist/index.d.ts +1 -1
- package/dist/mock-store.d.ts +10 -0
- package/dist/persist.d.ts +8 -2
- package/dist/persist.js +18 -3
- package/dist/protocol.d.ts +47 -18
- package/dist/protocol.js +12 -2
- package/dist/run/run.d.ts +1 -1
- package/dist/run/run.js +6 -2
- package/dist/run/types.d.ts +7 -2
- package/dist/testing.d.ts +2 -0
- package/dist/testing.js +35 -1
- package/dist/types.d.ts +6 -1
- package/dist/util.d.ts +5 -1
- package/dist/util.js +10 -4
- package/package.json +1 -1
- package/skills/vantage-client/SKILL.md +38 -10
- package/skills/vantage-client/references/protocol.md +13 -3
- package/skills/vantage-client/references/react.md +4 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.0 — 2026-10-18
|
|
4
|
+
|
|
5
|
+
- `db.reconnect()` works from any state: it closes an open socket and connects again with `token` called afresh, so a sign-in over an open anonymous connection switches to the new token. Breaking: before, it did nothing unless the connection was `refused`.
|
|
6
|
+
- Sign-in over auth `session`: connect with no token (the `welcome` names `anonymous`), run the sign-in action, store the token from its result, `reconnect()`. The skill and the protocol reference say how.
|
|
7
|
+
- A 1008 close with `signed out` (the session ended, expired or was revoked) reads as `refused` with that error.
|
|
8
|
+
- `hello` sends `features: ["runs:1", "session:1"]`.
|
|
9
|
+
- Edits queued offline under one principal are never sent under another: when a `welcome` names someone else (a sign-out and sign-in on one page), each settles with an error and the outbox is emptied. People are told apart by `welcome.principal_id` (stable and unique), not the display name, so two people with one name never share edits; an older server without the id falls back to the name. `reconnect()` also cancels a pending backoff reconnect, so it opens one socket, not two.
|
|
10
|
+
- The saved outbox records which principal id made each edit. On the first `welcome` after a reload, saved edits from anyone else (or saved by an older client, unattributed) are dropped with a console warning and never sent. `Persisted.outbox` and `PersistChange.outbox` are now `OutboxEntry[]` (`{ item, principal }`); a custom `Storage` saves and returns them as given.
|
|
11
|
+
- An `invalidated` change (live or replayed) also re-reads the table's records held outside any open collection, not only its collections, so a change the server can't name per row still reaches them.
|
|
12
|
+
- A window's `items` stops at the last row once `lengthExact` is true. A 100-row window over a 22-row table now holds 22 items, not 22 and 78 `undefined` slots that never load (a list rendered `items` as an endless tail of placeholders).
|
|
13
|
+
- A sign-in page works with `instant` tables: while the `welcome` is `anonymous`, the client sends no table read (no `open_table`, no `open_records`); collections and records wait, loading, instead of failing with "sign in first", and load at the signed-in `welcome`.
|
|
14
|
+
- A write or run an access rule refused says what refused: the server's `code: "forbidden"` frames carry `table`, `rule` and `reason`, and the client puts them on `error.forbidden` (an `ActionError`, with `kind: "forbidden"`) and `run.error.forbidden`. The protocol types gain `AccessRefusal` and `RejectedFrame`; `AccessRefusal` is exported.
|
|
15
|
+
- The skill and protocol reference cover an API profile's own `sign_in:` list for anonymous sockets.
|
|
16
|
+
- MockServer fixtures take `principals` (token → the name `welcome` gives), `principalIds` (token → its `principal_id`) and `signIn` (a tokenless `hello` is anonymous, and its table reads and writes are refused).
|
|
17
|
+
|
|
3
18
|
## 0.4.4 — 2026-10-13
|
|
4
19
|
|
|
5
20
|
- A README for the npm page, plus `keywords` and `homepage`.
|
|
@@ -5,15 +5,32 @@ export declare class ClientConnection extends ClientRuns {
|
|
|
5
5
|
private attempt;
|
|
6
6
|
/** `reconnect()` calls waiting for the attempt to open or be refused. */
|
|
7
7
|
private reconnecting;
|
|
8
|
+
/** The backoff reconnect a lost socket scheduled; `reconnect()` replaces it. */
|
|
9
|
+
private backoff?;
|
|
8
10
|
/**
|
|
9
|
-
* Connect again
|
|
10
|
-
*
|
|
11
|
-
*
|
|
11
|
+
* Connect again with `token` called afresh, from any state: what a
|
|
12
|
+
* sign-in does once it has a token (a signed-in session's, after an
|
|
13
|
+
* anonymous `hello`), or a sign-out once it has dropped one. A live socket
|
|
14
|
+
* is closed first; requests it still owed settle as `offline`.
|
|
15
|
+
* Collections and records a refusal failed get another try. Resolves
|
|
12
16
|
* like `ready` does, once the new connection has loaded the `instant`
|
|
13
|
-
* tables or has been refused
|
|
14
|
-
* other state this resolves with the connection as it is.
|
|
17
|
+
* tables or has been refused.
|
|
15
18
|
*/
|
|
16
19
|
reconnect(): Promise<Connection>;
|
|
20
|
+
/**
|
|
21
|
+
* Edits queued while `before` was signed in never go out as `now` (a
|
|
22
|
+
* sign-out and sign-in on one page): each settles with an error, and the
|
|
23
|
+
* offline mirror's outbox is emptied.
|
|
24
|
+
*/
|
|
25
|
+
private dropEditsOf;
|
|
26
|
+
/**
|
|
27
|
+
* The first `welcome` after a reload: edits the saved outbox holds from
|
|
28
|
+
* anyone but `now` (or from an older client, which didn't say whose) are
|
|
29
|
+
* dropped, with a warning, and never sent.
|
|
30
|
+
*/
|
|
31
|
+
private dropRestoredEditsOfOthers;
|
|
32
|
+
/** The socket is gone: what it owed settles `offline`, its subs mean nothing. */
|
|
33
|
+
private forgetSocket;
|
|
17
34
|
/** Answer the `reconnect()` calls waiting on this attempt. */
|
|
18
35
|
private settleReconnect;
|
|
19
36
|
protected start(): Promise<void>;
|
|
@@ -10,18 +10,25 @@ export class ClientConnection extends ClientRuns {
|
|
|
10
10
|
attempt = 0;
|
|
11
11
|
/** `reconnect()` calls waiting for the attempt to open or be refused. */
|
|
12
12
|
reconnecting = [];
|
|
13
|
+
/** The backoff reconnect a lost socket scheduled; `reconnect()` replaces it. */
|
|
14
|
+
backoff;
|
|
13
15
|
/**
|
|
14
|
-
* Connect again
|
|
15
|
-
*
|
|
16
|
-
*
|
|
16
|
+
* Connect again with `token` called afresh, from any state: what a
|
|
17
|
+
* sign-in does once it has a token (a signed-in session's, after an
|
|
18
|
+
* anonymous `hello`), or a sign-out once it has dropped one. A live socket
|
|
19
|
+
* is closed first; requests it still owed settle as `offline`.
|
|
20
|
+
* Collections and records a refusal failed get another try. Resolves
|
|
17
21
|
* like `ready` does, once the new connection has loaded the `instant`
|
|
18
|
-
* tables or has been refused
|
|
19
|
-
* other state this resolves with the connection as it is.
|
|
22
|
+
* tables or has been refused.
|
|
20
23
|
*/
|
|
21
24
|
reconnect() {
|
|
22
|
-
if (this.connection.peek().status !== "refused")
|
|
23
|
-
return Promise.resolve(this.connection.peek());
|
|
24
25
|
const settled = new Promise((resolve) => this.reconnecting.push(resolve));
|
|
26
|
+
// Events from the socket being replaced are ignored from here on, and a
|
|
27
|
+
// backoff reconnect still pending would open a second socket.
|
|
28
|
+
this.attempt++;
|
|
29
|
+
clearTimeout(this.backoff);
|
|
30
|
+
this.transport.close();
|
|
31
|
+
this.forgetSocket();
|
|
25
32
|
this.closed = false;
|
|
26
33
|
this.retry = 0;
|
|
27
34
|
for (const cell of this.collections.values())
|
|
@@ -29,10 +36,49 @@ export class ClientConnection extends ClientRuns {
|
|
|
29
36
|
for (const cell of this.store.allCells())
|
|
30
37
|
if (cell.presence.peek() === "error")
|
|
31
38
|
cell.presence.set("none");
|
|
39
|
+
clearInterval(this.sweeper);
|
|
32
40
|
this.sweeper = setInterval(() => this.sweep(), 5_000);
|
|
33
41
|
this.connect();
|
|
34
42
|
return settled;
|
|
35
43
|
}
|
|
44
|
+
/**
|
|
45
|
+
* Edits queued while `before` was signed in never go out as `now` (a
|
|
46
|
+
* sign-out and sign-in on one page): each settles with an error, and the
|
|
47
|
+
* offline mirror's outbox is emptied.
|
|
48
|
+
*/
|
|
49
|
+
dropEditsOf(before, now) {
|
|
50
|
+
const message = `made as ${before}; not sent as ${now}`;
|
|
51
|
+
for (const { resolve } of this.flashQueue.splice(0))
|
|
52
|
+
resolve({ t: "error", id: 0, kind: "forbidden", message });
|
|
53
|
+
if (this.outbox.length) {
|
|
54
|
+
this.outbox = [];
|
|
55
|
+
void this.storage?.save({ outbox: [] });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* The first `welcome` after a reload: edits the saved outbox holds from
|
|
60
|
+
* anyone but `now` (or from an older client, which didn't say whose) are
|
|
61
|
+
* dropped, with a warning, and never sent.
|
|
62
|
+
*/
|
|
63
|
+
dropRestoredEditsOfOthers(now) {
|
|
64
|
+
this.outboxRestored = false;
|
|
65
|
+
const kept = this.outbox.filter((entry) => entry.principal === now);
|
|
66
|
+
const dropped = this.outbox.length - kept.length;
|
|
67
|
+
if (dropped === 0)
|
|
68
|
+
return;
|
|
69
|
+
console.warn(`vantage: dropped ${dropped} edit(s) saved offline by someone other than ${now}; they were not sent`);
|
|
70
|
+
this.outbox = kept;
|
|
71
|
+
void this.storage?.save({ outbox: kept });
|
|
72
|
+
}
|
|
73
|
+
/** The socket is gone: what it owed settles `offline`, its subs mean nothing. */
|
|
74
|
+
forgetSocket() {
|
|
75
|
+
for (const resolve of this.requests.values())
|
|
76
|
+
resolve({ t: "error", id: 0, kind: "offline", message: "connection lost" });
|
|
77
|
+
this.requests.clear();
|
|
78
|
+
for (const cell of this.collections.values())
|
|
79
|
+
cell.sub = null;
|
|
80
|
+
this.bySub.clear();
|
|
81
|
+
}
|
|
36
82
|
/** Answer the `reconnect()` calls waiting on this attempt. */
|
|
37
83
|
settleReconnect() {
|
|
38
84
|
const connection = this.connection.peek();
|
|
@@ -51,6 +97,7 @@ export class ClientConnection extends ClientRuns {
|
|
|
51
97
|
});
|
|
52
98
|
this.cursor = saved.cursor ?? undefined;
|
|
53
99
|
this.outbox = saved.outbox ?? [];
|
|
100
|
+
this.outboxRestored = this.outbox.length > 0;
|
|
54
101
|
}
|
|
55
102
|
}
|
|
56
103
|
this.connect();
|
|
@@ -88,11 +135,24 @@ export class ClientConnection extends ClientRuns {
|
|
|
88
135
|
this.checkSchema(welcome.schema_hash);
|
|
89
136
|
this.retry = 0;
|
|
90
137
|
this.cursor = { epoch: welcome.epoch, seq: welcome.seq };
|
|
138
|
+
// People are told apart by their stable id, not their display name: two
|
|
139
|
+
// "John Smith"s never share edits.
|
|
140
|
+
const id = welcome.principal_id ?? welcome.principal;
|
|
141
|
+
const before = this.principalId;
|
|
142
|
+
if (before !== null && before !== id)
|
|
143
|
+
this.dropEditsOf(before, id);
|
|
144
|
+
if (this.outboxRestored)
|
|
145
|
+
this.dropRestoredEditsOfOthers(id);
|
|
146
|
+
this.principalId = id;
|
|
91
147
|
this.principal.set(welcome.principal);
|
|
148
|
+
// Anonymous (a sign-in page before sign-in): the server reads no table
|
|
149
|
+
// for it, so nothing opens, `instant` tables included; they load at the
|
|
150
|
+
// signed-in `welcome` that `reconnect()` brings.
|
|
151
|
+
this.anonymous = welcome.anonymous === true;
|
|
92
152
|
this.features = new Set(welcome.features ?? []);
|
|
93
153
|
this.setStatus("open");
|
|
94
154
|
this.resumeRuns();
|
|
95
|
-
// Bootstrap: instant tables load whole and stay open.
|
|
155
|
+
// Bootstrap: instant tables load whole and stay open (once signed in).
|
|
96
156
|
const instant = Object.entries(this.schema.tables)
|
|
97
157
|
.filter(([, meta]) => meta.load === "instant")
|
|
98
158
|
.map(([name]) => this.collectionCell({ table: name }));
|
|
@@ -182,14 +242,11 @@ export class ClientConnection extends ClientRuns {
|
|
|
182
242
|
this.settleReconnect();
|
|
183
243
|
}
|
|
184
244
|
lost(code, reason) {
|
|
185
|
-
|
|
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();
|
|
245
|
+
this.forgetSocket();
|
|
191
246
|
const halted = this.haltedWith !== undefined;
|
|
192
|
-
// 1008 (policy violation): the reloaded API refuses this client's token
|
|
247
|
+
// 1008 (policy violation): the reloaded API refuses this client's token,
|
|
248
|
+
// or its session ended (`signed out`). Either way the token is spent;
|
|
249
|
+
// `reconnect()` with another one.
|
|
193
250
|
if (code === 1008 && !halted)
|
|
194
251
|
return this.halt("refused", reason || "the server refused this client");
|
|
195
252
|
if (halted)
|
|
@@ -198,7 +255,7 @@ export class ClientConnection extends ClientRuns {
|
|
|
198
255
|
if (this.closed)
|
|
199
256
|
return;
|
|
200
257
|
const delay = Math.min(10_000, 200 * 2 ** this.retry++);
|
|
201
|
-
setTimeout(() => this.connect(), delay);
|
|
258
|
+
this.backoff = setTimeout(() => this.connect(), delay);
|
|
202
259
|
}
|
|
203
260
|
receive(frame) {
|
|
204
261
|
if ("id" in frame && frame.id && this.requests.has(frame.id)) {
|
|
@@ -251,10 +308,25 @@ export class ClientConnection extends ClientRuns {
|
|
|
251
308
|
c.drop(event.id);
|
|
252
309
|
}
|
|
253
310
|
else {
|
|
311
|
+
// `invalidated`: the table changed in ways no row names (live, or
|
|
312
|
+
// replayed after a reconnect). Re-read it: the open collections, and
|
|
313
|
+
// the records held outside them, which no collection reload reaches.
|
|
314
|
+
const inCollections = new Set();
|
|
254
315
|
for (const c of this.collections.values()) {
|
|
255
|
-
if (c.spec.table
|
|
316
|
+
if (c.spec.table !== event.table)
|
|
317
|
+
continue;
|
|
318
|
+
for (const id of c.ids.peek())
|
|
319
|
+
inCollections.add(id);
|
|
320
|
+
if (c.sub !== null)
|
|
256
321
|
void this.openCollection(c);
|
|
257
322
|
}
|
|
323
|
+
const held = [];
|
|
324
|
+
for (const cell of this.store.allCells()) {
|
|
325
|
+
if (cell.table === event.table && cell.presence.peek() === "loaded" && !inCollections.has(cell.id))
|
|
326
|
+
held.push(cell.id);
|
|
327
|
+
}
|
|
328
|
+
if (held.length)
|
|
329
|
+
void this.reloadRecords(event.table, held);
|
|
258
330
|
}
|
|
259
331
|
}
|
|
260
332
|
}
|
package/dist/client-core.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type { CollectionCell } from "./collection-cell.js";
|
|
|
3
3
|
import type { Collection } from "./collection.js";
|
|
4
4
|
import { type AnyModel } from "./model.js";
|
|
5
5
|
import type { ClientFrame, Cursor, FlashItem, ServerFrame } from "./protocol.js";
|
|
6
|
-
import type { Storage } from "./persist.js";
|
|
6
|
+
import type { OutboxEntry, Storage } from "./persist.js";
|
|
7
7
|
import { State } from "./signal.js";
|
|
8
8
|
import { type RecordCell } from "./store.js";
|
|
9
9
|
import type { TableHandle } from "./table.js";
|
|
@@ -39,7 +39,9 @@ export declare class ClientCore {
|
|
|
39
39
|
item: FlashItem;
|
|
40
40
|
resolve: (frame: ServerFrame) => void;
|
|
41
41
|
}[];
|
|
42
|
-
protected outbox:
|
|
42
|
+
protected outbox: OutboxEntry[];
|
|
43
|
+
/** The outbox came from storage (a reload): the first `welcome` checks whose it is. */
|
|
44
|
+
protected outboxRestored: boolean;
|
|
43
45
|
protected undoStack: {
|
|
44
46
|
table: string;
|
|
45
47
|
id: string;
|
|
@@ -61,6 +63,14 @@ export declare class ClientCore {
|
|
|
61
63
|
protected get db(): Vantage<any>;
|
|
62
64
|
close(): void;
|
|
63
65
|
protected get online(): boolean;
|
|
66
|
+
/**
|
|
67
|
+
* The last `welcome` was anonymous (auth `session`, not signed in yet):
|
|
68
|
+
* the server answers no table read, so none is sent. Collections and
|
|
69
|
+
* records wait, loading, until a signed-in `welcome`.
|
|
70
|
+
*/
|
|
71
|
+
protected anonymous: boolean;
|
|
72
|
+
/** Whether reads go out now: open, and signed in (or no sign-in needed). */
|
|
73
|
+
protected get readsOpen(): boolean;
|
|
64
74
|
protected setStatus(status: ConnectionStatus, error?: string): void;
|
|
65
75
|
protected send(frame: ClientFrame): void;
|
|
66
76
|
protected persistLater(cell: RecordCell): void;
|
package/dist/client-core.js
CHANGED
|
@@ -9,6 +9,12 @@ export class ClientCore {
|
|
|
9
9
|
/** @internal */ store = new Store();
|
|
10
10
|
connection = new State({ status: "connecting", halted: false });
|
|
11
11
|
principal = new State(null);
|
|
12
|
+
/**
|
|
13
|
+
* @internal Who the last `welcome` said this client is, stably
|
|
14
|
+
* (`welcome.principal_id`, else its name from an older server): what the
|
|
15
|
+
* outbox is stamped with, since display names may repeat.
|
|
16
|
+
*/
|
|
17
|
+
principalId = null;
|
|
12
18
|
seq = new State(0);
|
|
13
19
|
/**
|
|
14
20
|
* Resolves with the connection once `instant` tables have loaded for the
|
|
@@ -33,6 +39,8 @@ export class ClientCore {
|
|
|
33
39
|
loadWaiters = new Map();
|
|
34
40
|
flashQueue = [];
|
|
35
41
|
outbox = [];
|
|
42
|
+
/** The outbox came from storage (a reload): the first `welcome` checks whose it is. */
|
|
43
|
+
outboxRestored = false;
|
|
36
44
|
undoStack = [];
|
|
37
45
|
retry = 0;
|
|
38
46
|
/** Bumps on every `resync`: replies to frames sent before it are stale. */
|
|
@@ -68,6 +76,16 @@ export class ClientCore {
|
|
|
68
76
|
get online() {
|
|
69
77
|
return this.connection.peek().status === "open";
|
|
70
78
|
}
|
|
79
|
+
/**
|
|
80
|
+
* The last `welcome` was anonymous (auth `session`, not signed in yet):
|
|
81
|
+
* the server answers no table read, so none is sent. Collections and
|
|
82
|
+
* records wait, loading, until a signed-in `welcome`.
|
|
83
|
+
*/
|
|
84
|
+
anonymous = false;
|
|
85
|
+
/** Whether reads go out now: open, and signed in (or no sign-in needed). */
|
|
86
|
+
get readsOpen() {
|
|
87
|
+
return this.online && !this.anonymous;
|
|
88
|
+
}
|
|
71
89
|
/** @internal The server's final refusal (`refused`, `protocol_mismatch`), once there is one. */
|
|
72
90
|
get haltedWith() {
|
|
73
91
|
const { status, error, halted } = this.connection.peek();
|
package/dist/client-loading.js
CHANGED
|
@@ -37,8 +37,8 @@ export class ClientLoading extends ClientCore {
|
|
|
37
37
|
return Promise.all(waits).then(() => undefined);
|
|
38
38
|
}
|
|
39
39
|
async flushLoads() {
|
|
40
|
-
if (!this.
|
|
41
|
-
return; // retried
|
|
40
|
+
if (!this.readsOpen)
|
|
41
|
+
return; // retried at the next (signed-in) welcome
|
|
42
42
|
const queued = [...this.loadQueue];
|
|
43
43
|
this.loadQueue.clear();
|
|
44
44
|
await Promise.all(queued.map(async ([table, ids]) => {
|
|
@@ -79,7 +79,9 @@ export class ClientLoading extends ClientCore {
|
|
|
79
79
|
* replaces them. When the reload fails, the next read of each tries again.
|
|
80
80
|
*/
|
|
81
81
|
async reloadRecords(table, ids) {
|
|
82
|
-
const reply =
|
|
82
|
+
const reply = this.readsOpen
|
|
83
|
+
? await this.request({ t: "open_records", table, ids })
|
|
84
|
+
: { t: "error", id: 0, kind: "offline", message: "not signed in" };
|
|
83
85
|
if (reply.t !== "records") {
|
|
84
86
|
for (const id of ids) {
|
|
85
87
|
const cell = this.store.peek(table, id);
|
|
@@ -115,8 +117,8 @@ export class ClientLoading extends ClientCore {
|
|
|
115
117
|
* closed; so is the sub this open replaces.
|
|
116
118
|
*/
|
|
117
119
|
async openCollection(cell) {
|
|
118
|
-
if (!this.
|
|
119
|
-
return;
|
|
120
|
+
if (!this.readsOpen)
|
|
121
|
+
return; // opened at the next (signed-in) welcome
|
|
120
122
|
const { $window: _, ...spec } = cell.spec;
|
|
121
123
|
const generation = this.generation;
|
|
122
124
|
const reply = await this.request({ t: "open_table", ...spec });
|
package/dist/client-writes.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Writing: optimistic edits batched per microtask, deletes, undo, and edits
|
|
2
2
|
// queued while offline.
|
|
3
3
|
import { ClientLoading } from "./client-loading.js";
|
|
4
|
-
import { fail, failFrom, pick, sameValue, uuidv7 } from "./util.js";
|
|
4
|
+
import { errorOf, fail, failFrom, pick, sameValue, uuidv7 } from "./util.js";
|
|
5
5
|
export class ClientWrites extends ClientLoading {
|
|
6
6
|
/** Choices for a form field whose options come from a script. */
|
|
7
7
|
async options(action, field, form = {}) {
|
|
@@ -46,12 +46,12 @@ export class ClientWrites extends ClientLoading {
|
|
|
46
46
|
this.undoStack.shift();
|
|
47
47
|
return { ok: true, value: this.model(table, target.id) };
|
|
48
48
|
}
|
|
49
|
-
const
|
|
50
|
-
const
|
|
49
|
+
const error = frame.t === "rejected" || frame.t === "error" ? errorOf(frame) : { kind: "invalid", message: "write failed", fieldErrors: {} };
|
|
50
|
+
const { fieldErrors, message } = error;
|
|
51
51
|
cell.settle(keys, "failed", Object.keys(fieldErrors).length ? fieldErrors : { $: message });
|
|
52
52
|
if (!before)
|
|
53
53
|
cell.markRemoved();
|
|
54
|
-
return { ok: false, error
|
|
54
|
+
return { ok: false, error };
|
|
55
55
|
}
|
|
56
56
|
/** @internal */ async remove(table, id) {
|
|
57
57
|
const cell = this.store.cell(table, id);
|
|
@@ -87,7 +87,7 @@ export class ClientWrites extends ClientLoading {
|
|
|
87
87
|
if (!this.storage)
|
|
88
88
|
return resolve({ t: "error", id: 0, kind: "offline", message: "not connected" });
|
|
89
89
|
// Offline with a mirror: keep it and send on reconnect.
|
|
90
|
-
this.outbox.push(item);
|
|
90
|
+
this.outbox.push({ item, principal: this.principalId });
|
|
91
91
|
void this.storage.save({ outbox: this.outbox });
|
|
92
92
|
this.flashQueue.push({ item, resolve });
|
|
93
93
|
return;
|
package/dist/collection.js
CHANGED
|
@@ -91,16 +91,21 @@ export class Collection {
|
|
|
91
91
|
const loadState = cell.loadState.get();
|
|
92
92
|
const total = cell.total.get();
|
|
93
93
|
const error = cell.error.get();
|
|
94
|
-
const
|
|
94
|
+
const slots = Array.from({ length: count }, (_, i) => {
|
|
95
95
|
const at = start + i - cell.start;
|
|
96
96
|
const id = at >= 0 && at < ids.length ? ids[at] : undefined;
|
|
97
97
|
return id ? this.db.model(this.spec.table, id) : undefined;
|
|
98
98
|
});
|
|
99
|
-
const gap =
|
|
99
|
+
const gap = slots.indexOf(undefined);
|
|
100
100
|
const end = start + (gap === -1 ? count : gap); // rows arrived from `start` without a gap
|
|
101
101
|
const settled = cell.windowSettled(start, count);
|
|
102
102
|
// The last `rows`/`members` frame said `complete`: the server sent every row.
|
|
103
103
|
const totalExact = loadState === "complete" && total !== null;
|
|
104
|
+
const length = total ?? end;
|
|
105
|
+
const lengthExact = total !== null ? totalExact : settled && error === null && (loadState === "complete" || end < start + count);
|
|
106
|
+
// Once the real count is known, the window stops at the last row: slots past it would
|
|
107
|
+
// stay `undefined` for good, and a list rendering them shows rows that never load.
|
|
108
|
+
const items = lengthExact ? slots.slice(0, Math.max(0, length - start)) : slots;
|
|
104
109
|
return {
|
|
105
110
|
start,
|
|
106
111
|
count,
|
|
@@ -110,8 +115,8 @@ export class Collection {
|
|
|
110
115
|
total,
|
|
111
116
|
totalExact,
|
|
112
117
|
settled,
|
|
113
|
-
length
|
|
114
|
-
lengthExact
|
|
118
|
+
length,
|
|
119
|
+
lengthExact,
|
|
115
120
|
whenLoaded: () => cell.whenWindow(start, count).then(() => this.window(start, count)),
|
|
116
121
|
};
|
|
117
122
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -4,5 +4,5 @@ export { State, batch, autorun } from "./signal.js";
|
|
|
4
4
|
export type { Transport } from "./transport.js";
|
|
5
5
|
export { MemoryStorage, IndexedDBStorage, type Storage } from "./persist.js";
|
|
6
6
|
export type { ActionError, ActionResult, CollectionWindow, Connection, ConnectionStatus, FieldMeta, Hydrated, LazyCollection, LazyReference, LoadState, Model, Presence, RelationKeys, RowStatus, SchemaMeta, SchemaTypes, Servo, TableMeta, WritableFields, } from "./types.js";
|
|
7
|
-
export type { CollectionSpec, Where, Sort, WireRecord, Json, RunCode } from "./protocol.js";
|
|
7
|
+
export type { AccessRefusal, CollectionSpec, Where, Sort, WireRecord, Json, RunCode } from "./protocol.js";
|
|
8
8
|
export type { Run, RunError, RunFactory, RunMessageOf, RunStatus, RunTypes, StartOptions } from "./run/types.js";
|
package/dist/mock-store.d.ts
CHANGED
|
@@ -36,6 +36,16 @@ export interface Fixtures {
|
|
|
36
36
|
eager?: boolean;
|
|
37
37
|
/** The features `welcome` offers (default: all this client knows); `[]` is a server without runs. */
|
|
38
38
|
features?: string[];
|
|
39
|
+
/** The principal `welcome` names for each `hello` token (default, and for any other token: `test`). */
|
|
40
|
+
principals?: Record<string, string>;
|
|
41
|
+
/** The stable `principal_id` `welcome` gives for each token (default: the principal's name). */
|
|
42
|
+
principalIds?: Record<string, string>;
|
|
43
|
+
/**
|
|
44
|
+
* Like vantage-server under auth `session`: a `hello` without a token is
|
|
45
|
+
* welcomed as `anonymous`, and its table reads and writes are refused
|
|
46
|
+
* with "sign in first".
|
|
47
|
+
*/
|
|
48
|
+
signIn?: boolean;
|
|
39
49
|
}
|
|
40
50
|
export declare class MockStore {
|
|
41
51
|
readonly schema: SchemaMeta;
|
package/dist/persist.d.ts
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
import type { Cursor, FlashItem, WireRecord } from "./protocol.js";
|
|
2
|
+
/** One edit waiting to go out, and who was signed in when it was made. */
|
|
3
|
+
export interface OutboxEntry {
|
|
4
|
+
item: FlashItem;
|
|
5
|
+
/** The `welcome` principal at the time; `null` before any `welcome`. */
|
|
6
|
+
principal: string | null;
|
|
7
|
+
}
|
|
2
8
|
export interface Persisted {
|
|
3
9
|
records: {
|
|
4
10
|
table: string;
|
|
5
11
|
record: WireRecord;
|
|
6
12
|
}[];
|
|
7
13
|
cursor: Cursor | null;
|
|
8
|
-
outbox:
|
|
14
|
+
outbox: OutboxEntry[];
|
|
9
15
|
}
|
|
10
16
|
export interface PersistChange {
|
|
11
17
|
/** `record: null` removes it. */
|
|
@@ -15,7 +21,7 @@ export interface PersistChange {
|
|
|
15
21
|
record: WireRecord | null;
|
|
16
22
|
}[];
|
|
17
23
|
cursor?: Cursor;
|
|
18
|
-
outbox?:
|
|
24
|
+
outbox?: OutboxEntry[];
|
|
19
25
|
}
|
|
20
26
|
export interface Storage {
|
|
21
27
|
load(): Promise<Persisted>;
|
package/dist/persist.js
CHANGED
|
@@ -4,8 +4,23 @@
|
|
|
4
4
|
// next start the app paints from the mirror (records marked `stale`)
|
|
5
5
|
// before the socket connects. Edits made while offline wait in memory and
|
|
6
6
|
// go out in order on reconnect, within the same page. They are also saved
|
|
7
|
-
// to the outbox,
|
|
8
|
-
// still waiting when the page reloads
|
|
7
|
+
// to the outbox, each with the principal that made it, but nothing sends a
|
|
8
|
+
// saved outbox after a reload: edits still waiting when the page reloads
|
|
9
|
+
// are lost. On the first `welcome` after a reload, saved edits another
|
|
10
|
+
// principal made are dropped from it (with a warning), so a later sign-in
|
|
11
|
+
// can never send them.
|
|
12
|
+
/**
|
|
13
|
+
* A saved outbox entry, or an edit an older client saved bare: whose it was
|
|
14
|
+
* is unknown, so it reads as nobody's (`null`) and is dropped at the next
|
|
15
|
+
* `welcome`.
|
|
16
|
+
*/
|
|
17
|
+
function asEntry(saved) {
|
|
18
|
+
const entry = saved;
|
|
19
|
+
if (entry && typeof entry === "object" && "item" in entry && entry.item) {
|
|
20
|
+
return { item: entry.item, principal: entry.principal ?? null };
|
|
21
|
+
}
|
|
22
|
+
return { item: saved, principal: null };
|
|
23
|
+
}
|
|
9
24
|
/** Keeps everything in memory (tests; SSR). */
|
|
10
25
|
export class MemoryStorage {
|
|
11
26
|
records = new Map();
|
|
@@ -66,7 +81,7 @@ export class IndexedDBStorage {
|
|
|
66
81
|
req(tx.objectStore("meta").get("cursor")),
|
|
67
82
|
req(tx.objectStore("meta").get("outbox")),
|
|
68
83
|
]);
|
|
69
|
-
return { records, cursor: cursor ?? null, outbox: outbox ?? [] };
|
|
84
|
+
return { records, cursor: cursor ?? null, outbox: (outbox ?? []).map(asEntry) };
|
|
70
85
|
}
|
|
71
86
|
async save(change) {
|
|
72
87
|
const db = await this.open();
|
package/dist/protocol.d.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
/** The wire protocol this client speaks; sent in `hello`. */
|
|
2
2
|
export declare const PROTOCOL = 1;
|
|
3
|
-
/**
|
|
3
|
+
/**
|
|
4
|
+
* What this client understands beyond the protocol; sent in `hello`.
|
|
5
|
+
* `runs:1`: actions run as runs. `session:1`: an anonymous `hello` under
|
|
6
|
+
* auth `session`, and a 1008 `signed out` close when the session ends.
|
|
7
|
+
*/
|
|
4
8
|
export declare const FEATURES: string[];
|
|
5
9
|
export type Json = null | boolean | number | string | Json[] | {
|
|
6
10
|
[key: string]: Json;
|
|
@@ -42,10 +46,29 @@ export type ChangeEvent = {
|
|
|
42
46
|
table: string;
|
|
43
47
|
};
|
|
44
48
|
export type ErrorKind = "invalid" | "not_found" | "forbidden" | "unavailable" | "conflict" | "failed" | "offline";
|
|
45
|
-
|
|
49
|
+
/**
|
|
50
|
+
* Which access rule refused a request, sent with `code: "forbidden"` on a
|
|
51
|
+
* `rejected`, an `error` and a failed run's `error`: the table's catalog
|
|
52
|
+
* key, the rule (`<datasource>.invariants.<column>`, `.script` for a rule
|
|
53
|
+
* script's) and the rule's own words.
|
|
54
|
+
*/
|
|
55
|
+
export type AccessRefusal = {
|
|
56
|
+
table: string;
|
|
57
|
+
rule: string;
|
|
58
|
+
reason: string;
|
|
59
|
+
};
|
|
60
|
+
export type ServerFrame =
|
|
61
|
+
/**
|
|
62
|
+
* `principal` is the display name; `principal_id` tells people apart (an
|
|
63
|
+
* older server sends none); `anonymous` is a socket not signed in yet,
|
|
64
|
+
* which reads no table.
|
|
65
|
+
*/
|
|
66
|
+
{
|
|
46
67
|
t: "welcome";
|
|
47
68
|
id: number;
|
|
48
69
|
principal: string;
|
|
70
|
+
principal_id?: string;
|
|
71
|
+
anonymous?: boolean;
|
|
49
72
|
epoch: string;
|
|
50
73
|
seq: number;
|
|
51
74
|
schema_hash: string;
|
|
@@ -95,12 +118,7 @@ export type ServerFrame = {
|
|
|
95
118
|
rid: string;
|
|
96
119
|
seq: number;
|
|
97
120
|
record: WireRecord | null;
|
|
98
|
-
} | {
|
|
99
|
-
t: "rejected";
|
|
100
|
-
id: number;
|
|
101
|
-
message: string;
|
|
102
|
-
field_errors?: Record<string, string>;
|
|
103
|
-
} | {
|
|
121
|
+
} | RejectedFrame | {
|
|
104
122
|
t: "batch_result";
|
|
105
123
|
id: number;
|
|
106
124
|
results: ({
|
|
@@ -109,12 +127,7 @@ export type ServerFrame = {
|
|
|
109
127
|
rid: string;
|
|
110
128
|
seq: number;
|
|
111
129
|
record: WireRecord | null;
|
|
112
|
-
} |
|
|
113
|
-
t: "rejected";
|
|
114
|
-
id: number;
|
|
115
|
-
message: string;
|
|
116
|
-
field_errors?: Record<string, string>;
|
|
117
|
-
})[];
|
|
130
|
+
} | RejectedFrame)[];
|
|
118
131
|
}
|
|
119
132
|
/** The answer to `invoke`; the run's frames follow. */
|
|
120
133
|
| {
|
|
@@ -130,14 +143,17 @@ export type ServerFrame = {
|
|
|
130
143
|
label: string;
|
|
131
144
|
}[];
|
|
132
145
|
}
|
|
133
|
-
/**
|
|
134
|
-
|
|
146
|
+
/**
|
|
147
|
+
* `code` names errors a client acts on: `protocol`, `unknown_frame`,
|
|
148
|
+
* `forbidden` (with the {@link AccessRefusal}) and a run's {@link RunCode}.
|
|
149
|
+
*/
|
|
150
|
+
| ({
|
|
135
151
|
t: "error";
|
|
136
152
|
id: number;
|
|
137
153
|
kind: ErrorKind;
|
|
138
154
|
message: string;
|
|
139
155
|
code?: string;
|
|
140
|
-
} | {
|
|
156
|
+
} & Partial<AccessRefusal>) | {
|
|
141
157
|
t: "pong";
|
|
142
158
|
id: number;
|
|
143
159
|
}
|
|
@@ -167,11 +183,24 @@ export interface RunFrame {
|
|
|
167
183
|
seq?: number;
|
|
168
184
|
}[];
|
|
169
185
|
result?: Json;
|
|
186
|
+
/** A body an access rule stopped fails with code `forbidden` and the {@link AccessRefusal}. */
|
|
170
187
|
error?: {
|
|
171
188
|
code: RunCode;
|
|
172
189
|
message: string;
|
|
173
|
-
}
|
|
190
|
+
} & Partial<AccessRefusal>;
|
|
174
191
|
}
|
|
192
|
+
/** A write that didn't happen; `code: "forbidden"` when an access rule refused it. */
|
|
193
|
+
export type RejectedFrame = {
|
|
194
|
+
t: "rejected";
|
|
195
|
+
id: number;
|
|
196
|
+
message: string;
|
|
197
|
+
field_errors?: Record<string, string>;
|
|
198
|
+
code?: "forbidden";
|
|
199
|
+
} & Partial<AccessRefusal>;
|
|
200
|
+
/** The access rule's refusal a frame carries, if any. */
|
|
201
|
+
export declare function refusalOf(frame: {
|
|
202
|
+
code?: string;
|
|
203
|
+
} & Partial<AccessRefusal>): AccessRefusal | undefined;
|
|
175
204
|
export type FlashItem = {
|
|
176
205
|
table: string;
|
|
177
206
|
rid?: string;
|
package/dist/protocol.js
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
// Wire frames (mirror of vantage-server `src/protocol.rs`).
|
|
2
2
|
/** The wire protocol this client speaks; sent in `hello`. */
|
|
3
3
|
export const PROTOCOL = 1;
|
|
4
|
-
/**
|
|
5
|
-
|
|
4
|
+
/**
|
|
5
|
+
* What this client understands beyond the protocol; sent in `hello`.
|
|
6
|
+
* `runs:1`: actions run as runs. `session:1`: an anonymous `hello` under
|
|
7
|
+
* auth `session`, and a 1008 `signed out` close when the session ends.
|
|
8
|
+
*/
|
|
9
|
+
export const FEATURES = ["runs:1", "session:1"];
|
|
10
|
+
/** The access rule's refusal a frame carries, if any. */
|
|
11
|
+
export function refusalOf(frame) {
|
|
12
|
+
if (frame.code !== "forbidden" || frame.table === undefined || frame.rule === undefined || frame.reason === undefined)
|
|
13
|
+
return undefined;
|
|
14
|
+
return { table: frame.table, rule: frame.rule, reason: frame.reason };
|
|
15
|
+
}
|
package/dist/run/run.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type Json, type ServerFrame } from "../protocol.js";
|
|
2
2
|
import type { RunMeta } from "../schema-meta.js";
|
|
3
3
|
import type { ActionResult } from "../types.js";
|
|
4
4
|
import type { Run, RunError, RunFactory, RunStatus, RunTypes, StartOptions } from "./types.js";
|
package/dist/run/run.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// answer is sent again with the same id and args after the welcome (the
|
|
6
6
|
// server attaches instead of running twice), and a running run attaches
|
|
7
7
|
// again for a fresh snapshot.
|
|
8
|
+
import { refusalOf } from "../protocol.js";
|
|
8
9
|
import { State, batch } from "../signal.js";
|
|
9
10
|
import { fail, uuidv7 } from "../util.js";
|
|
10
11
|
import { classify, isFinal } from "./frames.js";
|
|
@@ -201,10 +202,10 @@ export class RunImpl {
|
|
|
201
202
|
return;
|
|
202
203
|
this.reattaching = false;
|
|
203
204
|
const code = reply.code ?? "failed";
|
|
204
|
-
this.finishWith("failed", undefined,
|
|
205
|
+
this.finishWith("failed", undefined, runError(code, reply.message, refusalOf(reply)));
|
|
205
206
|
}
|
|
206
207
|
finish(frame) {
|
|
207
|
-
const error = frame.error ?
|
|
208
|
+
const error = frame.error ? runError(frame.error.code, frame.error.message, refusalOf(frame.error)) : null;
|
|
208
209
|
this.finishWith(frame.status, frame.result, error);
|
|
209
210
|
}
|
|
210
211
|
finishWith(status, result, error) {
|
|
@@ -234,6 +235,9 @@ export class RunImpl {
|
|
|
234
235
|
return this.lastSeq >= 0 && message.seq !== undefined && message.seq > this.lastSeq;
|
|
235
236
|
}
|
|
236
237
|
}
|
|
238
|
+
function runError(code, message, forbidden) {
|
|
239
|
+
return forbidden ? { code, message, forbidden } : { code, message };
|
|
240
|
+
}
|
|
237
241
|
/** The factory for `action`: `start`, `attach`, `idle`. */
|
|
238
242
|
export function runFactory(host, action, rid, meta) {
|
|
239
243
|
const make = () => new RunImpl(host, action, rid, meta);
|
package/dist/run/types.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Json, RunCode } from "../protocol.js";
|
|
1
|
+
import type { AccessRefusal, Json, RunCode } from "../protocol.js";
|
|
2
2
|
import type { ActionResult } from "../types.js";
|
|
3
3
|
/**
|
|
4
4
|
* One action's run types, as `generate` emits them: what it takes (`args`),
|
|
@@ -16,10 +16,15 @@ export interface RunTypes {
|
|
|
16
16
|
* server's `running`, `done`, `failed` or `cancelled`.
|
|
17
17
|
*/
|
|
18
18
|
export type RunStatus = "idle" | "starting" | "running" | "done" | "failed" | "cancelled";
|
|
19
|
-
/**
|
|
19
|
+
/**
|
|
20
|
+
* Why a run was refused or failed. `offline`: the client wasn't connected.
|
|
21
|
+
* `forbidden` is set when an access rule refused it: the table, the rule
|
|
22
|
+
* and its reason.
|
|
23
|
+
*/
|
|
20
24
|
export interface RunError {
|
|
21
25
|
readonly code: RunCode | "offline";
|
|
22
26
|
readonly message: string;
|
|
27
|
+
readonly forbidden?: AccessRefusal;
|
|
23
28
|
}
|
|
24
29
|
/** The messages the framework sends for any action: a body's `notify(text)` and `log(text)`. */
|
|
25
30
|
export interface FrameworkMessages {
|
package/dist/testing.d.ts
CHANGED
|
@@ -22,6 +22,8 @@ export declare class MockServer extends MockStore {
|
|
|
22
22
|
private held;
|
|
23
23
|
private refusals;
|
|
24
24
|
private writeRefusal?;
|
|
25
|
+
/** Sockets whose `hello` had no token, under fixture `signIn`. */
|
|
26
|
+
private anonymous;
|
|
25
27
|
/** A server with one table, its schema made from the rows' keys. */
|
|
26
28
|
static withTable(table: string, rows: Row[], fixtures?: Omit<Fixtures, "tables">): MockServer;
|
|
27
29
|
transport(): MockTransport;
|
package/dist/testing.js
CHANGED
|
@@ -32,6 +32,8 @@ import { FEATURES, PROTOCOL } from "./protocol.js";
|
|
|
32
32
|
export { MockTransport };
|
|
33
33
|
/** The frames a write travels in: one edit, several batched in a tick, a delete. */
|
|
34
34
|
const WRITES = new Set(["flash", "batch", "delete"]);
|
|
35
|
+
/** What an anonymous socket may not send (fixture `signIn`). */
|
|
36
|
+
const READS_AND_WRITES = new Set(["open_table", "open_records", "viewport", "options", ...WRITES]);
|
|
35
37
|
export class MockServer extends MockStore {
|
|
36
38
|
/** Every `hello` received, in order. */
|
|
37
39
|
hellos = [];
|
|
@@ -42,6 +44,8 @@ export class MockServer extends MockStore {
|
|
|
42
44
|
held = new Map();
|
|
43
45
|
refusals = new Map();
|
|
44
46
|
writeRefusal;
|
|
47
|
+
/** Sockets whose `hello` had no token, under fixture `signIn`. */
|
|
48
|
+
anonymous = new Set();
|
|
45
49
|
/** A server with one table, its schema made from the rows' keys. */
|
|
46
50
|
static withTable(table, rows, fixtures = {}) {
|
|
47
51
|
return new MockServer(tableSchema(table, rows), { ...fixtures, tables: { [table]: rows } });
|
|
@@ -102,6 +106,11 @@ export class MockServer extends MockStore {
|
|
|
102
106
|
const refusal = this.refusals.get(frame.t) ?? (WRITES.has(frame.t) ? this.writeRefusal : undefined);
|
|
103
107
|
if (refusal !== undefined)
|
|
104
108
|
return client.deliver({ t: "error", id: frame.id ?? 0, kind: "invalid", message: refusal });
|
|
109
|
+
// Like vantage-server under auth `session`: an anonymous socket reads
|
|
110
|
+
// and writes no table.
|
|
111
|
+
if (this.anonymous.has(client) && READS_AND_WRITES.has(frame.t)) {
|
|
112
|
+
return client.deliver({ t: "error", id: frame.id ?? 0, kind: "forbidden", message: "sign in first" });
|
|
113
|
+
}
|
|
105
114
|
const held = this.held.get(frame.t);
|
|
106
115
|
if (held)
|
|
107
116
|
return void held.push([client, frame]);
|
|
@@ -121,10 +130,35 @@ export class MockServer extends MockStore {
|
|
|
121
130
|
}
|
|
122
131
|
if (this.fixtures.refuseHello)
|
|
123
132
|
return reply({ t: "error", id, kind: "forbidden", message: this.fixtures.refuseHello });
|
|
133
|
+
{
|
|
134
|
+
const anonymous = this.fixtures.signIn === true && !frame.token;
|
|
135
|
+
if (anonymous)
|
|
136
|
+
this.anonymous.add(client);
|
|
137
|
+
else
|
|
138
|
+
this.anonymous.delete(client);
|
|
139
|
+
if (anonymous) {
|
|
140
|
+
return reply({
|
|
141
|
+
t: "welcome",
|
|
142
|
+
id,
|
|
143
|
+
principal: "anonymous",
|
|
144
|
+
principal_id: "anonymous:mock",
|
|
145
|
+
anonymous: true,
|
|
146
|
+
epoch: `mock-${this.epoch}`,
|
|
147
|
+
seq: this.seq,
|
|
148
|
+
schema_hash: this.schema.hash,
|
|
149
|
+
resync: true,
|
|
150
|
+
protocol: this.fixtures.protocol ?? PROTOCOL,
|
|
151
|
+
features: (this.fixtures.features ?? FEATURES).filter((f) => !frame.features || frame.features.includes(f)),
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
}
|
|
124
155
|
return reply({
|
|
125
156
|
t: "welcome",
|
|
126
157
|
id,
|
|
127
|
-
principal: "test",
|
|
158
|
+
principal: (frame.token !== undefined && this.fixtures.principals?.[frame.token]) || "test",
|
|
159
|
+
principal_id: (frame.token !== undefined && this.fixtures.principalIds?.[frame.token]) ||
|
|
160
|
+
(frame.token !== undefined && this.fixtures.principals?.[frame.token]) ||
|
|
161
|
+
"test",
|
|
128
162
|
epoch: `mock-${this.epoch}`,
|
|
129
163
|
seq: this.seq,
|
|
130
164
|
schema_hash: this.schema.hash,
|
package/dist/types.d.ts
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
|
-
import type { ErrorKind, Sort, Where } from "./protocol.js";
|
|
1
|
+
import type { AccessRefusal, ErrorKind, Sort, Where } from "./protocol.js";
|
|
2
2
|
import type { Presence, RowStatus } from "./store.js";
|
|
3
3
|
export type { RowStatus, Presence };
|
|
4
4
|
export type { ActionMeta, FieldMeta, RunMeta, SchemaMeta, SchemaTypes, TableMeta } from "./schema-meta.js";
|
|
5
|
+
/**
|
|
6
|
+
* Why a write or run failed. `forbidden` is set when an access rule refused
|
|
7
|
+
* it (`kind` is then `forbidden`): the table, the rule and its reason.
|
|
8
|
+
*/
|
|
5
9
|
export type ActionError = {
|
|
6
10
|
kind: ErrorKind;
|
|
7
11
|
message: string;
|
|
8
12
|
fieldErrors: Record<string, string>;
|
|
13
|
+
forbidden?: AccessRefusal;
|
|
9
14
|
};
|
|
10
15
|
export type ActionResult<R> = {
|
|
11
16
|
ok: true;
|
package/dist/util.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type ErrorKind, type ServerFrame } from "./protocol.js";
|
|
2
2
|
import type { ActionError, TableMeta } from "./types.js";
|
|
3
3
|
/** Relation names → their columns (`bakery` → `bakery_id`). */
|
|
4
4
|
export declare function toColumns(meta: TableMeta, fields: Record<string, unknown>): Record<string, unknown>;
|
|
@@ -10,6 +10,10 @@ export declare function failFrom(frame: ServerFrame): {
|
|
|
10
10
|
ok: false;
|
|
11
11
|
error: ActionError;
|
|
12
12
|
};
|
|
13
|
+
/** A `rejected` or `error` as an {@link ActionError}, with the access rule's refusal when one caused it. */
|
|
14
|
+
export declare function errorOf(frame: ServerFrame & {
|
|
15
|
+
t: "error" | "rejected";
|
|
16
|
+
}): ActionError;
|
|
13
17
|
export declare function pick(record: Record<string, unknown>, keys: string[]): Record<string, unknown>;
|
|
14
18
|
export declare function sameValue(a: unknown, b: unknown): boolean;
|
|
15
19
|
/** A type name from a table name: `order_line` → `OrderLine`. Separators are dropped. */
|
package/dist/util.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// Small helpers shared by the client's parts.
|
|
2
|
+
import { refusalOf } from "./protocol.js";
|
|
2
3
|
/** Relation names → their columns (`bakery` → `bakery_id`). */
|
|
3
4
|
export function toColumns(meta, fields) {
|
|
4
5
|
const out = {};
|
|
@@ -10,12 +11,17 @@ export function fail(kind, message) {
|
|
|
10
11
|
return { ok: false, error: { kind, message, fieldErrors: {} } };
|
|
11
12
|
}
|
|
12
13
|
export function failFrom(frame) {
|
|
13
|
-
if (frame.t === "error")
|
|
14
|
-
return
|
|
15
|
-
if (frame.t === "rejected")
|
|
16
|
-
return { ok: false, error: { kind: "invalid", message: frame.message, fieldErrors: frame.field_errors ?? {} } };
|
|
14
|
+
if (frame.t === "error" || frame.t === "rejected")
|
|
15
|
+
return { ok: false, error: errorOf(frame) };
|
|
17
16
|
return fail("failed", `unexpected reply \`${frame.t}\``);
|
|
18
17
|
}
|
|
18
|
+
/** A `rejected` or `error` as an {@link ActionError}, with the access rule's refusal when one caused it. */
|
|
19
|
+
export function errorOf(frame) {
|
|
20
|
+
const forbidden = refusalOf(frame);
|
|
21
|
+
const kind = forbidden ? "forbidden" : frame.t === "error" ? frame.kind : "invalid";
|
|
22
|
+
const fieldErrors = frame.t === "rejected" ? (frame.field_errors ?? {}) : {};
|
|
23
|
+
return forbidden ? { kind, message: frame.message, fieldErrors, forbidden } : { kind, message: frame.message, fieldErrors };
|
|
24
|
+
}
|
|
19
25
|
export function pick(record, keys) {
|
|
20
26
|
return Object.fromEntries(keys.filter((k) => k in record).map((k) => [k, record[k]]));
|
|
21
27
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vantage-ui/client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "A vantage-ui project as a typed, live object graph: lazy references and collections, optimistic edits, server-side actions, React bindings.",
|
|
5
5
|
"keywords": ["vantage", "vantage-ui", "websocket", "realtime", "react", "codegen"],
|
|
6
6
|
"homepage": "https://vantage-ui.com",
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: vantage-client
|
|
3
3
|
description: Use when writing a web frontend (React or plain TypeScript) against a Vantage API served by vantage-server — connecting with @vantage-ui/client, generating typed models, reading collections, windows and records, writing, running actions as runs with shared state and messages, handling refusals and resync, and testing against MockServer.
|
|
4
4
|
metadata:
|
|
5
|
-
version: "2.
|
|
5
|
+
version: "2.3.0"
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# @vantage-ui/client
|
|
@@ -78,8 +78,8 @@ const db = createVantage<Schema>({ schema, token: () => sessionStorage.getItem("
|
|
|
78
78
|
```
|
|
79
79
|
|
|
80
80
|
`token` is sent in `hello` on every connect; a function is called each
|
|
81
|
-
time, so a new sign-in is picked up on the next connect
|
|
82
|
-
|
|
81
|
+
time, so a new sign-in is picked up on the next connect, and
|
|
82
|
+
`db.reconnect()` connects again at once (see Connection states).
|
|
83
83
|
Outside a browser (scripts, tests, Node), pass the URL:
|
|
84
84
|
|
|
85
85
|
```ts
|
|
@@ -131,7 +131,8 @@ const initialSort = issues.defaultSort; // { column, direction }, or null
|
|
|
131
131
|
|
|
132
132
|
For long lists, read one window at a time. Rows outside the window are not
|
|
133
133
|
sent. `window()` returns what has arrived so far (a row is `undefined` until
|
|
134
|
-
it loads
|
|
134
|
+
it loads, and once `lengthExact` is true `items` stops at the last row, so a
|
|
135
|
+
100-row window over 22 rows holds 22); `whenLoaded()` waits for the window's rows. In React, `useWindow`
|
|
135
136
|
re-renders as they arrive instead.
|
|
136
137
|
|
|
137
138
|
```ts
|
|
@@ -310,7 +311,7 @@ true in the two final states:
|
|
|
310
311
|
| `connecting` | Before the first `welcome` |
|
|
311
312
|
| `open` | Connected; reads and writes go out |
|
|
312
313
|
| `closed` | Lost; the client reconnects with backoff and resumes |
|
|
313
|
-
| `refused` | The server refused the token (at `hello`,
|
|
314
|
+
| `refused` | The server refused the token (at `hello`, after an API reload, or `signed out` when the session ended). Final |
|
|
314
315
|
| `protocol_mismatch` | The server speaks another protocol version. Final |
|
|
315
316
|
|
|
316
317
|
After `refused` or `protocol_mismatch` the client stops reconnecting, and
|
|
@@ -331,11 +332,12 @@ const stop = autorun(() => {
|
|
|
331
332
|
In React, `useConnection()` gives the same; `references/react.md` shows a
|
|
332
333
|
provider fallback built on it.
|
|
333
334
|
|
|
334
|
-
`refused` stays final until you call `db.reconnect()`. It
|
|
335
|
-
calls `token` afresh, and
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
335
|
+
`refused` stays final until you call `db.reconnect()`. It works from any
|
|
336
|
+
state: it closes an open socket, connects again, calls `token` afresh, and
|
|
337
|
+
gives the collections and records a refusal failed another try. It
|
|
338
|
+
resolves like `db.ready`: with the connection once it is open, or refused.
|
|
339
|
+
A sign-in form stores the new token where the `token` function reads it
|
|
340
|
+
and calls `reconnect()`, with no page reload:
|
|
339
341
|
|
|
340
342
|
```ts
|
|
341
343
|
// test: connection#reconnect
|
|
@@ -345,6 +347,23 @@ const connection = await db.reconnect();
|
|
|
345
347
|
if (connection.halted) showBanner(`Sign-in refused: ${connection.error}`);
|
|
346
348
|
```
|
|
347
349
|
|
|
350
|
+
When the API signs people in itself (auth `session`), the page connects
|
|
351
|
+
with no token first: the `welcome` names `anonymous`, and the only thing it
|
|
352
|
+
may do is run actions. Which ones is the API's choice: the profile's own
|
|
353
|
+
`auth: { <profile>: { session: { sign_in: [..] } } }` list, else
|
|
354
|
+
`application.yaml` `session.signed_out`; anything else answers `forbidden`
|
|
355
|
+
"sign in first". Signing in is one: run the project's sign-in action
|
|
356
|
+
(`db.run.sign_in.start({ email, password })`), wait for `run.done`, store
|
|
357
|
+
the token its result carries where `token` reads it, and call
|
|
358
|
+
`db.reconnect()`. From then on every socket of that session shares it. A
|
|
359
|
+
sign-out action, an expired session, or one deleted on the server closes
|
|
360
|
+
the socket with 1008 `signed out`, which reads as `refused` with that
|
|
361
|
+
error: clear the stored token and `reconnect()` to continue anonymously.
|
|
362
|
+
|
|
363
|
+
This sign-in form is the only one such a project needs. Its desktop app never
|
|
364
|
+
signs in (vantage-ui's
|
|
365
|
+
`vantage-application-settings/references/sessions-and-audit.md`).
|
|
366
|
+
|
|
348
367
|
## Refusals
|
|
349
368
|
|
|
350
369
|
The server refuses what the API doesn't allow (an unknown column, a filter
|
|
@@ -356,6 +375,15 @@ throws and is never retried on its own:
|
|
|
356
375
|
|
|
357
376
|
Change the query (a new `where` is a new collection) to try again.
|
|
358
377
|
|
|
378
|
+
A refusal by the project's access rules (a datasource `invariants:` rule or
|
|
379
|
+
its `script:` hook, say "editors change only their environments") comes
|
|
380
|
+
with what refused: the write's `error.kind` is `forbidden` and
|
|
381
|
+
`error.forbidden` is `{ table, rule, reason }`, and so is a failed run's
|
|
382
|
+
`run.error.forbidden` (its `code` is `forbidden`). `reason` is the rule's
|
|
383
|
+
own words, fit to show; `rule` names it (`flags.invariants.environment.script`).
|
|
384
|
+
Other refusals have no `forbidden`. The API runs as its own app (the
|
|
385
|
+
`api/<key>.yaml` key), which no client can change.
|
|
386
|
+
|
|
359
387
|
## Resync
|
|
360
388
|
|
|
361
389
|
When the API's YAML changes, vantage-server reloads and sends `resync`. The
|
|
@@ -9,13 +9,14 @@ scripts and debugging. HTTP: `/v1/schema` (the schema `generate` reads),
|
|
|
9
9
|
|
|
10
10
|
| Client → server | Server → client |
|
|
11
11
|
|---|---|
|
|
12
|
-
| `hello {protocol: 1, token?, cursor?, schema_hash?, features?}` | `welcome {protocol, principal, epoch, seq, schema_hash, resync, features}`: the features both sides have (`runs:1`) |
|
|
12
|
+
| `hello {protocol: 1, token?, cursor?, schema_hash?, features?}` | `welcome {protocol, principal, principal_id, anonymous, epoch, seq, schema_hash, resync, features}`: `principal` is the name to show, `principal_id` the stable id that tells people apart (`identity:<id>`, `anonymous:<socket>`, `token:<name>`), `anonymous` true for a socket not signed in (the client then opens no table, `instant` ones included, until a signed-in `welcome`); the features both sides have (`runs:1`, `session:1`). Under auth `session` a `hello` without a token is welcomed as `anonymous`, which may only `invoke` the actions the project lists in `session.signed_out` (sign in), `run_set`, `run_cancel` and `ping`; anything else gets `error {kind: "forbidden", message: "sign in first"}` |
|
|
13
13
|
| a frame with a `t` the server doesn't know | `error {code: "unknown_frame"}`; a client ignores frames and fields it doesn't know |
|
|
14
14
|
| | `error {kind, message, code: "protocol"}`, then close 1002: another protocol version |
|
|
15
15
|
| | `error {kind: "forbidden", message}`: the token is refused; the socket stays open for another `hello` |
|
|
16
16
|
| | `resync {epoch, seq, schema_hash}`: the API was reloaded; reopen what you show, and re-attach the runs you follow (`invoke {run}`): an `invoke` answered around the reload may not stream its run |
|
|
17
17
|
| | `reload`: the served frontend was rebuilt; reload the page (sent before `welcome` too) |
|
|
18
|
-
| | close 1008: after a reload, the API refuses the token |
|
|
18
|
+
| | close 1008: after a reload, the API refuses the token; or `signed out`: the session ended (signed out, expired, deleted, its person deactivated) |
|
|
19
|
+
| | `resync` also follows a change to the session's own fields (impersonation): the same `hello` holds, collections and interest are gone |
|
|
19
20
|
| | close 1012 `reconnect`: the server is shutting down |
|
|
20
21
|
|
|
21
22
|
Nothing but `hello` is accepted before a `welcome`.
|
|
@@ -38,7 +39,7 @@ has-many). A refused request gets `error {id, kind, message}`.
|
|
|
38
39
|
|
|
39
40
|
| Client → server | Server → client |
|
|
40
41
|
|---|---|
|
|
41
|
-
| `flash {table, rid?, patch}` · `batch {items}` · `delete {table, rid}` | `flashed {rid, seq, record}` · `rejected {message, field_errors}` · `batch_result {results}` |
|
|
42
|
+
| `flash {table, rid?, patch}` · `batch {items}` · `delete {table, rid}` | `flashed {rid, seq, record}` · `rejected {message, field_errors, code?, table?, rule?, reason?}` · `batch_result {results}` |
|
|
42
43
|
| `invoke {run, action?, rid?, args}` (no `action`: attach) | `result {run, status: started \| attached \| done}`, then `run {run, seq, status?, state?, messages?, result?, error?}`, the first a snapshot |
|
|
43
44
|
| `run_set {run, name, value}` · `run_cancel {run}` | `ack` · `error {kind, message, code}` |
|
|
44
45
|
| `options {action, field, form?}` | `options_result {items}` |
|
|
@@ -51,6 +52,15 @@ missed. A refused `invoke` carries `code`:
|
|
|
51
52
|
`conflict` (the id was used with other args), `expired`, `forbidden` (no
|
|
52
53
|
such run for you, or an action the API can't run), `invalid` or `limit`.
|
|
53
54
|
|
|
55
|
+
A request an access rule refused carries `code: "forbidden"` and the
|
|
56
|
+
refusal beside `message`: `table` (the table's catalog key), `rule`
|
|
57
|
+
(`<datasource>.invariants.<column>`, `.script` for a rule script's,
|
|
58
|
+
`session.table`, `session.identity`) and `reason`, the rule's own words.
|
|
59
|
+
This holds for `rejected`, for `error` (an `open_table`, `open_records`,
|
|
60
|
+
`options` or `invoke` the rule refused) and for a run's `error` when a rule
|
|
61
|
+
stopped its body. Signed out, an `invoke` runs only the actions in the
|
|
62
|
+
API profile's `sign_in:` list, or without one `session.signed_out`.
|
|
63
|
+
|
|
54
64
|
## A raw script
|
|
55
65
|
|
|
56
66
|
```ts
|
|
@@ -70,7 +70,10 @@ For virtualised lists, `useWindow(collection, start, count)` gives the rows
|
|
|
70
70
|
`start..start+count`. Moving `start` moves the server's window; rows stay
|
|
71
71
|
on screen until the new ones arrive. Size the virtualiser from
|
|
72
72
|
`page.length`, show it as a count only when `page.lengthExact`, and use
|
|
73
|
-
`page.settled` to tell "still loading" from "no rows here".
|
|
73
|
+
`page.settled` to tell "still loading" from "no rows here". Once the count is
|
|
74
|
+
exact, `page.items` ends at the last row, so rendering `items` never shows
|
|
75
|
+
placeholders for rows that don't exist. A list that grows its window as the
|
|
76
|
+
user scrolls asks for more only while `!page.lengthExact`.
|
|
74
77
|
|
|
75
78
|
```tsx
|
|
76
79
|
// test: react#window
|