@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.
Files changed (88) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +131 -2
  3. package/bin/vantage-client.js +5 -0
  4. package/dist/client-connection.d.ts +41 -0
  5. package/dist/client-connection.js +260 -0
  6. package/dist/client-core.d.ts +70 -0
  7. package/dist/client-core.js +140 -0
  8. package/dist/client-loading.d.ts +13 -0
  9. package/dist/client-loading.js +229 -0
  10. package/dist/client-runs.d.ts +18 -0
  11. package/dist/client-runs.js +88 -0
  12. package/dist/client-writes.d.ts +14 -0
  13. package/dist/client-writes.js +114 -0
  14. package/dist/client.d.ts +32 -0
  15. package/dist/client.js +61 -0
  16. package/dist/codegen/cli.d.ts +8 -0
  17. package/dist/codegen/cli.js +54 -0
  18. package/dist/codegen/emit.d.ts +3 -0
  19. package/dist/codegen/emit.js +190 -0
  20. package/dist/codegen/fetch.d.ts +4 -0
  21. package/dist/codegen/fetch.js +41 -0
  22. package/dist/codegen/meta.d.ts +13 -0
  23. package/dist/codegen/meta.js +59 -0
  24. package/dist/codegen/runs.d.ts +20 -0
  25. package/dist/codegen/runs.js +58 -0
  26. package/dist/codegen/schema.d.ts +97 -0
  27. package/dist/codegen/schema.js +23 -0
  28. package/dist/collection-cell.d.ts +69 -0
  29. package/dist/collection-cell.js +153 -0
  30. package/dist/collection.d.ts +32 -0
  31. package/dist/collection.js +122 -0
  32. package/dist/index.d.ts +8 -0
  33. package/dist/index.js +14 -0
  34. package/dist/mock-query.d.ts +9 -0
  35. package/dist/mock-query.js +75 -0
  36. package/dist/mock-runs.d.ts +61 -0
  37. package/dist/mock-runs.js +165 -0
  38. package/dist/mock-store.d.ts +78 -0
  39. package/dist/mock-store.js +158 -0
  40. package/dist/mock-transport.d.ts +16 -0
  41. package/dist/mock-transport.js +38 -0
  42. package/dist/model.d.ts +5 -0
  43. package/dist/model.js +169 -0
  44. package/dist/persist.d.ts +47 -0
  45. package/dist/persist.js +108 -0
  46. package/dist/protocol.d.ts +236 -0
  47. package/dist/protocol.js +5 -0
  48. package/dist/react.d.ts +59 -0
  49. package/dist/react.js +169 -0
  50. package/dist/reserved.d.ts +6 -0
  51. package/dist/reserved.js +13 -0
  52. package/dist/run/frames.d.ts +6 -0
  53. package/dist/run/frames.js +24 -0
  54. package/dist/run/hooks.d.ts +18 -0
  55. package/dist/run/hooks.js +87 -0
  56. package/dist/run/messages.d.ts +20 -0
  57. package/dist/run/messages.js +42 -0
  58. package/dist/run/outcome.d.ts +12 -0
  59. package/dist/run/outcome.js +27 -0
  60. package/dist/run/run.d.ts +82 -0
  61. package/dist/run/run.js +246 -0
  62. package/dist/run/storage.d.ts +3 -0
  63. package/dist/run/storage.js +20 -0
  64. package/dist/run/types.d.ts +92 -0
  65. package/dist/run/types.js +3 -0
  66. package/dist/run/variables.d.ts +26 -0
  67. package/dist/run/variables.js +70 -0
  68. package/dist/schema-meta.d.ts +91 -0
  69. package/dist/schema-meta.js +3 -0
  70. package/dist/signal.d.ts +45 -0
  71. package/dist/signal.js +163 -0
  72. package/dist/store.d.ts +45 -0
  73. package/dist/store.js +144 -0
  74. package/dist/table.d.ts +62 -0
  75. package/dist/table.js +82 -0
  76. package/dist/testing.d.ts +61 -0
  77. package/dist/testing.js +195 -0
  78. package/dist/transport.d.ts +28 -0
  79. package/dist/transport.js +32 -0
  80. package/dist/types.d.ts +159 -0
  81. package/dist/types.js +2 -0
  82. package/dist/util.d.ts +18 -0
  83. package/dist/util.js +46 -0
  84. package/package.json +56 -4
  85. package/skills/vantage-client/SKILL.md +390 -0
  86. package/skills/vantage-client/references/protocol.md +67 -0
  87. package/skills/vantage-client/references/react.md +242 -0
  88. package/skills/vantage-client/references/testing.md +173 -0
