@vantage-ui/client 0.0.0-stage → 0.4.4
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 +50 -0
- package/README.md +131 -2
- package/bin/vantage-client.js +5 -0
- package/dist/client-connection.d.ts +41 -0
- package/dist/client-connection.js +260 -0
- package/dist/client-core.d.ts +70 -0
- package/dist/client-core.js +140 -0
- package/dist/client-loading.d.ts +13 -0
- package/dist/client-loading.js +229 -0
- package/dist/client-runs.d.ts +18 -0
- package/dist/client-runs.js +88 -0
- package/dist/client-writes.d.ts +14 -0
- package/dist/client-writes.js +114 -0
- package/dist/client.d.ts +32 -0
- package/dist/client.js +61 -0
- package/dist/codegen/cli.d.ts +8 -0
- package/dist/codegen/cli.js +54 -0
- package/dist/codegen/emit.d.ts +3 -0
- package/dist/codegen/emit.js +190 -0
- package/dist/codegen/fetch.d.ts +4 -0
- package/dist/codegen/fetch.js +41 -0
- package/dist/codegen/meta.d.ts +13 -0
- package/dist/codegen/meta.js +59 -0
- package/dist/codegen/runs.d.ts +20 -0
- package/dist/codegen/runs.js +58 -0
- package/dist/codegen/schema.d.ts +97 -0
- package/dist/codegen/schema.js +23 -0
- package/dist/collection-cell.d.ts +69 -0
- package/dist/collection-cell.js +153 -0
- package/dist/collection.d.ts +32 -0
- package/dist/collection.js +122 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +14 -0
- package/dist/mock-query.d.ts +9 -0
- package/dist/mock-query.js +75 -0
- package/dist/mock-runs.d.ts +61 -0
- package/dist/mock-runs.js +165 -0
- package/dist/mock-store.d.ts +78 -0
- package/dist/mock-store.js +158 -0
- package/dist/mock-transport.d.ts +16 -0
- package/dist/mock-transport.js +38 -0
- package/dist/model.d.ts +5 -0
- package/dist/model.js +169 -0
- package/dist/persist.d.ts +47 -0
- package/dist/persist.js +108 -0
- package/dist/protocol.d.ts +236 -0
- package/dist/protocol.js +5 -0
- package/dist/react.d.ts +59 -0
- package/dist/react.js +169 -0
- package/dist/reserved.d.ts +6 -0
- package/dist/reserved.js +13 -0
- package/dist/run/frames.d.ts +6 -0
- package/dist/run/frames.js +24 -0
- package/dist/run/hooks.d.ts +18 -0
- package/dist/run/hooks.js +87 -0
- package/dist/run/messages.d.ts +20 -0
- package/dist/run/messages.js +42 -0
- package/dist/run/outcome.d.ts +12 -0
- package/dist/run/outcome.js +27 -0
- package/dist/run/run.d.ts +82 -0
- package/dist/run/run.js +246 -0
- package/dist/run/storage.d.ts +3 -0
- package/dist/run/storage.js +20 -0
- package/dist/run/types.d.ts +92 -0
- package/dist/run/types.js +3 -0
- package/dist/run/variables.d.ts +26 -0
- package/dist/run/variables.js +70 -0
- package/dist/schema-meta.d.ts +91 -0
- package/dist/schema-meta.js +3 -0
- package/dist/signal.d.ts +45 -0
- package/dist/signal.js +163 -0
- package/dist/store.d.ts +45 -0
- package/dist/store.js +144 -0
- package/dist/table.d.ts +62 -0
- package/dist/table.js +82 -0
- package/dist/testing.d.ts +61 -0
- package/dist/testing.js +195 -0
- package/dist/transport.d.ts +28 -0
- package/dist/transport.js +32 -0
- package/dist/types.d.ts +159 -0
- package/dist/types.js +2 -0
- package/dist/util.d.ts +18 -0
- package/dist/util.js +46 -0
- package/package.json +56 -4
- package/skills/vantage-client/SKILL.md +390 -0
- package/skills/vantage-client/references/protocol.md +67 -0
- package/skills/vantage-client/references/react.md +242 -0
- package/skills/vantage-client/references/testing.md +173 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// The client's state and plumbing: the transport and request routing, the
|
|
2
|
+
// connection status, persistence, and the model instances. The layers on
|
|
3
|
+
// top (loading, writes, connection) and `Vantage` itself extend it.
|
|
4
|
+
import { buildModelClass } from "./model.js";
|
|
5
|
+
import { State } from "./signal.js";
|
|
6
|
+
import { Store } from "./store.js";
|
|
7
|
+
import { WebSocketTransport, defaultUrl } from "./transport.js";
|
|
8
|
+
export class ClientCore {
|
|
9
|
+
/** @internal */ store = new Store();
|
|
10
|
+
connection = new State({ status: "connecting", halted: false });
|
|
11
|
+
principal = new State(null);
|
|
12
|
+
seq = new State(0);
|
|
13
|
+
/**
|
|
14
|
+
* Resolves with the connection once `instant` tables have loaded for the
|
|
15
|
+
* first time, or once the server refused the client for good (`halted`).
|
|
16
|
+
*/
|
|
17
|
+
ready;
|
|
18
|
+
schema;
|
|
19
|
+
transport;
|
|
20
|
+
opts;
|
|
21
|
+
storage;
|
|
22
|
+
models = new Map();
|
|
23
|
+
classes = new Map();
|
|
24
|
+
collections = new Map();
|
|
25
|
+
/** One Collection per description, kept while one of its cells is. */
|
|
26
|
+
interned = new Map();
|
|
27
|
+
bySub = new Map();
|
|
28
|
+
handles = new Map();
|
|
29
|
+
requests = new Map();
|
|
30
|
+
nextId = 1;
|
|
31
|
+
cursor;
|
|
32
|
+
loadQueue = new Map();
|
|
33
|
+
loadWaiters = new Map();
|
|
34
|
+
flashQueue = [];
|
|
35
|
+
outbox = [];
|
|
36
|
+
undoStack = [];
|
|
37
|
+
retry = 0;
|
|
38
|
+
/** Bumps on every `resync`: replies to frames sent before it are stale. */
|
|
39
|
+
generation = 0;
|
|
40
|
+
closed = false;
|
|
41
|
+
sweeper;
|
|
42
|
+
resolveReady;
|
|
43
|
+
persistTimer;
|
|
44
|
+
dirtyCells = new Set();
|
|
45
|
+
constructor(options) {
|
|
46
|
+
this.opts = options;
|
|
47
|
+
this.schema = options.schema;
|
|
48
|
+
this.storage = options.storage;
|
|
49
|
+
this.transport = options.transport ?? new WebSocketTransport(options.url ?? defaultUrl());
|
|
50
|
+
this.ready = new Promise((resolve) => (this.resolveReady = resolve));
|
|
51
|
+
}
|
|
52
|
+
/** Settle `ready` with the connection as it is now; later calls change nothing. */
|
|
53
|
+
markReady() {
|
|
54
|
+
this.resolveReady(this.connection.peek());
|
|
55
|
+
}
|
|
56
|
+
/** This client as the typed `Vantage` the models and collections hold. */
|
|
57
|
+
get db() {
|
|
58
|
+
return this;
|
|
59
|
+
}
|
|
60
|
+
/** @internal */ get keepAliveMs() {
|
|
61
|
+
return this.opts.keepAliveMs ?? 30_000;
|
|
62
|
+
}
|
|
63
|
+
close() {
|
|
64
|
+
this.closed = true;
|
|
65
|
+
clearInterval(this.sweeper);
|
|
66
|
+
this.transport.close();
|
|
67
|
+
}
|
|
68
|
+
get online() {
|
|
69
|
+
return this.connection.peek().status === "open";
|
|
70
|
+
}
|
|
71
|
+
/** @internal The server's final refusal (`refused`, `protocol_mismatch`), once there is one. */
|
|
72
|
+
get haltedWith() {
|
|
73
|
+
const { status, error, halted } = this.connection.peek();
|
|
74
|
+
return halted ? (error ?? status) : undefined;
|
|
75
|
+
}
|
|
76
|
+
setStatus(status, error) {
|
|
77
|
+
const now = this.connection.peek();
|
|
78
|
+
if (now.status === status && now.error === error)
|
|
79
|
+
return;
|
|
80
|
+
const halted = status === "refused" || status === "protocol_mismatch";
|
|
81
|
+
this.connection.set(error === undefined ? { status, halted } : { status, halted, error });
|
|
82
|
+
}
|
|
83
|
+
/** @internal */ request(frame, force = false) {
|
|
84
|
+
if (!force && !this.online)
|
|
85
|
+
return Promise.resolve({ t: "error", id: 0, kind: "offline", message: "not connected" });
|
|
86
|
+
const id = this.nextId++;
|
|
87
|
+
return new Promise((resolve) => {
|
|
88
|
+
this.requests.set(id, resolve);
|
|
89
|
+
this.transport.send({ ...frame, id });
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
send(frame) {
|
|
93
|
+
this.transport.send(frame);
|
|
94
|
+
}
|
|
95
|
+
// ------------------------------------------------------------ persistence
|
|
96
|
+
persistLater(cell) {
|
|
97
|
+
if (!this.storage || !this.schema.tables[cell.table]?.persist)
|
|
98
|
+
return;
|
|
99
|
+
this.dirtyCells.add(cell);
|
|
100
|
+
clearTimeout(this.persistTimer);
|
|
101
|
+
this.persistTimer = setTimeout(() => {
|
|
102
|
+
const cells = [...this.dirtyCells];
|
|
103
|
+
this.dirtyCells.clear();
|
|
104
|
+
void this.storage.save({
|
|
105
|
+
records: cells.map((c) => ({ table: c.table, id: c.id, record: c.presence.peek() === "missing" ? null : c.baseline })),
|
|
106
|
+
cursor: this.cursor,
|
|
107
|
+
});
|
|
108
|
+
}, 250);
|
|
109
|
+
}
|
|
110
|
+
persistCursor() {
|
|
111
|
+
if (this.storage && this.cursor)
|
|
112
|
+
void this.storage.save({ cursor: this.cursor });
|
|
113
|
+
}
|
|
114
|
+
// ------------------------------------------------------------ models
|
|
115
|
+
/** @internal The one model instance for (table, id). */
|
|
116
|
+
model(table, id) {
|
|
117
|
+
const cell = this.store.cell(table, id);
|
|
118
|
+
let model = this.models.get(cell);
|
|
119
|
+
if (!model) {
|
|
120
|
+
const Class = this.modelClass(table);
|
|
121
|
+
model = new Class(cell);
|
|
122
|
+
this.models.set(cell, model);
|
|
123
|
+
}
|
|
124
|
+
return model;
|
|
125
|
+
}
|
|
126
|
+
/** @internal */ meta(table) {
|
|
127
|
+
const meta = this.schema.tables[table];
|
|
128
|
+
if (!meta)
|
|
129
|
+
throw new Error(`vantage: unknown table \`${table}\``);
|
|
130
|
+
return meta;
|
|
131
|
+
}
|
|
132
|
+
modelClass(table) {
|
|
133
|
+
let Class = this.classes.get(table);
|
|
134
|
+
if (!Class) {
|
|
135
|
+
Class = buildModelClass(this.db, table, this.meta(table));
|
|
136
|
+
this.classes.set(table, Class);
|
|
137
|
+
}
|
|
138
|
+
return Class;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { ClientCore } from "./client-core.js";
|
|
2
|
+
import type { ServerFrame } from "./protocol.js";
|
|
3
|
+
export declare class ClientLoading extends ClientCore {
|
|
4
|
+
protected flushLoads(): Promise<void>;
|
|
5
|
+
protected wake(table: string, id: string): void;
|
|
6
|
+
/** Close the cell's sub on the server and forget it, so its `members` no longer reach the cell. */
|
|
7
|
+
private dropSub;
|
|
8
|
+
protected applyMembers(frame: Extract<ServerFrame, {
|
|
9
|
+
t: "members";
|
|
10
|
+
}>): void;
|
|
11
|
+
/** Close collections and drop records nothing has shown for a while. */
|
|
12
|
+
protected sweep(): void;
|
|
13
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
// Reading: records by id (batched per microtask), collections and their
|
|
2
|
+
// windows, and the sweep that closes what nothing has shown for a while.
|
|
3
|
+
import { ClientCore } from "./client-core.js";
|
|
4
|
+
import { CollectionCell, specKey } from "./collection-cell.js";
|
|
5
|
+
import { batch } from "./signal.js";
|
|
6
|
+
export class ClientLoading extends ClientCore {
|
|
7
|
+
/**
|
|
8
|
+
* @internal Load records by id, batched per microtask. A record the server refused
|
|
9
|
+
* isn't asked for again, and after a final refusal of the connection
|
|
10
|
+
* every record resolves at once, refused with its message.
|
|
11
|
+
*/
|
|
12
|
+
requestRecords(table, ids) {
|
|
13
|
+
const halted = this.haltedWith;
|
|
14
|
+
const waits = ids.map((id) => {
|
|
15
|
+
const cell = this.store.cell(table, id);
|
|
16
|
+
const presence = cell.presence.peek();
|
|
17
|
+
if (presence === "loaded" || presence === "missing" || presence === "error")
|
|
18
|
+
return Promise.resolve();
|
|
19
|
+
if (halted !== undefined) {
|
|
20
|
+
cell.refused(halted);
|
|
21
|
+
return Promise.resolve();
|
|
22
|
+
}
|
|
23
|
+
if (presence !== "loading") {
|
|
24
|
+
cell.presence.set("loading");
|
|
25
|
+
let queued = this.loadQueue.get(table);
|
|
26
|
+
if (!queued)
|
|
27
|
+
this.loadQueue.set(table, (queued = new Set()));
|
|
28
|
+
queued.add(id);
|
|
29
|
+
if (this.loadQueue.size === 1 && queued.size === 1)
|
|
30
|
+
queueMicrotask(() => this.flushLoads());
|
|
31
|
+
}
|
|
32
|
+
return new Promise((resolve) => {
|
|
33
|
+
const key = `${table}\u0000${id}`;
|
|
34
|
+
this.loadWaiters.set(key, [...(this.loadWaiters.get(key) ?? []), resolve]);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
return Promise.all(waits).then(() => undefined);
|
|
38
|
+
}
|
|
39
|
+
async flushLoads() {
|
|
40
|
+
if (!this.online)
|
|
41
|
+
return; // retried on reconnect
|
|
42
|
+
const queued = [...this.loadQueue];
|
|
43
|
+
this.loadQueue.clear();
|
|
44
|
+
await Promise.all(queued.map(async ([table, ids]) => {
|
|
45
|
+
const reply = await this.request({ t: "open_records", table, ids: [...ids] });
|
|
46
|
+
batch(() => {
|
|
47
|
+
if (reply.t === "records") {
|
|
48
|
+
for (const r of reply.records)
|
|
49
|
+
this.store.cell(table, r.id).applyServer(r);
|
|
50
|
+
for (const id of reply.missing)
|
|
51
|
+
this.store.cell(table, id).presence.set("missing");
|
|
52
|
+
}
|
|
53
|
+
else if (reply.t === "error" && reply.kind === "offline" && this.haltedWith === undefined) {
|
|
54
|
+
for (const id of ids)
|
|
55
|
+
this.store.cell(table, id).presence.set("none");
|
|
56
|
+
}
|
|
57
|
+
else if (reply.t === "error" && reply.kind === "offline") {
|
|
58
|
+
for (const id of ids)
|
|
59
|
+
this.store.cell(table, id).refused(this.haltedWith);
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
const message = reply.t === "error" ? reply.message : `unexpected reply \`${reply.t}\``;
|
|
63
|
+
for (const id of ids)
|
|
64
|
+
this.store.cell(table, id).refused(message);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
for (const id of ids)
|
|
68
|
+
this.wake(table, id);
|
|
69
|
+
}));
|
|
70
|
+
}
|
|
71
|
+
wake(table, id) {
|
|
72
|
+
const key = `${table}\u0000${id}`;
|
|
73
|
+
for (const resolve of this.loadWaiters.get(key) ?? [])
|
|
74
|
+
resolve();
|
|
75
|
+
this.loadWaiters.delete(key);
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* @internal Fetch loaded records again; they stay readable until the reply
|
|
79
|
+
* replaces them. When the reload fails, the next read of each tries again.
|
|
80
|
+
*/
|
|
81
|
+
async reloadRecords(table, ids) {
|
|
82
|
+
const reply = await this.request({ t: "open_records", table, ids });
|
|
83
|
+
if (reply.t !== "records") {
|
|
84
|
+
for (const id of ids) {
|
|
85
|
+
const cell = this.store.peek(table, id);
|
|
86
|
+
if (cell?.presence.peek() === "loaded")
|
|
87
|
+
cell.reload = true;
|
|
88
|
+
}
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
batch(() => {
|
|
92
|
+
for (const r of reply.records) {
|
|
93
|
+
const cell = this.store.cell(table, r.id);
|
|
94
|
+
cell.applyServer(r);
|
|
95
|
+
this.persistLater(cell);
|
|
96
|
+
}
|
|
97
|
+
for (const id of reply.missing)
|
|
98
|
+
this.store.cell(table, id).markRemoved();
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
// ------------------------------------------------------------ collections
|
|
102
|
+
/** @internal */ collectionCell(spec) {
|
|
103
|
+
const key = specKey(spec);
|
|
104
|
+
let cell = this.collections.get(key);
|
|
105
|
+
if (!cell) {
|
|
106
|
+
cell = new CollectionCell(this.db, spec, key);
|
|
107
|
+
this.collections.set(key, cell);
|
|
108
|
+
}
|
|
109
|
+
return cell;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* @internal Open `cell` on the server. A refusal sets its error and is not
|
|
113
|
+
* retried; a lost connection leaves it for the reconnect. A reply that
|
|
114
|
+
* comes after a resync, or after the cell closed, is dropped and its sub
|
|
115
|
+
* closed; so is the sub this open replaces.
|
|
116
|
+
*/
|
|
117
|
+
async openCollection(cell) {
|
|
118
|
+
if (!this.online)
|
|
119
|
+
return;
|
|
120
|
+
const { $window: _, ...spec } = cell.spec;
|
|
121
|
+
const generation = this.generation;
|
|
122
|
+
const reply = await this.request({ t: "open_table", ...spec });
|
|
123
|
+
if (generation !== this.generation || this.collections.get(cell.key) !== cell) {
|
|
124
|
+
if (reply.t === "rows")
|
|
125
|
+
this.send({ t: "close", sub: reply.sub });
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (reply.t !== "rows") {
|
|
129
|
+
const lost = reply.t === "error" && reply.kind === "offline";
|
|
130
|
+
if (!lost) {
|
|
131
|
+
// A refused reopen (after an invalidation) ends the sub it would have replaced.
|
|
132
|
+
this.dropSub(cell);
|
|
133
|
+
cell.failed(reply.t === "error" ? reply.message : `unexpected reply \`${reply.t}\``);
|
|
134
|
+
}
|
|
135
|
+
else if (this.haltedWith !== undefined)
|
|
136
|
+
cell.failed(this.haltedWith);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (cell.sub !== reply.sub)
|
|
140
|
+
this.dropSub(cell);
|
|
141
|
+
this.bySub.set(reply.sub, cell);
|
|
142
|
+
// A scrolled window keeps the rows on screen until its viewport arrives.
|
|
143
|
+
const scrolled = cell.scrolled;
|
|
144
|
+
batch(() => {
|
|
145
|
+
for (const r of reply.records) {
|
|
146
|
+
const record = this.store.cell(reply.table, r.id);
|
|
147
|
+
record.applyServer(r);
|
|
148
|
+
this.persistLater(record);
|
|
149
|
+
}
|
|
150
|
+
if (scrolled)
|
|
151
|
+
cell.opened(reply.sub);
|
|
152
|
+
else
|
|
153
|
+
cell.loaded(reply.sub, reply.start, reply.ids, reply.total, reply.complete);
|
|
154
|
+
});
|
|
155
|
+
if (scrolled)
|
|
156
|
+
this.moveWindow(cell);
|
|
157
|
+
}
|
|
158
|
+
/** Close the cell's sub on the server and forget it, so its `members` no longer reach the cell. */
|
|
159
|
+
dropSub(cell) {
|
|
160
|
+
if (cell.sub === null)
|
|
161
|
+
return;
|
|
162
|
+
this.bySub.delete(cell.sub);
|
|
163
|
+
this.send({ t: "close", sub: cell.sub });
|
|
164
|
+
cell.sub = null;
|
|
165
|
+
}
|
|
166
|
+
/** @internal Move the window; a refused move sets the cell's error. */
|
|
167
|
+
moveWindow(cell) {
|
|
168
|
+
if (cell.sub === null || !cell.wantedWindow)
|
|
169
|
+
return;
|
|
170
|
+
const generation = this.generation;
|
|
171
|
+
void this.request({ t: "viewport", sub: cell.sub, start: cell.wantedWindow.start, count: cell.wantedWindow.count }).then((reply) => {
|
|
172
|
+
if (generation !== this.generation)
|
|
173
|
+
return;
|
|
174
|
+
if (reply.t === "members")
|
|
175
|
+
this.applyMembers(reply);
|
|
176
|
+
else if (reply.t === "error" && reply.kind !== "offline")
|
|
177
|
+
cell.failed(reply.message);
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
/** @internal */ closeCollection(cell) {
|
|
181
|
+
if (cell.sub !== null) {
|
|
182
|
+
this.send({ t: "close", sub: cell.sub });
|
|
183
|
+
this.bySub.delete(cell.sub);
|
|
184
|
+
}
|
|
185
|
+
this.collections.delete(cell.key);
|
|
186
|
+
}
|
|
187
|
+
applyMembers(frame) {
|
|
188
|
+
const cell = this.bySub.get(frame.sub);
|
|
189
|
+
if (!cell)
|
|
190
|
+
return;
|
|
191
|
+
batch(() => {
|
|
192
|
+
for (const r of frame.records) {
|
|
193
|
+
const record = this.store.cell(cell.spec.table, r.id);
|
|
194
|
+
record.applyServer(r);
|
|
195
|
+
this.persistLater(record);
|
|
196
|
+
}
|
|
197
|
+
cell.members(frame.start, frame.ids, frame.total, frame.complete);
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
// ------------------------------------------------------------ housekeeping
|
|
201
|
+
/** Close collections and drop records nothing has shown for a while. */
|
|
202
|
+
sweep() {
|
|
203
|
+
const keep = this.keepAliveMs;
|
|
204
|
+
const now = Date.now();
|
|
205
|
+
for (const cell of [...this.collections.values()]) {
|
|
206
|
+
if (!cell.pinned && !cell.wanted() && now - cell.touched > keep)
|
|
207
|
+
this.closeCollection(cell);
|
|
208
|
+
}
|
|
209
|
+
for (const [key, collection] of this.interned) {
|
|
210
|
+
if (now - collection.created > keep && !collection.cellKeys().some((k) => this.collections.has(k)))
|
|
211
|
+
this.interned.delete(key);
|
|
212
|
+
}
|
|
213
|
+
const listed = new Set([...this.collections.values()].flatMap((c) => c.ids.peek().map((id) => `${c.spec.table}\u0000${id}`)));
|
|
214
|
+
const drop = [];
|
|
215
|
+
for (const cell of this.store.allCells()) {
|
|
216
|
+
const observed = [...cell.fields.values()].some((s) => s.observed) || cell.presence.observed || cell.version.observed;
|
|
217
|
+
if (observed || cell.pending.size || now - cell.touched < keep || listed.has(`${cell.table}\u0000${cell.id}`))
|
|
218
|
+
continue;
|
|
219
|
+
if (this.schema.tables[cell.table]?.load === "instant")
|
|
220
|
+
continue;
|
|
221
|
+
this.models.delete(cell);
|
|
222
|
+
this.store.evict(cell.table, cell.id);
|
|
223
|
+
if (cell.presence.peek() === "loaded")
|
|
224
|
+
drop.push([cell.table, cell.id]);
|
|
225
|
+
}
|
|
226
|
+
if (drop.length && this.online)
|
|
227
|
+
this.send({ t: "interest", remove: drop });
|
|
228
|
+
}
|
|
229
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { ClientWrites } from "./client-writes.js";
|
|
2
|
+
import type { RunFrame } from "./protocol.js";
|
|
3
|
+
export declare class ClientRuns extends ClientWrites {
|
|
4
|
+
/** The runs frames are routed to, by id. More than one may follow an id (two tabs' worth in one page). */
|
|
5
|
+
private following;
|
|
6
|
+
private factories;
|
|
7
|
+
private readonly runHost;
|
|
8
|
+
/** Route a `run` frame to every run following its id. */
|
|
9
|
+
protected onRun(frame: RunFrame): void;
|
|
10
|
+
/**
|
|
11
|
+
* After a welcome or a resync the server streams nothing to this socket:
|
|
12
|
+
* send each unanswered invoke again (same id and args, so nothing runs
|
|
13
|
+
* twice) and attach each running run for a fresh snapshot.
|
|
14
|
+
*/
|
|
15
|
+
protected resumeRuns(): void;
|
|
16
|
+
private invokeRun;
|
|
17
|
+
private runMeta;
|
|
18
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Action runs: `db.run.<action>` and `model.$run.<action>` factories, the
|
|
2
|
+
// runs this client follows, their `run` frames, and picking them up again
|
|
3
|
+
// after a reconnect or a resync. Row and toolbar action methods
|
|
4
|
+
// (`issue.close_issue(…)`) are a run awaited to its end.
|
|
5
|
+
import { ClientWrites } from "./client-writes.js";
|
|
6
|
+
import { runFactory } from "./run/run.js";
|
|
7
|
+
/** The feature a server offers when it runs actions as runs. */
|
|
8
|
+
const RUNS = "runs:1";
|
|
9
|
+
export class ClientRuns extends ClientWrites {
|
|
10
|
+
/** The runs frames are routed to, by id. More than one may follow an id (two tabs' worth in one page). */
|
|
11
|
+
following = new Map();
|
|
12
|
+
factories = new Map();
|
|
13
|
+
/** @internal What the server's `welcome` offered; null before the first. */
|
|
14
|
+
features = null;
|
|
15
|
+
runHost = {
|
|
16
|
+
invoke: (run, args) => this.invokeRun(run, args),
|
|
17
|
+
request: (frame) => this.request(frame),
|
|
18
|
+
follow: (run) => {
|
|
19
|
+
let set = this.following.get(run.id);
|
|
20
|
+
if (!set)
|
|
21
|
+
this.following.set(run.id, (set = new Set()));
|
|
22
|
+
set.add(run);
|
|
23
|
+
},
|
|
24
|
+
unfollow: (run) => {
|
|
25
|
+
const set = this.following.get(run.id);
|
|
26
|
+
if (set?.delete(run) && set.size === 0)
|
|
27
|
+
this.following.delete(run.id);
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
/** @internal The factory for a project action (`action`) or a row/toolbar action (`table.name`, with `rid` for a row). */
|
|
31
|
+
runs(action, rid) {
|
|
32
|
+
const key = `${action}\u0000${rid ?? ""}`;
|
|
33
|
+
let factory = this.factories.get(key);
|
|
34
|
+
if (!factory) {
|
|
35
|
+
factory = runFactory(this.runHost, action, rid, this.runMeta(action));
|
|
36
|
+
// Row factories are per record; keep only project and toolbar ones.
|
|
37
|
+
if (rid === undefined)
|
|
38
|
+
this.factories.set(key, factory);
|
|
39
|
+
}
|
|
40
|
+
return factory;
|
|
41
|
+
}
|
|
42
|
+
/** @internal A row or toolbar action as one call: start a run, wait for its end. */
|
|
43
|
+
async invoke(action, rid, args = {}, opts = {}) {
|
|
44
|
+
const table = action.includes(".") ? action.split(".")[0] : undefined;
|
|
45
|
+
const cell = table && rid ? this.store.cell(table, rid) : undefined;
|
|
46
|
+
const optimistic = opts.optimistic && cell ? Object.keys(opts.optimistic) : [];
|
|
47
|
+
if (cell && opts.optimistic)
|
|
48
|
+
cell.setPending(opts.optimistic);
|
|
49
|
+
const outcome = await this.runs(action, rid).start(args).done;
|
|
50
|
+
if (cell && optimistic.length)
|
|
51
|
+
cell.settle(optimistic, outcome.ok ? "fresh" : "failed");
|
|
52
|
+
if (!outcome.ok)
|
|
53
|
+
return outcome;
|
|
54
|
+
return { ok: true, value: table && rid ? this.model(table, rid) : undefined };
|
|
55
|
+
}
|
|
56
|
+
/** Route a `run` frame to every run following its id. */
|
|
57
|
+
onRun(frame) {
|
|
58
|
+
for (const run of [...(this.following.get(frame.run) ?? [])])
|
|
59
|
+
run.apply(frame);
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* After a welcome or a resync the server streams nothing to this socket:
|
|
63
|
+
* send each unanswered invoke again (same id and args, so nothing runs
|
|
64
|
+
* twice) and attach each running run for a fresh snapshot.
|
|
65
|
+
*/
|
|
66
|
+
resumeRuns() {
|
|
67
|
+
for (const set of [...this.following.values()])
|
|
68
|
+
for (const run of [...set])
|
|
69
|
+
run.resume();
|
|
70
|
+
}
|
|
71
|
+
async invokeRun(run, args) {
|
|
72
|
+
const halted = this.haltedWith;
|
|
73
|
+
if (halted !== undefined)
|
|
74
|
+
return { t: "error", id: 0, kind: "unavailable", message: halted };
|
|
75
|
+
// Not connected: answered as not sent; `resumeRuns` sends it after the welcome.
|
|
76
|
+
if (!this.online)
|
|
77
|
+
return { t: "error", id: 0, kind: "offline", message: "not connected" };
|
|
78
|
+
if (!this.features?.has(RUNS))
|
|
79
|
+
return { t: "error", id: 0, kind: "failed", message: "the server doesn't support runs (no `runs:1` in its welcome)" };
|
|
80
|
+
const frame = args === undefined ? { t: "invoke", run: run.id } : { t: "invoke", run: run.id, action: run.action, rid: run.recordId, args: args };
|
|
81
|
+
return this.request(frame);
|
|
82
|
+
}
|
|
83
|
+
runMeta(action) {
|
|
84
|
+
const [table, name] = action.includes(".") ? action.split(".") : [undefined, action];
|
|
85
|
+
const meta = table ? this.schema.tables[table]?.actions[name] : this.schema.actions[name];
|
|
86
|
+
return meta ?? {};
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { ClientLoading } from "./client-loading.js";
|
|
2
|
+
import type { ActionResult } from "./types.js";
|
|
3
|
+
export declare class ClientWrites extends ClientLoading {
|
|
4
|
+
/** Choices for a form field whose options come from a script. */
|
|
5
|
+
options(action: string, field: string, form?: Record<string, unknown>): Promise<{
|
|
6
|
+
value: unknown;
|
|
7
|
+
label: string;
|
|
8
|
+
}[]>;
|
|
9
|
+
/** Undo your last confirmed edit, if nobody has changed those fields since. */
|
|
10
|
+
undo(): Promise<ActionResult<undefined>>;
|
|
11
|
+
/** Flashes in one microtask travel as one `batch` (not a transaction). After a halt, a write settles at once with its error. */
|
|
12
|
+
private enqueue;
|
|
13
|
+
protected flush(): Promise<void>;
|
|
14
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// Writing: optimistic edits batched per microtask, deletes, undo, and edits
|
|
2
|
+
// queued while offline.
|
|
3
|
+
import { ClientLoading } from "./client-loading.js";
|
|
4
|
+
import { fail, failFrom, pick, sameValue, uuidv7 } from "./util.js";
|
|
5
|
+
export class ClientWrites extends ClientLoading {
|
|
6
|
+
/** Choices for a form field whose options come from a script. */
|
|
7
|
+
async options(action, field, form = {}) {
|
|
8
|
+
const reply = await this.request({ t: "options", action, field, form });
|
|
9
|
+
return reply.t === "options_result" ? reply.items : [];
|
|
10
|
+
}
|
|
11
|
+
// ------------------------------------------------------------ writes
|
|
12
|
+
/** @internal Optimistic write. No `id` inserts (an `input.id` is kept). */
|
|
13
|
+
async write(table, id, patch, opts = {}) {
|
|
14
|
+
let cell;
|
|
15
|
+
let before;
|
|
16
|
+
const keys = Object.keys(patch).filter((k) => k !== "id");
|
|
17
|
+
if (id) {
|
|
18
|
+
cell = this.store.cell(table, id);
|
|
19
|
+
before = pick(cell.snapshot(), keys);
|
|
20
|
+
cell.setPending(pick(patch, keys));
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
const local = String(patch.id ?? uuidv7());
|
|
24
|
+
id = local.includes(":") ? local : `${table}:${local}`;
|
|
25
|
+
cell = this.store.cell(table, id);
|
|
26
|
+
cell.setPending({ ...pick(patch, keys) });
|
|
27
|
+
patch = { ...patch, id: local.includes(":") ? local.split(":").slice(1).join(":") : local };
|
|
28
|
+
}
|
|
29
|
+
const item = before ? { table, rid: id, patch: pick(patch, keys) } : { table, patch };
|
|
30
|
+
const frame = await this.enqueue(item);
|
|
31
|
+
if (frame.t === "flashed") {
|
|
32
|
+
let target = cell;
|
|
33
|
+
if (frame.rid !== cell.id) {
|
|
34
|
+
// The server named it (`client_order:…`): move to the real id.
|
|
35
|
+
target = this.store.cell(table, frame.rid);
|
|
36
|
+
cell.markRemoved();
|
|
37
|
+
this.store.evict(table, cell.id);
|
|
38
|
+
}
|
|
39
|
+
if (frame.record)
|
|
40
|
+
target.applyServer(frame.record);
|
|
41
|
+
target.settle(keys, "fresh");
|
|
42
|
+
this.persistLater(target);
|
|
43
|
+
if (before && opts.recordUndo !== false)
|
|
44
|
+
this.undoStack.push({ table, id: target.id, before, after: pick(patch, keys) });
|
|
45
|
+
if (this.undoStack.length > 100)
|
|
46
|
+
this.undoStack.shift();
|
|
47
|
+
return { ok: true, value: this.model(table, target.id) };
|
|
48
|
+
}
|
|
49
|
+
const fieldErrors = frame.t === "rejected" ? (frame.field_errors ?? {}) : {};
|
|
50
|
+
const message = frame.t === "rejected" || frame.t === "error" ? frame.message : "write failed";
|
|
51
|
+
cell.settle(keys, "failed", Object.keys(fieldErrors).length ? fieldErrors : { $: message });
|
|
52
|
+
if (!before)
|
|
53
|
+
cell.markRemoved();
|
|
54
|
+
return { ok: false, error: { kind: frame.t === "error" ? frame.kind : "invalid", message, fieldErrors } };
|
|
55
|
+
}
|
|
56
|
+
/** @internal */ async remove(table, id) {
|
|
57
|
+
const cell = this.store.cell(table, id);
|
|
58
|
+
const backup = cell.snapshot();
|
|
59
|
+
cell.markRemoved();
|
|
60
|
+
const reply = await this.request({ t: "delete", table, rid: id });
|
|
61
|
+
if (reply.t === "flashed")
|
|
62
|
+
return { ok: true, value: undefined };
|
|
63
|
+
cell.applyServer(backup, "failed");
|
|
64
|
+
return failFrom(reply);
|
|
65
|
+
}
|
|
66
|
+
/** Undo your last confirmed edit, if nobody has changed those fields since. */
|
|
67
|
+
async undo() {
|
|
68
|
+
const last = this.undoStack.pop();
|
|
69
|
+
if (!last)
|
|
70
|
+
return fail("invalid", "nothing to undo");
|
|
71
|
+
const now = this.store.cell(last.table, last.id).snapshot();
|
|
72
|
+
const keys = Object.keys(last.after).filter((k) => sameValue(now[k], last.after[k]));
|
|
73
|
+
if (keys.length === 0)
|
|
74
|
+
return fail("conflict", "someone changed it since");
|
|
75
|
+
const result = await this.write(last.table, last.id, pick(last.before, keys), { recordUndo: false });
|
|
76
|
+
return result.ok ? { ok: true, value: undefined } : result;
|
|
77
|
+
}
|
|
78
|
+
/** Flashes in one microtask travel as one `batch` (not a transaction). After a halt, a write settles at once with its error. */
|
|
79
|
+
enqueue(item) {
|
|
80
|
+
return new Promise((resolve) => {
|
|
81
|
+
const halted = this.haltedWith;
|
|
82
|
+
if (halted !== undefined) {
|
|
83
|
+
const kind = this.connection.peek().status === "refused" ? "forbidden" : "unavailable";
|
|
84
|
+
return resolve({ t: "error", id: 0, kind, message: halted });
|
|
85
|
+
}
|
|
86
|
+
if (!this.online) {
|
|
87
|
+
if (!this.storage)
|
|
88
|
+
return resolve({ t: "error", id: 0, kind: "offline", message: "not connected" });
|
|
89
|
+
// Offline with a mirror: keep it and send on reconnect.
|
|
90
|
+
this.outbox.push(item);
|
|
91
|
+
void this.storage.save({ outbox: this.outbox });
|
|
92
|
+
this.flashQueue.push({ item, resolve });
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
this.flashQueue.push({ item, resolve });
|
|
96
|
+
if (this.flashQueue.length === 1)
|
|
97
|
+
queueMicrotask(() => this.flush());
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
async flush() {
|
|
101
|
+
const queue = this.flashQueue.splice(0);
|
|
102
|
+
if (queue.length === 0)
|
|
103
|
+
return;
|
|
104
|
+
const reply = queue.length === 1 ? await this.request({ t: "flash", ...queue[0].item }) : await this.request({ t: "batch", items: queue.map((q) => q.item) });
|
|
105
|
+
if (reply.t === "batch_result")
|
|
106
|
+
reply.results.forEach((r, i) => queue[i].resolve(r));
|
|
107
|
+
else
|
|
108
|
+
queue.forEach((q) => q.resolve(reply));
|
|
109
|
+
if (this.outbox.length && this.online) {
|
|
110
|
+
this.outbox = [];
|
|
111
|
+
void this.storage?.save({ outbox: [] });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { ClientConnection } from "./client-connection.js";
|
|
2
|
+
import type { Storage } from "./persist.js";
|
|
3
|
+
import { type InputOf, type ModelOf, type ProjectActions, type TableActionsOf, type TableHandle, type TableName, type Tables } from "./table.js";
|
|
4
|
+
import type { Transport } from "./transport.js";
|
|
5
|
+
import type { FieldMeta, Hydrated, SchemaMeta, SchemaTypes } from "./types.js";
|
|
6
|
+
export interface VantageOptions {
|
|
7
|
+
/** The `schema` const from vantage.gen.ts. */
|
|
8
|
+
schema: SchemaMeta;
|
|
9
|
+
/** `ws(s)://host/v1/ws`; defaults to the page's origin, and is required outside a browser. */
|
|
10
|
+
url?: string;
|
|
11
|
+
/** Sent in `hello`: a token, or a function returning one, called on every connect. */
|
|
12
|
+
token?: string | (() => string | undefined | Promise<string | undefined>);
|
|
13
|
+
/** Offline mirror of `persist: true` tables (see ./persist). */
|
|
14
|
+
storage?: Storage;
|
|
15
|
+
/** Swap the WebSocket (tests use the MockServer). */
|
|
16
|
+
transport?: Transport;
|
|
17
|
+
/** How long an unread record or collection is kept after nothing shows it. Default 30 s. */
|
|
18
|
+
keepAliveMs?: number;
|
|
19
|
+
/** The server's frontend was rebuilt (`{t:"reload"}`). Default: `location.reload()` in a browser, nothing elsewhere. */
|
|
20
|
+
onReload?: () => void;
|
|
21
|
+
}
|
|
22
|
+
export type VantageDb<S extends SchemaTypes> = Vantage<S> & Tables<S>;
|
|
23
|
+
export declare function createVantage<S extends SchemaTypes>(options: VantageOptions): VantageDb<S>;
|
|
24
|
+
export declare class Vantage<S extends SchemaTypes = SchemaTypes> extends ClientConnection {
|
|
25
|
+
/** `db.run.magic_promotion.start({ bakery })`: project actions, as runs. */
|
|
26
|
+
readonly run: ProjectActions<S>;
|
|
27
|
+
constructor(options: VantageOptions);
|
|
28
|
+
table<T extends TableName<S>>(name: T): TableHandle<ModelOf<S, T>, InputOf<S, T>, TableActionsOf<S, T>>;
|
|
29
|
+
/** Run loads ahead of navigation; resolves when they're done. */
|
|
30
|
+
preload(fn: () => unknown): Promise<void>;
|
|
31
|
+
}
|
|
32
|
+
export type { FieldMeta, Hydrated, ProjectActions, TableHandle, Tables };
|