@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,61 @@
|
|
|
1
|
+
import { type VantageDb, type VantageOptions } from "./client.js";
|
|
2
|
+
import { MockTransport } from "./mock-transport.js";
|
|
3
|
+
import { type Row } from "./mock-query.js";
|
|
4
|
+
import { type MockRun, type MockRunControl } from "./mock-runs.js";
|
|
5
|
+
import { MockStore, type ActionHandler, type Fixtures } from "./mock-store.js";
|
|
6
|
+
import { type ClientFrame, type CollectionSpec } from "./protocol.js";
|
|
7
|
+
import type { RunTypes } from "./run/types.js";
|
|
8
|
+
import type { Model, SchemaMeta, SchemaTypes } from "./types.js";
|
|
9
|
+
export { MockTransport };
|
|
10
|
+
export type { Row, ActionHandler, Fixtures, MockRun, MockRunControl };
|
|
11
|
+
/** Client options a test may set; the transport is the MockServer's. */
|
|
12
|
+
export type TestOptions = Omit<VantageOptions, "schema" | "transport">;
|
|
13
|
+
export declare class MockServer extends MockStore {
|
|
14
|
+
/** Every `hello` received, in order. */
|
|
15
|
+
readonly hellos: Extract<ClientFrame, {
|
|
16
|
+
t: "hello";
|
|
17
|
+
}>[];
|
|
18
|
+
/** Every collection a client opened (`open_table`), in order, refused ones too. */
|
|
19
|
+
readonly opened: CollectionSpec[];
|
|
20
|
+
private epoch;
|
|
21
|
+
private received;
|
|
22
|
+
private held;
|
|
23
|
+
private refusals;
|
|
24
|
+
private writeRefusal?;
|
|
25
|
+
/** A server with one table, its schema made from the rows' keys. */
|
|
26
|
+
static withTable(table: string, rows: Row[], fixtures?: Omit<Fixtures, "tables">): MockServer;
|
|
27
|
+
transport(): MockTransport;
|
|
28
|
+
/** Reload the API: a new journal, every collection gone, `resync` to every client. */
|
|
29
|
+
resync(): void;
|
|
30
|
+
/** The served frontend was rebuilt: `reload` to every client. */
|
|
31
|
+
reload(): void;
|
|
32
|
+
/** Answer every `op` request with an error carrying `message`. */
|
|
33
|
+
refuse(op: ClientFrame["t"], message: string): void;
|
|
34
|
+
/** Stop refusing `op`. */
|
|
35
|
+
allow(op: ClientFrame["t"]): void;
|
|
36
|
+
/** Refuse every write with `message`, however the client sends it (`flash`, `batch` or `delete`). */
|
|
37
|
+
refuseWrites(message: string): void;
|
|
38
|
+
/** Stop refusing writes. */
|
|
39
|
+
allowWrites(): void;
|
|
40
|
+
/** Keep `op` requests unanswered until `release(op)`. */
|
|
41
|
+
hold(op: ClientFrame["t"]): void;
|
|
42
|
+
release(op: ClientFrame["t"]): void;
|
|
43
|
+
/** How many `op` frames arrived. */
|
|
44
|
+
count(op: ClientFrame["t"]): number;
|
|
45
|
+
private answer;
|
|
46
|
+
}
|
|
47
|
+
/** Types for a client without generated ones: any table name, loosely typed models. */
|
|
48
|
+
export interface UntypedSchema extends SchemaTypes {
|
|
49
|
+
models: Record<string, Model & Record<string, any>>;
|
|
50
|
+
fields: Record<string, Record<string, unknown>>;
|
|
51
|
+
inputs: Record<string, Record<string, unknown>>;
|
|
52
|
+
tableActions: Record<string, Record<string, Record<string, unknown>>>;
|
|
53
|
+
projectActions: Record<string, RunTypes>;
|
|
54
|
+
}
|
|
55
|
+
export declare function createTestVantage<S extends SchemaTypes = UntypedSchema>(schema: SchemaMeta, fixtures?: Fixtures, options?: TestOptions): TestVantage<S>;
|
|
56
|
+
export declare function createTestVantage<S extends SchemaTypes = UntypedSchema>(server: MockServer, options?: TestOptions): TestVantage<S>;
|
|
57
|
+
export interface TestVantage<S extends SchemaTypes> {
|
|
58
|
+
db: VantageDb<S>;
|
|
59
|
+
server: MockServer;
|
|
60
|
+
transport: MockTransport;
|
|
61
|
+
}
|
package/dist/testing.js
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// An in-memory vantage-server for tests and Storybook: the same frames,
|
|
2
|
+
// no network. Collections filter/sort/window like the real one; writes
|
|
3
|
+
// broadcast `changed` and update collection membership.
|
|
4
|
+
//
|
|
5
|
+
// const { db, server } = createTestVantage<Schema>(schema, {
|
|
6
|
+
// tables: { bakery: [{ id: "bakery:leeds", name: "Leeds" }] },
|
|
7
|
+
// actions: { "order.mark_confirmed": (row) => ({ status: "confirmed" }) },
|
|
8
|
+
// });
|
|
9
|
+
// server.upsert("order", { id: "order:9", status: "placed" }); // a "remote" change
|
|
10
|
+
//
|
|
11
|
+
// Without generated types, one table is enough:
|
|
12
|
+
//
|
|
13
|
+
// const server = MockServer.withTable("issues", [{ id: "1", title: "a" }]);
|
|
14
|
+
// const { db } = createTestVantage(server, { token: "test" });
|
|
15
|
+
//
|
|
16
|
+
// Server-side events for tests: `resync()` (an API reload), `reload()` (a
|
|
17
|
+
// frontend rebuild), `refuse(op, msg)`
|
|
18
|
+
// and `refuseWrites(msg)`, `hold(op)`/`release(op)` to keep replies pending.
|
|
19
|
+
// What the client asked for: `opened` (its collections), `hellos`, `count(op)`,
|
|
20
|
+
// and `runs` (every run, with its args and state).
|
|
21
|
+
//
|
|
22
|
+
// Actions run as runs: a handler gets `(row, args, run)` and `run` stands in
|
|
23
|
+
// for the body's vocabulary (`run.set`, `run.wait`, `run.emit`, `run.notify`):
|
|
24
|
+
//
|
|
25
|
+
// actions: { restock: async (_, args, run) => { run.set("total", 3); await run.wait("confirmed"); return "ok"; } }
|
|
26
|
+
import { createVantage } from "./client.js";
|
|
27
|
+
import { MockTransport } from "./mock-transport.js";
|
|
28
|
+
import { tableSchema } from "./mock-query.js";
|
|
29
|
+
import { setInput } from "./mock-runs.js";
|
|
30
|
+
import { MockStore } from "./mock-store.js";
|
|
31
|
+
import { FEATURES, PROTOCOL } from "./protocol.js";
|
|
32
|
+
export { MockTransport };
|
|
33
|
+
/** The frames a write travels in: one edit, several batched in a tick, a delete. */
|
|
34
|
+
const WRITES = new Set(["flash", "batch", "delete"]);
|
|
35
|
+
export class MockServer extends MockStore {
|
|
36
|
+
/** Every `hello` received, in order. */
|
|
37
|
+
hellos = [];
|
|
38
|
+
/** Every collection a client opened (`open_table`), in order, refused ones too. */
|
|
39
|
+
opened = [];
|
|
40
|
+
epoch = 1;
|
|
41
|
+
received = new Map();
|
|
42
|
+
held = new Map();
|
|
43
|
+
refusals = new Map();
|
|
44
|
+
writeRefusal;
|
|
45
|
+
/** A server with one table, its schema made from the rows' keys. */
|
|
46
|
+
static withTable(table, rows, fixtures = {}) {
|
|
47
|
+
return new MockServer(tableSchema(table, rows), { ...fixtures, tables: { [table]: rows } });
|
|
48
|
+
}
|
|
49
|
+
transport() {
|
|
50
|
+
return new MockTransport(this);
|
|
51
|
+
}
|
|
52
|
+
/** Reload the API: a new journal, every collection gone, `resync` to every client. */
|
|
53
|
+
resync() {
|
|
54
|
+
this.epoch++;
|
|
55
|
+
this.seq = 0;
|
|
56
|
+
this.subs.clear();
|
|
57
|
+
for (const client of this.clients)
|
|
58
|
+
client.deliver({ t: "resync", epoch: `mock-${this.epoch}`, seq: this.seq, schema_hash: this.schema.hash });
|
|
59
|
+
}
|
|
60
|
+
/** The served frontend was rebuilt: `reload` to every client. */
|
|
61
|
+
reload() {
|
|
62
|
+
for (const client of this.clients)
|
|
63
|
+
client.deliver({ t: "reload" });
|
|
64
|
+
}
|
|
65
|
+
/** Answer every `op` request with an error carrying `message`. */
|
|
66
|
+
refuse(op, message) {
|
|
67
|
+
this.refusals.set(op, message);
|
|
68
|
+
}
|
|
69
|
+
/** Stop refusing `op`. */
|
|
70
|
+
allow(op) {
|
|
71
|
+
this.refusals.delete(op);
|
|
72
|
+
}
|
|
73
|
+
/** Refuse every write with `message`, however the client sends it (`flash`, `batch` or `delete`). */
|
|
74
|
+
refuseWrites(message) {
|
|
75
|
+
this.writeRefusal = message;
|
|
76
|
+
}
|
|
77
|
+
/** Stop refusing writes. */
|
|
78
|
+
allowWrites() {
|
|
79
|
+
this.writeRefusal = undefined;
|
|
80
|
+
}
|
|
81
|
+
/** Keep `op` requests unanswered until `release(op)`. */
|
|
82
|
+
hold(op) {
|
|
83
|
+
if (!this.held.has(op))
|
|
84
|
+
this.held.set(op, []);
|
|
85
|
+
}
|
|
86
|
+
release(op) {
|
|
87
|
+
const waiting = this.held.get(op) ?? [];
|
|
88
|
+
this.held.delete(op);
|
|
89
|
+
for (const [client, frame] of waiting)
|
|
90
|
+
this.answer(client, frame);
|
|
91
|
+
}
|
|
92
|
+
/** How many `op` frames arrived. */
|
|
93
|
+
count(op) {
|
|
94
|
+
return this.received.get(op) ?? 0;
|
|
95
|
+
}
|
|
96
|
+
/** @internal */ handle(client, frame) {
|
|
97
|
+
this.received.set(frame.t, this.count(frame.t) + 1);
|
|
98
|
+
if (frame.t === "open_table") {
|
|
99
|
+
const { t: _, id: __, ...spec } = frame;
|
|
100
|
+
this.opened.push(spec);
|
|
101
|
+
}
|
|
102
|
+
const refusal = this.refusals.get(frame.t) ?? (WRITES.has(frame.t) ? this.writeRefusal : undefined);
|
|
103
|
+
if (refusal !== undefined)
|
|
104
|
+
return client.deliver({ t: "error", id: frame.id ?? 0, kind: "invalid", message: refusal });
|
|
105
|
+
const held = this.held.get(frame.t);
|
|
106
|
+
if (held)
|
|
107
|
+
return void held.push([client, frame]);
|
|
108
|
+
this.answer(client, frame);
|
|
109
|
+
}
|
|
110
|
+
answer(client, frame) {
|
|
111
|
+
const id = frame.id ?? 0;
|
|
112
|
+
const reply = (f) => client.deliver(f);
|
|
113
|
+
switch (frame.t) {
|
|
114
|
+
case "hello":
|
|
115
|
+
this.hellos.push(frame);
|
|
116
|
+
if (frame.protocol !== undefined && frame.protocol !== (this.fixtures.protocol ?? PROTOCOL)) {
|
|
117
|
+
const speaks = this.fixtures.protocol ?? PROTOCOL;
|
|
118
|
+
reply({ t: "error", id, kind: "invalid", code: "protocol", message: `protocol ${frame.protocol} is not supported; this server speaks protocol ${speaks}` });
|
|
119
|
+
// The close follows the error, as vantage-server's writer sends it.
|
|
120
|
+
return void setTimeout(() => client.drop(1002, "unsupported protocol"), 0);
|
|
121
|
+
}
|
|
122
|
+
if (this.fixtures.refuseHello)
|
|
123
|
+
return reply({ t: "error", id, kind: "forbidden", message: this.fixtures.refuseHello });
|
|
124
|
+
return reply({
|
|
125
|
+
t: "welcome",
|
|
126
|
+
id,
|
|
127
|
+
principal: "test",
|
|
128
|
+
epoch: `mock-${this.epoch}`,
|
|
129
|
+
seq: this.seq,
|
|
130
|
+
schema_hash: this.schema.hash,
|
|
131
|
+
resync: true,
|
|
132
|
+
protocol: this.fixtures.protocol ?? PROTOCOL,
|
|
133
|
+
features: (this.fixtures.features ?? FEATURES).filter((f) => !frame.features || frame.features.includes(f)),
|
|
134
|
+
});
|
|
135
|
+
case "open_table": {
|
|
136
|
+
const { t: _, id: __, ...spec } = frame;
|
|
137
|
+
const ids = this.query(spec);
|
|
138
|
+
const limit = spec.limit ?? this.schema.tables[spec.table]?.collectionLimit ?? 500;
|
|
139
|
+
const sub = this.nextSub++;
|
|
140
|
+
const window = ids.slice(0, limit);
|
|
141
|
+
this.subs.set(sub, { spec: { ...spec, limit }, client, ids: window });
|
|
142
|
+
return reply({ t: "rows", id, sub, table: spec.table, start: 0, ids: window, records: this.records(spec.table, window), total: ids.length, complete: ids.length <= limit, seq: this.seq });
|
|
143
|
+
}
|
|
144
|
+
case "viewport": {
|
|
145
|
+
const s = this.subs.get(frame.sub);
|
|
146
|
+
if (!s)
|
|
147
|
+
return reply({ t: "error", id, kind: "not_found", message: "no sub" });
|
|
148
|
+
const all = this.query(s.spec);
|
|
149
|
+
const ids = all.slice(frame.start, frame.start + frame.count);
|
|
150
|
+
s.ids = ids;
|
|
151
|
+
return reply({ t: "members", id, sub: frame.sub, start: frame.start, ids, records: this.records(s.spec.table, ids), total: all.length, complete: true });
|
|
152
|
+
}
|
|
153
|
+
case "close":
|
|
154
|
+
this.subs.delete(frame.sub);
|
|
155
|
+
return;
|
|
156
|
+
case "open_records": {
|
|
157
|
+
const rows = frame.ids.map((i) => this.table(frame.table).get(i));
|
|
158
|
+
return reply({ t: "records", id, table: frame.table, records: rows.filter(Boolean).map((r) => this.wire(frame.table, r)), missing: frame.ids.filter((_, i) => !rows[i]) });
|
|
159
|
+
}
|
|
160
|
+
case "flash":
|
|
161
|
+
return reply(this.flash(id, frame));
|
|
162
|
+
case "batch":
|
|
163
|
+
return reply({ t: "batch_result", id, results: frame.items.map((item, i) => this.flash(i, item)) });
|
|
164
|
+
case "delete":
|
|
165
|
+
if (!this.table(frame.table).has(frame.rid))
|
|
166
|
+
return reply({ t: "rejected", id, message: "no such record" });
|
|
167
|
+
this.remove(frame.table, frame.rid);
|
|
168
|
+
return reply({ t: "flashed", id, rid: frame.rid, seq: this.seq, record: null });
|
|
169
|
+
case "invoke":
|
|
170
|
+
return this.invoke(client, id, frame);
|
|
171
|
+
case "options":
|
|
172
|
+
return reply({ t: "options_result", id, items: [] });
|
|
173
|
+
case "run_set":
|
|
174
|
+
return reply(setInput(this.runs.get(frame.run), id, frame));
|
|
175
|
+
case "run_cancel": {
|
|
176
|
+
const run = this.runs.get(frame.run);
|
|
177
|
+
if (!run)
|
|
178
|
+
return reply({ t: "error", id, kind: "forbidden", code: "forbidden", message: `no run \`${frame.run}\`` });
|
|
179
|
+
run.cancel();
|
|
180
|
+
return reply({ t: "ack", id });
|
|
181
|
+
}
|
|
182
|
+
case "interest":
|
|
183
|
+
return;
|
|
184
|
+
case "ping":
|
|
185
|
+
return reply({ t: "pong", id });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
export function createTestVantage(from, a, b) {
|
|
190
|
+
const server = from instanceof MockServer ? from : new MockServer(from, a);
|
|
191
|
+
const options = (from instanceof MockServer ? a : b);
|
|
192
|
+
const transport = server.transport();
|
|
193
|
+
const db = createVantage({ ...options, schema: server.schema, transport });
|
|
194
|
+
return { db, server, transport };
|
|
195
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { ClientFrame, ServerFrame } from "./protocol.js";
|
|
2
|
+
export interface TransportHandlers {
|
|
3
|
+
open(): void;
|
|
4
|
+
message(frame: ServerFrame): void;
|
|
5
|
+
/** The WebSocket close code and reason, when the transport has them. */
|
|
6
|
+
close(code?: number, reason?: string): void;
|
|
7
|
+
}
|
|
8
|
+
export interface Transport {
|
|
9
|
+
connect(handlers: TransportHandlers): void;
|
|
10
|
+
send(frame: ClientFrame & {
|
|
11
|
+
id?: number;
|
|
12
|
+
}): void;
|
|
13
|
+
readonly ready: boolean;
|
|
14
|
+
close(): void;
|
|
15
|
+
}
|
|
16
|
+
export declare class WebSocketTransport implements Transport {
|
|
17
|
+
readonly url: string;
|
|
18
|
+
private ws?;
|
|
19
|
+
constructor(url: string);
|
|
20
|
+
get ready(): boolean;
|
|
21
|
+
connect(handlers: TransportHandlers): void;
|
|
22
|
+
send(frame: ClientFrame & {
|
|
23
|
+
id?: number;
|
|
24
|
+
}): void;
|
|
25
|
+
close(): void;
|
|
26
|
+
}
|
|
27
|
+
/** `ws(s)://<host>/v1/ws` on the page's origin. */
|
|
28
|
+
export declare function defaultUrl(loc?: Pick<Location, "protocol" | "host"> | undefined): string;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// How frames travel. The client speaks to a Transport; tests swap in the
|
|
2
|
+
// in-memory MockServer (./testing) for the WebSocket.
|
|
3
|
+
export class WebSocketTransport {
|
|
4
|
+
url;
|
|
5
|
+
ws;
|
|
6
|
+
constructor(url) {
|
|
7
|
+
this.url = url;
|
|
8
|
+
}
|
|
9
|
+
get ready() {
|
|
10
|
+
return this.ws?.readyState === WebSocket.OPEN;
|
|
11
|
+
}
|
|
12
|
+
connect(handlers) {
|
|
13
|
+
const ws = new WebSocket(this.url);
|
|
14
|
+
this.ws = ws;
|
|
15
|
+
ws.onopen = () => handlers.open();
|
|
16
|
+
ws.onmessage = (m) => handlers.message(JSON.parse(String(m.data)));
|
|
17
|
+
ws.onclose = (e) => handlers.close(e.code, e.reason);
|
|
18
|
+
}
|
|
19
|
+
send(frame) {
|
|
20
|
+
if (this.ready)
|
|
21
|
+
this.ws.send(JSON.stringify(frame));
|
|
22
|
+
}
|
|
23
|
+
close() {
|
|
24
|
+
this.ws?.close();
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/** `ws(s)://<host>/v1/ws` on the page's origin. */
|
|
28
|
+
export function defaultUrl(loc = globalThis.location) {
|
|
29
|
+
if (!loc)
|
|
30
|
+
throw new Error("@vantage-ui/client: url is required outside a browser");
|
|
31
|
+
return `${loc.protocol === "https:" ? "wss" : "ws"}://${loc.host}/v1/ws`;
|
|
32
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import type { ErrorKind, Sort, Where } from "./protocol.js";
|
|
2
|
+
import type { Presence, RowStatus } from "./store.js";
|
|
3
|
+
export type { RowStatus, Presence };
|
|
4
|
+
export type { ActionMeta, FieldMeta, RunMeta, SchemaMeta, SchemaTypes, TableMeta } from "./schema-meta.js";
|
|
5
|
+
export type ActionError = {
|
|
6
|
+
kind: ErrorKind;
|
|
7
|
+
message: string;
|
|
8
|
+
fieldErrors: Record<string, string>;
|
|
9
|
+
};
|
|
10
|
+
export type ActionResult<R> = {
|
|
11
|
+
ok: true;
|
|
12
|
+
value: R;
|
|
13
|
+
} | {
|
|
14
|
+
ok: false;
|
|
15
|
+
error: ActionError;
|
|
16
|
+
};
|
|
17
|
+
export type LoadState = "idle" | "loading" | "partial" | "complete" | "error";
|
|
18
|
+
/** What every generated model has. `F` is the table's generated `Fields` (the record as data). */
|
|
19
|
+
export interface Model<T extends string = string, F extends object = Record<string, unknown>> {
|
|
20
|
+
readonly $table: T;
|
|
21
|
+
readonly id: string;
|
|
22
|
+
/** `fresh` · `stale` (from the offline mirror) · `pending` (edit in flight) · `failed`. */
|
|
23
|
+
readonly $status: RowStatus;
|
|
24
|
+
/** The title column's value, else the id. */
|
|
25
|
+
readonly $title: string;
|
|
26
|
+
/** Field errors from the last rejected edit. */
|
|
27
|
+
readonly $errors: Readonly<Record<string, string>>;
|
|
28
|
+
readonly $actions: readonly string[];
|
|
29
|
+
/** A run factory per row action: `issue.$run.close_issue.start(input)`. */
|
|
30
|
+
readonly $run: object;
|
|
31
|
+
/** A draft: set fields, then `$flash()`. */
|
|
32
|
+
edit(): Servo<this, F>;
|
|
33
|
+
/** Optimistic one-shot edit. */
|
|
34
|
+
patch(fields: Partial<WritableFields<F>>): Promise<ActionResult<this>>;
|
|
35
|
+
delete(): Promise<ActionResult<void>>;
|
|
36
|
+
/** Load the named references/collections; resolves to a `Hydrated` model. */
|
|
37
|
+
hydrate<K extends RelationKeys<this>>(keys?: readonly K[]): Promise<Hydrated<this, K>>;
|
|
38
|
+
}
|
|
39
|
+
/** A pointer to a record that may not be loaded yet. Reading `.value` starts loading. */
|
|
40
|
+
export interface LazyReference<T> extends PromiseLike<T | undefined> {
|
|
41
|
+
readonly id: string | null;
|
|
42
|
+
readonly value: T | undefined;
|
|
43
|
+
readonly status: Presence;
|
|
44
|
+
/** The server's message when it refused to load the record (`status` is `error`). */
|
|
45
|
+
readonly error: string | null;
|
|
46
|
+
load(): Promise<T | undefined>;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* An ordered set of records the server keeps current. Reading it starts
|
|
50
|
+
* loading. The server filters, sorts and limits it (`where`, `sort`,
|
|
51
|
+
* `search`, `limit`); there is no client-side `filter` or `find`. The same
|
|
52
|
+
* description gives the same object, so a render may build it inline.
|
|
53
|
+
*/
|
|
54
|
+
export interface LazyCollection<T> extends Iterable<T> {
|
|
55
|
+
readonly elements: readonly T[];
|
|
56
|
+
/** Exact when complete, else the server's estimate. */
|
|
57
|
+
readonly length: number;
|
|
58
|
+
readonly loadState: LoadState;
|
|
59
|
+
/** Rows in the whole collection; null until known. See `totalExact`. */
|
|
60
|
+
readonly total: number | null;
|
|
61
|
+
/** Whether `total` is the real row count: the server has sent the whole collection. */
|
|
62
|
+
readonly totalExact: boolean;
|
|
63
|
+
/** The server's message when it refused to open the collection; a refusal is not retried. */
|
|
64
|
+
readonly error: string | null;
|
|
65
|
+
map<U>(fn: (item: T, index: number) => U): U[];
|
|
66
|
+
forEach(fn: (item: T, index: number) => void): void;
|
|
67
|
+
where(conditions: Partial<Record<string, unknown>> | Where[]): LazyCollection<T>;
|
|
68
|
+
sort(column: string, direction?: Sort[1]): LazyCollection<T>;
|
|
69
|
+
search(text: string): LazyCollection<T>;
|
|
70
|
+
limit(rows: number): LazyCollection<T>;
|
|
71
|
+
/** Rows `start..start+count`, for virtualised lists. */
|
|
72
|
+
window(start: number, count: number): CollectionWindow<T>;
|
|
73
|
+
/** Resolves once the first load is done. */
|
|
74
|
+
load(): Promise<LazyCollection<T>>;
|
|
75
|
+
}
|
|
76
|
+
export interface CollectionWindow<T> {
|
|
77
|
+
readonly start: number;
|
|
78
|
+
readonly count: number;
|
|
79
|
+
/** The row at absolute `index`, or undefined while it loads. */
|
|
80
|
+
at(index: number): T | undefined;
|
|
81
|
+
readonly items: readonly (T | undefined)[];
|
|
82
|
+
/** The server's message when it refused to open the collection. */
|
|
83
|
+
readonly error: string | null;
|
|
84
|
+
/** Rows in the whole collection, for sizing a scrollbar; null until known. See `totalExact`. */
|
|
85
|
+
readonly total: number | null;
|
|
86
|
+
/**
|
|
87
|
+
* Whether `total` is the real row count. The server counts exactly once it
|
|
88
|
+
* has sent the whole collection; before that, `total` is its estimate, fine
|
|
89
|
+
* for a scrollbar but not to show as a count.
|
|
90
|
+
*/
|
|
91
|
+
readonly totalExact: boolean;
|
|
92
|
+
/**
|
|
93
|
+
* The rows from `start` have arrived (`count` of them, or all there are),
|
|
94
|
+
* or the window failed with `error`: what `whenLoaded()` waits for.
|
|
95
|
+
*/
|
|
96
|
+
readonly settled: boolean;
|
|
97
|
+
/**
|
|
98
|
+
* The list's length, for sizing a virtual list: `total` when the server
|
|
99
|
+
* sends one; otherwise as far as rows have arrived without a gap.
|
|
100
|
+
*/
|
|
101
|
+
readonly length: number;
|
|
102
|
+
/**
|
|
103
|
+
* Whether `length` is the real count: `totalExact` when there is a
|
|
104
|
+
* `total`; otherwise once a settled window came back short of `count`.
|
|
105
|
+
*/
|
|
106
|
+
readonly lengthExact: boolean;
|
|
107
|
+
/**
|
|
108
|
+
* Resolves with this window once its rows have arrived; settles with
|
|
109
|
+
* `error` set when the server refuses it or the connection halts.
|
|
110
|
+
*/
|
|
111
|
+
whenLoaded(): Promise<CollectionWindow<T>>;
|
|
112
|
+
}
|
|
113
|
+
export type RelationKeys<M> = {
|
|
114
|
+
[K in keyof M]: M[K] extends LazyReference<any> | LazyCollection<any> ? K : never;
|
|
115
|
+
}[keyof M] & string;
|
|
116
|
+
/** `M` with the references in `K` known to be loaded. */
|
|
117
|
+
export type Hydrated<M, K extends keyof M = RelationKeys<M>> = M & {
|
|
118
|
+
readonly [P in K]: M[P] extends LazyReference<infer U> ? LazyReference<U> & {
|
|
119
|
+
readonly value: U;
|
|
120
|
+
} : M[P];
|
|
121
|
+
};
|
|
122
|
+
type ReadonlyKeys<T> = {
|
|
123
|
+
[P in keyof T]-?: Equals<{
|
|
124
|
+
[Q in P]: T[P];
|
|
125
|
+
}, {
|
|
126
|
+
-readonly [Q in P]: T[P];
|
|
127
|
+
}> extends true ? never : P;
|
|
128
|
+
}[keyof T];
|
|
129
|
+
type Equals<X, Y> = (<T>() => T extends X ? 1 : 2) extends <T>() => T extends Y ? 1 : 2 ? true : false;
|
|
130
|
+
/** The fields an edit may set: `F` without its readonly ones (`client.name`, expressions, `id`). */
|
|
131
|
+
export type WritableFields<F> = {
|
|
132
|
+
-readonly [P in Exclude<keyof F, ReadonlyKeys<F> | "id">]: F[P];
|
|
133
|
+
};
|
|
134
|
+
/**
|
|
135
|
+
* An edit draft (vantage's Servo). Set fields, then `$flash()`. References
|
|
136
|
+
* are set by id. Everything the draft adds is `$`-prefixed, so any column
|
|
137
|
+
* (`status`, `flash`, …) stays editable.
|
|
138
|
+
*/
|
|
139
|
+
export type Servo<M, F extends object = Record<string, unknown>> = WritableFields<F> & {
|
|
140
|
+
readonly $model: M;
|
|
141
|
+
readonly $isDirty: boolean;
|
|
142
|
+
readonly $status: "tracking" | "pending" | "failed";
|
|
143
|
+
readonly $errors: Readonly<Record<string, string>>;
|
|
144
|
+
$dirty(field: keyof WritableFields<F> & string): boolean;
|
|
145
|
+
$revert(field?: keyof WritableFields<F> & string): void;
|
|
146
|
+
$flash(): Promise<ActionResult<M>>;
|
|
147
|
+
};
|
|
148
|
+
/**
|
|
149
|
+
* `open` once `hello` is welcomed; `closed` while reconnecting or after
|
|
150
|
+
* `close()`. `protocol_mismatch` and `refused` are final: the client stops
|
|
151
|
+
* reconnecting, and `error` holds the server's message.
|
|
152
|
+
*/
|
|
153
|
+
export type ConnectionStatus = "connecting" | "open" | "closed" | "protocol_mismatch" | "refused";
|
|
154
|
+
export interface Connection {
|
|
155
|
+
readonly status: ConnectionStatus;
|
|
156
|
+
/** The status is final (`refused` or `protocol_mismatch`): the client stopped for good. */
|
|
157
|
+
readonly halted: boolean;
|
|
158
|
+
readonly error?: string;
|
|
159
|
+
}
|
package/dist/types.js
ADDED
package/dist/util.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ErrorKind, ServerFrame } from "./protocol.js";
|
|
2
|
+
import type { ActionError, TableMeta } from "./types.js";
|
|
3
|
+
/** Relation names → their columns (`bakery` → `bakery_id`). */
|
|
4
|
+
export declare function toColumns(meta: TableMeta, fields: Record<string, unknown>): Record<string, unknown>;
|
|
5
|
+
export declare function fail(kind: ErrorKind, message: string): {
|
|
6
|
+
ok: false;
|
|
7
|
+
error: ActionError;
|
|
8
|
+
};
|
|
9
|
+
export declare function failFrom(frame: ServerFrame): {
|
|
10
|
+
ok: false;
|
|
11
|
+
error: ActionError;
|
|
12
|
+
};
|
|
13
|
+
export declare function pick(record: Record<string, unknown>, keys: string[]): Record<string, unknown>;
|
|
14
|
+
export declare function sameValue(a: unknown, b: unknown): boolean;
|
|
15
|
+
/** A type name from a table name: `order_line` → `OrderLine`. Separators are dropped. */
|
|
16
|
+
export declare function pascal(name: string): string;
|
|
17
|
+
/** UUIDv7: time-ordered, so client-made ids sort like server ones. */
|
|
18
|
+
export declare function uuidv7(): string;
|
package/dist/util.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Small helpers shared by the client's parts.
|
|
2
|
+
/** Relation names → their columns (`bakery` → `bakery_id`). */
|
|
3
|
+
export function toColumns(meta, fields) {
|
|
4
|
+
const out = {};
|
|
5
|
+
for (const [k, v] of Object.entries(fields))
|
|
6
|
+
out[meta.references[k]?.column ?? k] = v;
|
|
7
|
+
return out;
|
|
8
|
+
}
|
|
9
|
+
export function fail(kind, message) {
|
|
10
|
+
return { ok: false, error: { kind, message, fieldErrors: {} } };
|
|
11
|
+
}
|
|
12
|
+
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 ?? {} } };
|
|
17
|
+
return fail("failed", `unexpected reply \`${frame.t}\``);
|
|
18
|
+
}
|
|
19
|
+
export function pick(record, keys) {
|
|
20
|
+
return Object.fromEntries(keys.filter((k) => k in record).map((k) => [k, record[k]]));
|
|
21
|
+
}
|
|
22
|
+
export function sameValue(a, b) {
|
|
23
|
+
return a === b || JSON.stringify(a) === JSON.stringify(b);
|
|
24
|
+
}
|
|
25
|
+
/** A type name from a table name: `order_line` → `OrderLine`. Separators are dropped. */
|
|
26
|
+
export function pascal(name) {
|
|
27
|
+
return name
|
|
28
|
+
.split(/[^A-Za-z0-9]/)
|
|
29
|
+
.filter((s) => s !== "")
|
|
30
|
+
.map((s) => s[0].toUpperCase() + s.slice(1))
|
|
31
|
+
.join("");
|
|
32
|
+
}
|
|
33
|
+
/** UUIDv7: time-ordered, so client-made ids sort like server ones. */
|
|
34
|
+
export function uuidv7() {
|
|
35
|
+
const bytes = new Uint8Array(16);
|
|
36
|
+
crypto.getRandomValues(bytes);
|
|
37
|
+
let ms = Date.now();
|
|
38
|
+
for (let i = 5; i >= 0; i--) {
|
|
39
|
+
bytes[i] = ms & 0xff;
|
|
40
|
+
ms = Math.floor(ms / 256);
|
|
41
|
+
}
|
|
42
|
+
bytes[6] = (bytes[6] & 0x0f) | 0x70;
|
|
43
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
|
44
|
+
const hex = [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
45
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
46
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,56 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vantage-ui/client",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.4.3",
|
|
4
|
+
"description": "A vantage-ui project as a typed, live object graph: lazy references and collections, optimistic edits, server-side actions, React bindings.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./react": {
|
|
14
|
+
"types": "./dist/react.d.ts",
|
|
15
|
+
"import": "./dist/react.js"
|
|
16
|
+
},
|
|
17
|
+
"./testing": {
|
|
18
|
+
"types": "./dist/testing.d.ts",
|
|
19
|
+
"import": "./dist/testing.js"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"bin": {
|
|
23
|
+
"vantage-client": "bin/vantage-client.js"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"dist",
|
|
27
|
+
"bin",
|
|
28
|
+
"skills",
|
|
29
|
+
"CHANGELOG.md"
|
|
30
|
+
],
|
|
31
|
+
"scripts": {
|
|
32
|
+
"build": "tsc -p tsconfig.build.json",
|
|
33
|
+
"prepack": "npm run build",
|
|
34
|
+
"test": "vitest run",
|
|
35
|
+
"typecheck": "tsc --noEmit"
|
|
36
|
+
},
|
|
37
|
+
"peerDependencies": {
|
|
38
|
+
"react": ">=18"
|
|
39
|
+
},
|
|
40
|
+
"peerDependenciesMeta": {
|
|
41
|
+
"react": {
|
|
42
|
+
"optional": true
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@testing-library/react": "^16.0.0",
|
|
47
|
+
"@types/node": "^22.0.0",
|
|
48
|
+
"@types/react": "^19.0.0",
|
|
49
|
+
"fake-indexeddb": "^6.0.0",
|
|
50
|
+
"happy-dom": "^15.0.0",
|
|
51
|
+
"react": "^19.0.0",
|
|
52
|
+
"react-dom": "^19.0.0",
|
|
53
|
+
"typescript": "^5.6.0",
|
|
54
|
+
"vitest": "^3.0.0"
|
|
55
|
+
}
|
|
56
|
+
}
|