package/dist/table.js ADDED
@@ -0,0 +1,82 @@
1
+ // The typed table API: `db.order.where(…)`, `db.order.ref(id)`, and the
2
+ // references that point at one record.
3
+ import { toColumns } from "./util.js";
4
+ export class Table {
5
+ db;
6
+ name;
7
+ meta;
8
+ actions;
9
+ constructor(db, name, meta) {
10
+ this.db = db;
11
+ this.name = name;
12
+ this.meta = meta;
13
+ this.actions = new Proxy({}, { get: (_, action) => (input) => db.invoke(`${name}.${action}`, undefined, input ?? {}) });
14
+ }
15
+ get all() {
16
+ return this.db.collection({ table: this.name });
17
+ }
18
+ get(id) {
19
+ return this.ref(id).value;
20
+ }
21
+ ref(id) {
22
+ return this.db.ref(this.name, () => id);
23
+ }
24
+ where(conditions) {
25
+ return this.all.where(conditions);
26
+ }
27
+ sort(column, direction = "asc") {
28
+ return this.all.sort(column, direction);
29
+ }
30
+ search(text) {
31
+ return this.all.search(text);
32
+ }
33
+ create(input) {
34
+ return this.db.write(this.name, undefined, toColumns(this.meta, input));
35
+ }
36
+ }
37
+ export class Reference {
38
+ db;
39
+ table;
40
+ idFn;
41
+ constructor(db, table, idFn) {
42
+ this.db = db;
43
+ this.table = table;
44
+ this.idFn = idFn;
45
+ }
46
+ get id() {
47
+ return this.idFn();
48
+ }
49
+ get value() {
50
+ const id = this.id;
51
+ if (!id)
52
+ return undefined;
53
+ const cell = this.db.store.cell(this.table, id);
54
+ cell.touched = Date.now();
55
+ const presence = cell.presence.get();
56
+ if (presence === "none")
57
+ void this.db.requestRecords(this.table, [id]);
58
+ if (presence === "loaded" && cell.reload) {
59
+ cell.reload = false;
60
+ void this.db.reloadRecords(this.table, [id]);
61
+ }
62
+ return presence === "loaded" ? this.db.model(this.table, id) : undefined;
63
+ }
64
+ get status() {
65
+ const id = this.id;
66
+ return id ? this.db.store.cell(this.table, id).presence.get() : "none";
67
+ }
68
+ get error() {
69
+ const id = this.id;
70
+ return id ? this.db.store.cell(this.table, id).loadError.get() : null;
71
+ }
72
+ async load() {
73
+ const id = this.id;
74
+ if (!id)
75
+ return undefined;
76
+ await this.db.requestRecords(this.table, [id]);
77
+ return this.db.store.cell(this.table, id).presence.peek() === "loaded" ? this.db.model(this.table, id) : undefined;
78
+ }
79
+ then(onfulfilled, onrejected) {
80
+ return this.load().then(onfulfilled, onrejected);
81
+ }
82
+ }
@@ -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
+ }
@@ -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
+ }
@@ -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
@@ -0,0 +1,2 @@
1
+ // Public types the generated `vantage.gen.ts` builds on.
2
+ export {};
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
+ }