@vantage-ui/client 0.0.0-stage → 0.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +46 -0
- package/bin/vantage-client.js +5 -0
- package/dist/client-connection.d.ts +41 -0
- package/dist/client-connection.js +260 -0
- package/dist/client-core.d.ts +70 -0
- package/dist/client-core.js +140 -0
- package/dist/client-loading.d.ts +13 -0
- package/dist/client-loading.js +229 -0
- package/dist/client-runs.d.ts +18 -0
- package/dist/client-runs.js +88 -0
- package/dist/client-writes.d.ts +14 -0
- package/dist/client-writes.js +114 -0
- package/dist/client.d.ts +32 -0
- package/dist/client.js +61 -0
- package/dist/codegen/cli.d.ts +8 -0
- package/dist/codegen/cli.js +54 -0
- package/dist/codegen/emit.d.ts +3 -0
- package/dist/codegen/emit.js +190 -0
- package/dist/codegen/fetch.d.ts +4 -0
- package/dist/codegen/fetch.js +41 -0
- package/dist/codegen/meta.d.ts +13 -0
- package/dist/codegen/meta.js +59 -0
- package/dist/codegen/runs.d.ts +20 -0
- package/dist/codegen/runs.js +58 -0
- package/dist/codegen/schema.d.ts +97 -0
- package/dist/codegen/schema.js +23 -0
- package/dist/collection-cell.d.ts +69 -0
- package/dist/collection-cell.js +153 -0
- package/dist/collection.d.ts +32 -0
- package/dist/collection.js +122 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +14 -0
- package/dist/mock-query.d.ts +9 -0
- package/dist/mock-query.js +75 -0
- package/dist/mock-runs.d.ts +61 -0
- package/dist/mock-runs.js +165 -0
- package/dist/mock-store.d.ts +78 -0
- package/dist/mock-store.js +158 -0
- package/dist/mock-transport.d.ts +16 -0
- package/dist/mock-transport.js +38 -0
- package/dist/model.d.ts +5 -0
- package/dist/model.js +169 -0
- package/dist/persist.d.ts +47 -0
- package/dist/persist.js +108 -0
- package/dist/protocol.d.ts +236 -0
- package/dist/protocol.js +5 -0
- package/dist/react.d.ts +59 -0
- package/dist/react.js +169 -0
- package/dist/reserved.d.ts +6 -0
- package/dist/reserved.js +13 -0
- package/dist/run/frames.d.ts +6 -0
- package/dist/run/frames.js +24 -0
- package/dist/run/hooks.d.ts +18 -0
- package/dist/run/hooks.js +87 -0
- package/dist/run/messages.d.ts +20 -0
- package/dist/run/messages.js +42 -0
- package/dist/run/outcome.d.ts +12 -0
- package/dist/run/outcome.js +27 -0
- package/dist/run/run.d.ts +82 -0
- package/dist/run/run.js +246 -0
- package/dist/run/storage.d.ts +3 -0
- package/dist/run/storage.js +20 -0
- package/dist/run/types.d.ts +92 -0
- package/dist/run/types.js +3 -0
- package/dist/run/variables.d.ts +26 -0
- package/dist/run/variables.js +70 -0
- package/dist/schema-meta.d.ts +91 -0
- package/dist/schema-meta.js +3 -0
- package/dist/signal.d.ts +45 -0
- package/dist/signal.js +163 -0
- package/dist/store.d.ts +45 -0
- package/dist/store.js +144 -0
- package/dist/table.d.ts +62 -0
- package/dist/table.js +82 -0
- package/dist/testing.d.ts +61 -0
- package/dist/testing.js +195 -0
- package/dist/transport.d.ts +28 -0
- package/dist/transport.js +32 -0
- package/dist/types.d.ts +159 -0
- package/dist/types.js +2 -0
- package/dist/util.d.ts +18 -0
- package/dist/util.js +46 -0
- package/package.json +54 -4
- package/skills/vantage-client/SKILL.md +390 -0
- package/skills/vantage-client/references/protocol.md +67 -0
- package/skills/vantage-client/references/react.md +242 -0
- package/skills/vantage-client/references/testing.md +173 -0
- package/README.md +0 -3
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// The shared cell behind every Collection with the same description: the
|
|
2
|
+
// server's ordered ids for it, its load state, the sub that keeps it live,
|
|
3
|
+
// and the `window().whenLoaded()` promises waiting on its rows.
|
|
4
|
+
import { State } from "./signal.js";
|
|
5
|
+
/** One key per description, whatever order its parts were set in. */
|
|
6
|
+
export function specKey({ table, where, search, sort, via, limit, $window }) {
|
|
7
|
+
return JSON.stringify({ table, where: where?.length ? where : undefined, search: search || undefined, sort, via, limit, $window });
|
|
8
|
+
}
|
|
9
|
+
/** Shared state behind every Collection with the same description. Not exported from the package. */
|
|
10
|
+
export class CollectionCell {
|
|
11
|
+
db;
|
|
12
|
+
spec;
|
|
13
|
+
key;
|
|
14
|
+
ids;
|
|
15
|
+
loadState = new State("idle");
|
|
16
|
+
total = new State(null);
|
|
17
|
+
error = new State(null);
|
|
18
|
+
sub = null;
|
|
19
|
+
start = 0;
|
|
20
|
+
pinned = false;
|
|
21
|
+
/** The server refused to open it; reads don't retry until a resync. */
|
|
22
|
+
refused = false;
|
|
23
|
+
touched = Date.now();
|
|
24
|
+
wantedWindow;
|
|
25
|
+
opening;
|
|
26
|
+
settled;
|
|
27
|
+
firstLoad;
|
|
28
|
+
modelsCache;
|
|
29
|
+
windowWaiters = [];
|
|
30
|
+
constructor(db, spec, key) {
|
|
31
|
+
this.db = db;
|
|
32
|
+
this.spec = spec;
|
|
33
|
+
this.key = key;
|
|
34
|
+
this.ids = new State([], { observed: () => this.read() });
|
|
35
|
+
this.firstLoad = new Promise((resolve) => (this.settled = resolve));
|
|
36
|
+
}
|
|
37
|
+
/** Something read us: open if closed. After a final refusal of the connection, settle with its message. */
|
|
38
|
+
read() {
|
|
39
|
+
this.touched = Date.now();
|
|
40
|
+
if (this.sub !== null || this.refused)
|
|
41
|
+
return;
|
|
42
|
+
const halted = this.db.haltedWith;
|
|
43
|
+
if (halted !== undefined)
|
|
44
|
+
return this.failed(halted);
|
|
45
|
+
if (this.db.connection.peek().status === "open") {
|
|
46
|
+
if (this.loadState.peek() === "idle")
|
|
47
|
+
this.loadState.set("loading");
|
|
48
|
+
void this.open();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/** Open on the server, unless an open is already on its way. The ids shown so far stay until the reply. */
|
|
52
|
+
open() {
|
|
53
|
+
if (!this.opening) {
|
|
54
|
+
const opening = this.db.openCollection(this).finally(() => {
|
|
55
|
+
if (this.opening === opening)
|
|
56
|
+
this.opening = undefined;
|
|
57
|
+
});
|
|
58
|
+
this.opening = opening;
|
|
59
|
+
}
|
|
60
|
+
return this.opening;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* The server dropped every sub (a resync): forget the open in flight, so
|
|
64
|
+
* the next one goes out, and a refusal, so `whenLoaded()` waits for the reopen.
|
|
65
|
+
*/
|
|
66
|
+
reset() {
|
|
67
|
+
this.sub = null;
|
|
68
|
+
this.opening = undefined;
|
|
69
|
+
if (!this.refused)
|
|
70
|
+
return;
|
|
71
|
+
this.refused = false;
|
|
72
|
+
this.error.set(null);
|
|
73
|
+
this.loadState.set("loading");
|
|
74
|
+
}
|
|
75
|
+
/** Scrolled away from the start: the first reply's ids aren't the ones on screen. */
|
|
76
|
+
get scrolled() {
|
|
77
|
+
return !!this.wantedWindow && this.wantedWindow.start !== 0;
|
|
78
|
+
}
|
|
79
|
+
wanted() {
|
|
80
|
+
return this.ids.observed || this.loadState.observed || Date.now() - this.touched < this.db.keepAliveMs;
|
|
81
|
+
}
|
|
82
|
+
opened(sub) {
|
|
83
|
+
this.sub = sub;
|
|
84
|
+
this.refused = false;
|
|
85
|
+
this.error.set(null);
|
|
86
|
+
}
|
|
87
|
+
loaded(sub, start, ids, total, complete) {
|
|
88
|
+
this.opened(sub);
|
|
89
|
+
this.members(start, ids, total, complete);
|
|
90
|
+
}
|
|
91
|
+
members(start, ids, total, complete) {
|
|
92
|
+
this.start = start;
|
|
93
|
+
this.ids.set(ids);
|
|
94
|
+
this.total.set(total);
|
|
95
|
+
this.loadState.set(complete ? "complete" : "partial");
|
|
96
|
+
this.settled?.();
|
|
97
|
+
this.wakeWindows();
|
|
98
|
+
}
|
|
99
|
+
failed(message) {
|
|
100
|
+
this.refused = true;
|
|
101
|
+
this.error.set(message);
|
|
102
|
+
this.loadState.set("error");
|
|
103
|
+
this.settled?.();
|
|
104
|
+
this.wakeWindows();
|
|
105
|
+
}
|
|
106
|
+
/** Resolves once rows `start..start+count` have arrived, or the cell failed (refused, or the connection halted). */
|
|
107
|
+
whenWindow(start, count) {
|
|
108
|
+
this.read();
|
|
109
|
+
if (this.windowSettled(start, count))
|
|
110
|
+
return Promise.resolve();
|
|
111
|
+
return new Promise((resolve) => this.windowWaiters.push({ start, count, resolve }));
|
|
112
|
+
}
|
|
113
|
+
/** Whether a `whenLoaded()` is still waiting for its rows. */
|
|
114
|
+
get waitingForWindow() {
|
|
115
|
+
return this.windowWaiters.length > 0;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Failed, or the rows from `start` arrived: `count` of them, or fewer when
|
|
119
|
+
* the frame said `complete`. An estimated `total` never ends the window
|
|
120
|
+
* early. Reads state without tracking it.
|
|
121
|
+
*/
|
|
122
|
+
windowSettled(start, count) {
|
|
123
|
+
const state = this.loadState.peek();
|
|
124
|
+
if (state === "error")
|
|
125
|
+
return true;
|
|
126
|
+
if ((state !== "complete" && state !== "partial") || this.start !== start)
|
|
127
|
+
return false;
|
|
128
|
+
return state === "complete" || this.ids.peek().length >= count;
|
|
129
|
+
}
|
|
130
|
+
/** Resolve every `whenWindow()` whose rows are now here. */
|
|
131
|
+
wakeWindows() {
|
|
132
|
+
const waiting = this.windowWaiters;
|
|
133
|
+
this.windowWaiters = waiting.filter((w) => !this.windowSettled(w.start, w.count));
|
|
134
|
+
for (const w of waiting)
|
|
135
|
+
if (this.windowSettled(w.start, w.count))
|
|
136
|
+
w.resolve();
|
|
137
|
+
}
|
|
138
|
+
drop(id) {
|
|
139
|
+
const ids = this.ids.peek();
|
|
140
|
+
if (ids.includes(id))
|
|
141
|
+
this.ids.set(ids.filter((x) => x !== id));
|
|
142
|
+
}
|
|
143
|
+
whenLoaded() {
|
|
144
|
+
this.read();
|
|
145
|
+
return this.firstLoad;
|
|
146
|
+
}
|
|
147
|
+
models() {
|
|
148
|
+
const ids = this.ids.get();
|
|
149
|
+
if (this.modelsCache?.ids !== ids)
|
|
150
|
+
this.modelsCache = { ids, models: ids.map((id) => this.db.model(this.spec.table, id)) };
|
|
151
|
+
return this.modelsCache.models;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { CollectionSpec, Where } from "./protocol.js";
|
|
2
|
+
import type { CollectionWindow, LazyCollection, LoadState, Model } from "./types.js";
|
|
3
|
+
import type { Vantage } from "./client.js";
|
|
4
|
+
type AnyModel = Model & Record<string, any>;
|
|
5
|
+
export declare class Collection implements LazyCollection<AnyModel> {
|
|
6
|
+
private db;
|
|
7
|
+
readonly spec: CollectionSpec;
|
|
8
|
+
/** When it was made; a new one is kept for a while before anything reads it. */
|
|
9
|
+
readonly created: number;
|
|
10
|
+
private cells;
|
|
11
|
+
constructor(db: Vantage<any>, spec: CollectionSpec);
|
|
12
|
+
/** The keys of the cells it has read (its own, and its windows'). */
|
|
13
|
+
cellKeys(): string[];
|
|
14
|
+
private cellFor;
|
|
15
|
+
private get cell();
|
|
16
|
+
get elements(): readonly AnyModel[];
|
|
17
|
+
get length(): number;
|
|
18
|
+
get loadState(): LoadState;
|
|
19
|
+
get total(): number | null;
|
|
20
|
+
get totalExact(): boolean;
|
|
21
|
+
get error(): string | null;
|
|
22
|
+
map<U>(fn: (item: AnyModel, index: number) => U): U[];
|
|
23
|
+
forEach(fn: (item: AnyModel, index: number) => void): void;
|
|
24
|
+
[Symbol.iterator](): ArrayIterator<AnyModel>;
|
|
25
|
+
where(conditions: Partial<Record<string, unknown>> | Where[]): LazyCollection<AnyModel>;
|
|
26
|
+
sort(column: string, direction?: "asc" | "desc"): LazyCollection<AnyModel>;
|
|
27
|
+
search(text: string): LazyCollection<AnyModel>;
|
|
28
|
+
limit(rows: number): LazyCollection<AnyModel>;
|
|
29
|
+
window(start: number, count: number): CollectionWindow<AnyModel>;
|
|
30
|
+
load(): Promise<LazyCollection<AnyModel>>;
|
|
31
|
+
}
|
|
32
|
+
export {};
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// Collections: a description (table, where, sort, search, via, window) read
|
|
2
|
+
// through the shared CollectionCell that holds the server's ids for it.
|
|
3
|
+
// `db.collection(spec)` keeps one Collection per description.
|
|
4
|
+
export class Collection {
|
|
5
|
+
db;
|
|
6
|
+
spec;
|
|
7
|
+
/** When it was made; a new one is kept for a while before anything reads it. */
|
|
8
|
+
created = Date.now();
|
|
9
|
+
cells = new Set();
|
|
10
|
+
constructor(db, spec) {
|
|
11
|
+
this.db = db;
|
|
12
|
+
this.spec = spec;
|
|
13
|
+
}
|
|
14
|
+
/** The keys of the cells it has read (its own, and its windows'). */
|
|
15
|
+
cellKeys() {
|
|
16
|
+
return [...this.cells];
|
|
17
|
+
}
|
|
18
|
+
cellFor(spec) {
|
|
19
|
+
const cell = this.db.collectionCell(spec);
|
|
20
|
+
this.cells.add(cell.key);
|
|
21
|
+
return cell;
|
|
22
|
+
}
|
|
23
|
+
get cell() {
|
|
24
|
+
const cell = this.cellFor(this.spec);
|
|
25
|
+
cell.read();
|
|
26
|
+
return cell;
|
|
27
|
+
}
|
|
28
|
+
get elements() {
|
|
29
|
+
return this.cell.models();
|
|
30
|
+
}
|
|
31
|
+
get length() {
|
|
32
|
+
const cell = this.cell;
|
|
33
|
+
const ids = cell.ids.get();
|
|
34
|
+
return cell.loadState.get() === "complete" ? ids.length : (cell.total.get() ?? ids.length);
|
|
35
|
+
}
|
|
36
|
+
get loadState() {
|
|
37
|
+
return this.cell.loadState.get();
|
|
38
|
+
}
|
|
39
|
+
get total() {
|
|
40
|
+
return this.cell.total.get();
|
|
41
|
+
}
|
|
42
|
+
get totalExact() {
|
|
43
|
+
const cell = this.cell;
|
|
44
|
+
return cell.loadState.get() === "complete" && cell.total.get() !== null;
|
|
45
|
+
}
|
|
46
|
+
get error() {
|
|
47
|
+
return this.cell.error.get();
|
|
48
|
+
}
|
|
49
|
+
map(fn) {
|
|
50
|
+
return this.elements.map(fn);
|
|
51
|
+
}
|
|
52
|
+
forEach(fn) {
|
|
53
|
+
this.elements.forEach(fn);
|
|
54
|
+
}
|
|
55
|
+
[Symbol.iterator]() {
|
|
56
|
+
return this.elements[Symbol.iterator]();
|
|
57
|
+
}
|
|
58
|
+
where(conditions) {
|
|
59
|
+
const meta = this.db.meta(this.spec.table);
|
|
60
|
+
const added = Array.isArray(conditions)
|
|
61
|
+
? conditions
|
|
62
|
+
: Object.entries(conditions).map(([k, v]) => {
|
|
63
|
+
const column = meta.references[k]?.column ?? k;
|
|
64
|
+
if (Array.isArray(v))
|
|
65
|
+
return [column, "in", v];
|
|
66
|
+
if (v && typeof v === "object" && !Array.isArray(v)) {
|
|
67
|
+
const [op, operand] = Object.entries(v)[0];
|
|
68
|
+
return [column, op, operand];
|
|
69
|
+
}
|
|
70
|
+
return [column, "eq", v];
|
|
71
|
+
});
|
|
72
|
+
return this.db.collection({ ...this.spec, where: [...(this.spec.where ?? []), ...added] });
|
|
73
|
+
}
|
|
74
|
+
sort(column, direction = "asc") {
|
|
75
|
+
return this.db.collection({ ...this.spec, sort: [column, direction] });
|
|
76
|
+
}
|
|
77
|
+
search(text) {
|
|
78
|
+
return this.db.collection({ ...this.spec, search: text || undefined });
|
|
79
|
+
}
|
|
80
|
+
limit(rows) {
|
|
81
|
+
return this.db.collection({ ...this.spec, limit: rows });
|
|
82
|
+
}
|
|
83
|
+
window(start, count) {
|
|
84
|
+
const cell = this.cellFor({ ...this.spec, limit: count, $window: true });
|
|
85
|
+
const moved = !cell.wantedWindow || cell.wantedWindow.start !== start || cell.wantedWindow.count !== count;
|
|
86
|
+
cell.wantedWindow = { start, count };
|
|
87
|
+
cell.read();
|
|
88
|
+
if (moved && cell.sub !== null)
|
|
89
|
+
this.db.moveWindow(cell);
|
|
90
|
+
const ids = cell.ids.get();
|
|
91
|
+
const loadState = cell.loadState.get();
|
|
92
|
+
const total = cell.total.get();
|
|
93
|
+
const error = cell.error.get();
|
|
94
|
+
const items = Array.from({ length: count }, (_, i) => {
|
|
95
|
+
const at = start + i - cell.start;
|
|
96
|
+
const id = at >= 0 && at < ids.length ? ids[at] : undefined;
|
|
97
|
+
return id ? this.db.model(this.spec.table, id) : undefined;
|
|
98
|
+
});
|
|
99
|
+
const gap = items.indexOf(undefined);
|
|
100
|
+
const end = start + (gap === -1 ? count : gap); // rows arrived from `start` without a gap
|
|
101
|
+
const settled = cell.windowSettled(start, count);
|
|
102
|
+
// The last `rows`/`members` frame said `complete`: the server sent every row.
|
|
103
|
+
const totalExact = loadState === "complete" && total !== null;
|
|
104
|
+
return {
|
|
105
|
+
start,
|
|
106
|
+
count,
|
|
107
|
+
items,
|
|
108
|
+
at: (index) => items[index - start],
|
|
109
|
+
error,
|
|
110
|
+
total,
|
|
111
|
+
totalExact,
|
|
112
|
+
settled,
|
|
113
|
+
length: total ?? end,
|
|
114
|
+
lengthExact: total !== null ? totalExact : settled && error === null && (loadState === "complete" || end < start + count),
|
|
115
|
+
whenLoaded: () => cell.whenWindow(start, count).then(() => this.window(start, count)),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
async load() {
|
|
119
|
+
await this.cellFor(this.spec).whenLoaded();
|
|
120
|
+
return this;
|
|
121
|
+
}
|
|
122
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { createVantage } from "./client.js";
|
|
2
|
+
export type { Vantage, VantageDb, VantageOptions, TableHandle, Tables, ProjectActions } from "./client.js";
|
|
3
|
+
export { State, batch, autorun } from "./signal.js";
|
|
4
|
+
export type { Transport } from "./transport.js";
|
|
5
|
+
export { MemoryStorage, IndexedDBStorage, type Storage } from "./persist.js";
|
|
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";
|
|
8
|
+
export type { Run, RunError, RunFactory, RunMessageOf, RunStatus, RunTypes, StartOptions } from "./run/types.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// @vantage-ui/client — a vantage-ui project as a typed, live object graph.
|
|
2
|
+
//
|
|
3
|
+
// import { createVantage } from "@vantage-ui/client";
|
|
4
|
+
// import { schema, type Schema } from "./vantage.gen";
|
|
5
|
+
//
|
|
6
|
+
// const db = createVantage<Schema>({ schema, token }); // ws(s)://<page host>/v1/ws
|
|
7
|
+
// db.order.sort("created_at", "desc").map((o) => o.client.value?.name);
|
|
8
|
+
// await order.cancel_order({ reason: "out_of_stock" });
|
|
9
|
+
// const run = db.run.restock.start({ bakery }); // run.state, run.on(…), await run.done
|
|
10
|
+
//
|
|
11
|
+
// React bindings: "@vantage-ui/client/react". Tests: "@vantage-ui/client/testing".
|
|
12
|
+
export { createVantage } from "./client.js";
|
|
13
|
+
export { State, batch, autorun } from "./signal.js";
|
|
14
|
+
export { MemoryStorage, IndexedDBStorage } from "./persist.js";
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { CollectionSpec } from "./protocol.js";
|
|
2
|
+
import type { SchemaMeta } from "./types.js";
|
|
3
|
+
export type Row = Record<string, unknown> & {
|
|
4
|
+
id: string;
|
|
5
|
+
};
|
|
6
|
+
/** The ids of `rows` that `spec` selects, in its order. */
|
|
7
|
+
export declare function query(schema: SchemaMeta, rows: Row[], spec: CollectionSpec): string[];
|
|
8
|
+
/** A minimal schema for one table whose columns are the keys of `rows`. */
|
|
9
|
+
export declare function tableSchema(table: string, rows: Row[]): SchemaMeta;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// How the MockServer answers a collection: filter, traverse, search and
|
|
2
|
+
// sort like vantage-server does.
|
|
3
|
+
/** The ids of `rows` that `spec` selects, in its order. */
|
|
4
|
+
export function query(schema, rows, spec) {
|
|
5
|
+
for (const w of spec.where ?? [])
|
|
6
|
+
rows = rows.filter((r) => matches(r[w[0]], w));
|
|
7
|
+
if (spec.via) {
|
|
8
|
+
const fk = schema.tables[spec.via.table]?.hasMany[spec.via.ref]?.foreignKey;
|
|
9
|
+
rows = rows.filter((r) => fk && r[fk] === spec.via.id);
|
|
10
|
+
}
|
|
11
|
+
if (spec.search) {
|
|
12
|
+
const q = spec.search.toLowerCase();
|
|
13
|
+
rows = rows.filter((r) => Object.values(r).some((v) => String(v ?? "").toLowerCase().includes(q)));
|
|
14
|
+
}
|
|
15
|
+
if (spec.sort) {
|
|
16
|
+
const [col, dir] = spec.sort;
|
|
17
|
+
rows = [...rows].sort((a, b) => (dir === "desc" ? -1 : 1) * compare(a[col], b[col]));
|
|
18
|
+
}
|
|
19
|
+
return rows.map((r) => r.id);
|
|
20
|
+
}
|
|
21
|
+
function matches(value, [, op, operand]) {
|
|
22
|
+
switch (op) {
|
|
23
|
+
case "eq":
|
|
24
|
+
return value === operand;
|
|
25
|
+
case "ne":
|
|
26
|
+
return value !== operand;
|
|
27
|
+
case "gt":
|
|
28
|
+
return compare(value, operand) > 0;
|
|
29
|
+
case "gte":
|
|
30
|
+
return compare(value, operand) >= 0;
|
|
31
|
+
case "lt":
|
|
32
|
+
return compare(value, operand) < 0;
|
|
33
|
+
case "lte":
|
|
34
|
+
return compare(value, operand) <= 0;
|
|
35
|
+
case "in":
|
|
36
|
+
return Array.isArray(operand) && operand.includes(value);
|
|
37
|
+
case "not_in":
|
|
38
|
+
return Array.isArray(operand) && !operand.includes(value);
|
|
39
|
+
case "like":
|
|
40
|
+
return String(value ?? "").toLowerCase().includes(String(operand).toLowerCase());
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function compare(a, b) {
|
|
44
|
+
if (a == null)
|
|
45
|
+
return b == null ? 0 : 1;
|
|
46
|
+
if (b == null)
|
|
47
|
+
return -1;
|
|
48
|
+
if (typeof a === "number" && typeof b === "number")
|
|
49
|
+
return a - b;
|
|
50
|
+
return String(a).localeCompare(String(b));
|
|
51
|
+
}
|
|
52
|
+
/** A minimal schema for one table whose columns are the keys of `rows`. */
|
|
53
|
+
export function tableSchema(table, rows) {
|
|
54
|
+
const columns = Object.fromEntries([...new Set(["id", ...rows.flatMap((r) => Object.keys(r))])].map((c) => [c, { type: "string" }]));
|
|
55
|
+
return {
|
|
56
|
+
hash: "mock",
|
|
57
|
+
tables: {
|
|
58
|
+
[table]: {
|
|
59
|
+
key: table,
|
|
60
|
+
load: "lazy",
|
|
61
|
+
persist: false,
|
|
62
|
+
live: "push",
|
|
63
|
+
readOnly: false,
|
|
64
|
+
idColumn: "id",
|
|
65
|
+
title: null,
|
|
66
|
+
collectionLimit: 500,
|
|
67
|
+
columns,
|
|
68
|
+
references: {},
|
|
69
|
+
hasMany: {},
|
|
70
|
+
actions: {},
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
actions: {},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { MockTransport } from "./mock-transport.js";
|
|
2
|
+
import type { ClientFrame, Json, ServerFrame } from "./protocol.js";
|
|
3
|
+
import type { RunMeta } from "./schema-meta.js";
|
|
4
|
+
/** What a handler's run can do, like a body's run vocabulary. */
|
|
5
|
+
export interface MockRunControl {
|
|
6
|
+
readonly id: string;
|
|
7
|
+
get(name: string): unknown;
|
|
8
|
+
/** `state.<name> = value`: a patch to every follower. */
|
|
9
|
+
set(name: string, value: unknown): void;
|
|
10
|
+
/** Several variables in one patch, as the server's throttle coalesces them. */
|
|
11
|
+
update(values: Record<string, unknown>): void;
|
|
12
|
+
emit(name: string, payload?: unknown): void;
|
|
13
|
+
/** `notify(text)`: the `vantage.notify` message. */
|
|
14
|
+
notify(text: string): void;
|
|
15
|
+
/** Resolves once `state.<name>` is truthy; rejects when the run is cancelled. */
|
|
16
|
+
wait(name: string): Promise<unknown>;
|
|
17
|
+
readonly cancelled: boolean;
|
|
18
|
+
}
|
|
19
|
+
type Status = "running" | "done" | "failed" | "cancelled";
|
|
20
|
+
type Invoke = Extract<ClientFrame, {
|
|
21
|
+
t: "invoke";
|
|
22
|
+
}>;
|
|
23
|
+
export declare class MockRun implements MockRunControl {
|
|
24
|
+
readonly id: string;
|
|
25
|
+
readonly action: string;
|
|
26
|
+
readonly rid: string | undefined;
|
|
27
|
+
readonly args: Record<string, unknown>;
|
|
28
|
+
readonly meta: RunMeta;
|
|
29
|
+
status: Status;
|
|
30
|
+
result: unknown;
|
|
31
|
+
error: string | undefined;
|
|
32
|
+
readonly state: Record<string, unknown>;
|
|
33
|
+
readonly messages: {
|
|
34
|
+
name: string;
|
|
35
|
+
payload: Json;
|
|
36
|
+
seq: number;
|
|
37
|
+
}[];
|
|
38
|
+
private seq;
|
|
39
|
+
private followers;
|
|
40
|
+
private waiters;
|
|
41
|
+
constructor(id: string, action: string, rid: string | undefined, args: Record<string, unknown>, meta: RunMeta);
|
|
42
|
+
get cancelled(): boolean;
|
|
43
|
+
get finished(): boolean;
|
|
44
|
+
get(name: string): unknown;
|
|
45
|
+
set(name: string, value: unknown): void;
|
|
46
|
+
update(values: Record<string, unknown>): void;
|
|
47
|
+
emit(name: string, payload?: unknown): void;
|
|
48
|
+
notify(text: string): void;
|
|
49
|
+
wait(name: string): Promise<unknown>;
|
|
50
|
+
private outcome;
|
|
51
|
+
private send;
|
|
52
|
+
}
|
|
53
|
+
/** The answer to `invoke`, as vantage-server gives it. `start` runs a new run's handler. */
|
|
54
|
+
export declare function invokeRun(runs: Map<string, MockRun>, client: MockTransport, id: number, frame: Invoke, start: (frame: Invoke & {
|
|
55
|
+
action: string;
|
|
56
|
+
}) => MockRun | ServerFrame): void;
|
|
57
|
+
/** `run_set`: inputs only, of their type. */
|
|
58
|
+
export declare function setInput(run: MockRun | undefined, id: number, frame: Extract<ClientFrame, {
|
|
59
|
+
t: "run_set";
|
|
60
|
+
}>): ServerFrame;
|
|
61
|
+
export {};
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// The MockServer's action runs: `invoke` starts or attaches, the handler
|
|
2
|
+
// runs with a control object standing in for a Rhai body (`state`, `wait`,
|
|
3
|
+
// `emit`, `notify`), and every follower gets `run` frames as vantage-server
|
|
4
|
+
// sends them: a snapshot first, then one change per frame.
|
|
5
|
+
export class MockRun {
|
|
6
|
+
id;
|
|
7
|
+
action;
|
|
8
|
+
rid;
|
|
9
|
+
args;
|
|
10
|
+
meta;
|
|
11
|
+
status = "running";
|
|
12
|
+
result = undefined;
|
|
13
|
+
error;
|
|
14
|
+
state = {};
|
|
15
|
+
messages = [];
|
|
16
|
+
seq = 0;
|
|
17
|
+
followers = new Set();
|
|
18
|
+
waiters = [];
|
|
19
|
+
constructor(id, action, rid, args, meta) {
|
|
20
|
+
this.id = id;
|
|
21
|
+
this.action = action;
|
|
22
|
+
this.rid = rid;
|
|
23
|
+
this.args = args;
|
|
24
|
+
this.meta = meta;
|
|
25
|
+
for (const [name, info] of Object.entries(meta.state ?? {}))
|
|
26
|
+
this.state[name] = info.default;
|
|
27
|
+
}
|
|
28
|
+
get cancelled() {
|
|
29
|
+
return this.status === "cancelled";
|
|
30
|
+
}
|
|
31
|
+
get finished() {
|
|
32
|
+
return this.status !== "running";
|
|
33
|
+
}
|
|
34
|
+
get(name) {
|
|
35
|
+
return this.state[name];
|
|
36
|
+
}
|
|
37
|
+
set(name, value) {
|
|
38
|
+
this.update({ [name]: value });
|
|
39
|
+
}
|
|
40
|
+
update(values) {
|
|
41
|
+
if (this.finished)
|
|
42
|
+
throw new Error(this.cancelled ? "cancelled" : "the run has finished");
|
|
43
|
+
Object.assign(this.state, values);
|
|
44
|
+
this.send({ state: values });
|
|
45
|
+
for (const w of this.waiters.filter((w) => values[w.name])) {
|
|
46
|
+
this.waiters.splice(this.waiters.indexOf(w), 1);
|
|
47
|
+
w.resolve(values[w.name]);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
emit(name, payload = null) {
|
|
51
|
+
if (this.finished)
|
|
52
|
+
throw new Error("the run has finished");
|
|
53
|
+
// `send` gives the frame the next seq; the message keeps it, as the server's does.
|
|
54
|
+
const message = { name, payload: payload, seq: this.seq + 1 };
|
|
55
|
+
this.messages.push(message);
|
|
56
|
+
if (this.messages.length > 100)
|
|
57
|
+
this.messages.shift();
|
|
58
|
+
this.send({ messages: [message] });
|
|
59
|
+
}
|
|
60
|
+
notify(text) {
|
|
61
|
+
this.emit("vantage.notify", { text });
|
|
62
|
+
}
|
|
63
|
+
wait(name) {
|
|
64
|
+
if (this.cancelled)
|
|
65
|
+
return Promise.reject(new Error("cancelled"));
|
|
66
|
+
if (this.state[name])
|
|
67
|
+
return Promise.resolve(this.state[name]);
|
|
68
|
+
return new Promise((resolve, reject) => this.waiters.push({ name, resolve, reject }));
|
|
69
|
+
}
|
|
70
|
+
/** @internal Follow on `client`: the snapshot now, changes after. */
|
|
71
|
+
follow(client) {
|
|
72
|
+
this.followers.add(client);
|
|
73
|
+
client.deliver({ t: "run", ...this.snapshot() });
|
|
74
|
+
}
|
|
75
|
+
/** @internal */ unfollow(client) {
|
|
76
|
+
this.followers.delete(client);
|
|
77
|
+
}
|
|
78
|
+
/** @internal The snapshot for a client that only reads the outcome. */
|
|
79
|
+
snapshot() {
|
|
80
|
+
return { run: this.id, seq: this.seq, status: "running", state: { ...this.state }, messages: [...this.messages], ...this.outcome() };
|
|
81
|
+
}
|
|
82
|
+
/** @internal */ cancel() {
|
|
83
|
+
if (this.finished)
|
|
84
|
+
return;
|
|
85
|
+
this.end("cancelled");
|
|
86
|
+
for (const w of this.waiters.splice(0))
|
|
87
|
+
w.reject(new Error("cancelled"));
|
|
88
|
+
}
|
|
89
|
+
/** @internal */ end(status, result, error) {
|
|
90
|
+
if (this.finished)
|
|
91
|
+
return;
|
|
92
|
+
this.status = status;
|
|
93
|
+
this.result = result;
|
|
94
|
+
this.error = error;
|
|
95
|
+
this.send(this.outcome());
|
|
96
|
+
}
|
|
97
|
+
outcome() {
|
|
98
|
+
if (this.status === "done")
|
|
99
|
+
return { status: "done", result: (this.result ?? null) };
|
|
100
|
+
if (this.status === "failed")
|
|
101
|
+
return { status: "failed", error: { code: "failed", message: this.error ?? "failed" } };
|
|
102
|
+
if (this.status === "cancelled")
|
|
103
|
+
return { status: "cancelled" };
|
|
104
|
+
return {};
|
|
105
|
+
}
|
|
106
|
+
send(change) {
|
|
107
|
+
this.seq++;
|
|
108
|
+
for (const client of this.followers)
|
|
109
|
+
client.deliver({ t: "run", run: this.id, seq: this.seq, ...change });
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/** The answer to `invoke`, as vantage-server gives it. `start` runs a new run's handler. */
|
|
113
|
+
export function invokeRun(runs, client, id, frame, start) {
|
|
114
|
+
const reply = (f) => client.deliver(f);
|
|
115
|
+
const existing = runs.get(frame.run);
|
|
116
|
+
if (!frame.action || existing) {
|
|
117
|
+
if (!existing)
|
|
118
|
+
return reply({ t: "error", id, kind: "forbidden", code: "forbidden", message: `no run \`${frame.run}\`` });
|
|
119
|
+
const same = !frame.action || (existing.action === frame.action && existing.rid === frame.rid && JSON.stringify(existing.args) === JSON.stringify(frame.args ?? {}));
|
|
120
|
+
if (!same)
|
|
121
|
+
return reply({ t: "error", id, kind: "conflict", code: "conflict", message: `run \`${frame.run}\` was started with another action, record or args` });
|
|
122
|
+
reply({ t: "result", id, run: frame.run, status: existing.finished ? "done" : "attached" });
|
|
123
|
+
if (existing.finished)
|
|
124
|
+
return client.deliver({ t: "run", ...existing.snapshot() });
|
|
125
|
+
return existing.follow(client);
|
|
126
|
+
}
|
|
127
|
+
const started = start(frame);
|
|
128
|
+
if (!(started instanceof MockRun))
|
|
129
|
+
return reply({ ...started, id });
|
|
130
|
+
runs.set(frame.run, started);
|
|
131
|
+
reply({ t: "result", id, run: frame.run, status: "started" });
|
|
132
|
+
started.follow(client);
|
|
133
|
+
}
|
|
134
|
+
/** `run_set`: inputs only, of their type. */
|
|
135
|
+
export function setInput(run, id, frame) {
|
|
136
|
+
if (!run)
|
|
137
|
+
return { t: "error", id, kind: "forbidden", code: "forbidden", message: `no run \`${frame.run}\`` };
|
|
138
|
+
if (run.finished)
|
|
139
|
+
return { t: "error", id, kind: "conflict", code: "conflict", message: "the run has finished" };
|
|
140
|
+
const info = run.meta.state?.[frame.name];
|
|
141
|
+
if (!info?.input)
|
|
142
|
+
return { t: "error", id, kind: "invalid", code: "invalid", message: `\`state.${frame.name}\` is not an input` };
|
|
143
|
+
if (!fits(info.type, frame.value))
|
|
144
|
+
return { t: "error", id, kind: "invalid", code: "invalid", message: `\`state.${frame.name}\` takes a ${info.type}` };
|
|
145
|
+
run.set(frame.name, frame.value);
|
|
146
|
+
return { t: "ack", id };
|
|
147
|
+
}
|
|
148
|
+
function fits(type, value) {
|
|
149
|
+
switch (type) {
|
|
150
|
+
case "string":
|
|
151
|
+
return typeof value === "string";
|
|
152
|
+
case "int":
|
|
153
|
+
return Number.isInteger(value);
|
|
154
|
+
case "float":
|
|
155
|
+
return typeof value === "number";
|
|
156
|
+
case "bool":
|
|
157
|
+
return typeof value === "boolean";
|
|
158
|
+
case "list":
|
|
159
|
+
return Array.isArray(value);
|
|
160
|
+
case "map":
|
|
161
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
162
|
+
default:
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
165
|
+
}
|