@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 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 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
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 again. Only `refused` reconnects; in any
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 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
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 again. Only `refused` reconnects; in any
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
- 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();
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 === event.table && c.sub !== null)
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
  }
@@ -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: FlashItem[];
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;
@@ -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();
@@ -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.online)
41
- return; // retried on reconnect
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 = await this.request({ t: "open_records", table, ids });
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.online)
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 });
@@ -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 fieldErrors = frame.t === "rejected" ? (frame.field_errors ?? {}) : {};
50
- const message = frame.t === "rejected" || frame.t === "error" ? frame.message : "write failed";
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: { kind: frame.t === "error" ? frame.kind : "invalid", message, fieldErrors } };
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;
@@ -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 items = Array.from({ length: count }, (_, i) => {
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 = items.indexOf(undefined);
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: total ?? end,
114
- lengthExact: total !== null ? totalExact : settled && error === null && (loadState === "complete" || end < start + count),
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";
@@ -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: FlashItem[];
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?: FlashItem[];
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, but nothing sends a saved outbox after a reload: edits
8
- // still waiting when the page reloads are lost.
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();
@@ -1,6 +1,10 @@
1
1
  /** The wire protocol this client speaks; sent in `hello`. */
2
2
  export declare const PROTOCOL = 1;
3
- /** What this client understands beyond the protocol; sent in `hello`. */
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
- export type ServerFrame = {
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
- /** `code` names errors a client acts on: `protocol`, `unknown_frame`, and a run's {@link RunCode}. */
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
- /** What this client understands beyond the protocol; sent in `hello`. */
5
- export const FEATURES = ["runs:1"];
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 { Json, ServerFrame } from "../protocol.js";
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, { code, message: reply.message });
205
+ this.finishWith("failed", undefined, runError(code, reply.message, refusalOf(reply)));
205
206
  }
206
207
  finish(frame) {
207
- const error = frame.error ? { code: frame.error.code, message: frame.error.message } : null;
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);
@@ -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
- /** Why a run was refused or failed. `offline`: the client wasn't connected. */
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 { ErrorKind, ServerFrame } from "./protocol.js";
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 fail(frame.kind, frame.message);
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.4.4",
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.2.3"
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. After the server
82
- refuses a token, `db.reconnect()` connects again (see Connection states).
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); `whenLoaded()` waits for the window's rows. In React, `useWindow`
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`, or after an API reload). Final |
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 connects again,
335
- calls `token` afresh, and gives the collections and records the refusal
336
- failed another try. It resolves like `db.ready`: with the connection once
337
- it is open, or refused again. A sign-in form stores the new token where the
338
- `token` function reads it and calls `reconnect()`, with no page reload:
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