@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
@@ -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
+ }
@@ -0,0 +1,78 @@
1
+ import { type Row } from "./mock-query.js";
2
+ import { MockRun, type MockRunControl } from "./mock-runs.js";
3
+ import type { MockTransport } from "./mock-transport.js";
4
+ import type { ChangeEvent, ClientFrame, CollectionSpec, FlashItem, ServerFrame, WireRecord } from "./protocol.js";
5
+ import type { SchemaMeta } from "./types.js";
6
+ /**
7
+ * An action's body. What it returns (or resolves to) is the run's result; a
8
+ * row action's returned object is also applied to the row as a patch. `run`
9
+ * stands in for the body's run vocabulary: `run.set`, `run.wait`, `run.emit`.
10
+ */
11
+ export type ActionHandler = (row: Row | undefined, args: Record<string, unknown>, run: MockRunControl) => unknown;
12
+ export interface Fixtures {
13
+ tables?: Record<string, Row[]>;
14
+ /**
15
+ * Handlers by the action's wire name: `check_overdue` for the project
16
+ * action `check-overdue`, `issues.close_issue` for a row action. The YAML
17
+ * key with dashes works too; two keys naming the same action throw.
18
+ */
19
+ actions?: Record<string, ActionHandler>;
20
+ /** `when:` stand-ins: which row actions a record offers. */
21
+ available?: Record<string, (row: Row) => string[]>;
22
+ /** Reject writes: return a message (or field errors) to refuse. */
23
+ validate?: (table: string, row: Row) => string | Record<string, string> | void;
24
+ /**
25
+ * The protocol this server speaks (default 1). Like vantage-server, a
26
+ * `hello` naming another gets `error {code: "protocol"}` and a 1002 close.
27
+ */
28
+ protocol?: number;
29
+ /** Refuse every `hello` with this message. */
30
+ refuseHello?: string;
31
+ /**
32
+ * Start an action's handler before the invoking socket follows the run,
33
+ * as vantage-server does: what it sets and sends before its first `await`
34
+ * arrives inside the first snapshot. By default it starts just after.
35
+ */
36
+ eager?: boolean;
37
+ /** The features `welcome` offers (default: all this client knows); `[]` is a server without runs. */
38
+ features?: string[];
39
+ }
40
+ export declare class MockStore {
41
+ readonly schema: SchemaMeta;
42
+ protected readonly fixtures: Fixtures;
43
+ readonly tables: Map<string, Map<string, Row>>;
44
+ /** How many times a client connected. */
45
+ connects: number;
46
+ protected clients: Set<MockTransport>;
47
+ protected seq: number;
48
+ protected nextSub: number;
49
+ /** Every run, by id. */
50
+ readonly runs: Map<string, MockRun>;
51
+ protected subs: Map<number, {
52
+ spec: CollectionSpec;
53
+ client: MockTransport;
54
+ ids: string[];
55
+ }>;
56
+ constructor(schema: SchemaMeta, fixtures?: Fixtures);
57
+ private readonly handlers;
58
+ /** Change a record as if someone else did. */
59
+ upsert(table: string, row: Row): void;
60
+ remove(table: string, id: string): void;
61
+ /** A coarse change: clients re-read every collection on `table`. */
62
+ invalidate(table: string): void;
63
+ /** Replace a table's rows without telling anyone, as a reload might. */
64
+ setRows(table: string, rows: Row[]): void;
65
+ protected broadcast(events: ChangeEvent[], table: string): void;
66
+ protected table(name: string): Map<string, Row>;
67
+ protected available(table: string, row: Row): string[];
68
+ protected wire(table: string, row: Row): WireRecord;
69
+ protected records(table: string, ids: string[]): WireRecord[];
70
+ protected query(spec: CollectionSpec): string[];
71
+ /** Start or attach to a run (see ./mock-runs). */
72
+ protected invoke(client: MockTransport, id: number, frame: Extract<ClientFrame, {
73
+ t: "invoke";
74
+ }>): void;
75
+ private execute;
76
+ /** Apply one write, checked by the fixtures' `validate`. */
77
+ protected flash(id: number, item: FlashItem): ServerFrame;
78
+ }
@@ -0,0 +1,158 @@
1
+ // The MockServer's data side: its tables, the connected clients and their
2
+ // collections, the writes and actions that change the tables, and the
3
+ // `changed`/`members` broadcasts a change causes.
4
+ import { query } from "./mock-query.js";
5
+ import { MockRun, invokeRun } from "./mock-runs.js";
6
+ /**
7
+ * The fixture handlers keyed by wire name. vantage-server names an action
8
+ * on the wire with its key's dashes turned into underscores, so
9
+ * `check-overdue` and `check_overdue` are the same action.
10
+ */
11
+ function handlersByWireName(actions) {
12
+ const handlers = new Map();
13
+ const keys = new Map();
14
+ for (const [key, handler] of Object.entries(actions)) {
15
+ const wire = key.replaceAll("-", "_");
16
+ const other = keys.get(wire);
17
+ if (other !== undefined)
18
+ throw new Error(`MockServer: actions \`${other}\` and \`${key}\` are the same action \`${wire}\``);
19
+ keys.set(wire, key);
20
+ handlers.set(wire, handler);
21
+ }
22
+ return handlers;
23
+ }
24
+ export class MockStore {
25
+ schema;
26
+ fixtures;
27
+ tables = new Map();
28
+ /** How many times a client connected. */
29
+ connects = 0;
30
+ clients = new Set();
31
+ seq = 0;
32
+ nextSub = 1;
33
+ /** Every run, by id. */
34
+ runs = new Map();
35
+ subs = new Map();
36
+ constructor(schema, fixtures = {}) {
37
+ this.schema = schema;
38
+ this.fixtures = fixtures;
39
+ for (const [table, rows] of Object.entries(fixtures.tables ?? {}))
40
+ this.setRows(table, rows);
41
+ this.handlers = handlersByWireName(fixtures.actions ?? {});
42
+ }
43
+ handlers;
44
+ /** Change a record as if someone else did. */
45
+ upsert(table, row) {
46
+ this.table(table).set(row.id, { ...this.table(table).get(row.id), ...row });
47
+ this.broadcast([{ k: "upsert", table, id: row.id, record: this.wire(table, this.table(table).get(row.id)) }], table);
48
+ }
49
+ remove(table, id) {
50
+ this.table(table).delete(id);
51
+ this.broadcast([{ k: "removed", table, id }], table);
52
+ }
53
+ /** A coarse change: clients re-read every collection on `table`. */
54
+ invalidate(table) {
55
+ this.broadcast([{ k: "invalidated", table }], table);
56
+ }
57
+ /** Replace a table's rows without telling anyone, as a reload might. */
58
+ setRows(table, rows) {
59
+ this.tables.set(table, new Map(rows.map((r) => [r.id, { ...r }])));
60
+ }
61
+ /** @internal */ attach(client) {
62
+ this.clients.add(client);
63
+ this.connects++;
64
+ }
65
+ /** @internal */ detach(client) {
66
+ this.clients.delete(client);
67
+ for (const [sub, s] of this.subs)
68
+ if (s.client === client)
69
+ this.subs.delete(sub);
70
+ for (const run of this.runs.values())
71
+ run.unfollow(client);
72
+ }
73
+ broadcast(events, table) {
74
+ this.seq++;
75
+ for (const client of this.clients)
76
+ client.deliver({ t: "changed", seq: this.seq, events });
77
+ for (const [sub, s] of this.subs) {
78
+ if (s.spec.table !== table)
79
+ continue;
80
+ const ids = this.query(s.spec).slice(0, s.spec.limit ?? 500);
81
+ if (ids.join() !== s.ids.join()) {
82
+ const fresh = ids.filter((i) => !s.ids.includes(i));
83
+ s.ids = ids;
84
+ s.client.deliver({ t: "members", sub, start: 0, ids, records: this.records(table, fresh), total: ids.length, complete: true });
85
+ }
86
+ }
87
+ }
88
+ table(name) {
89
+ let t = this.tables.get(name);
90
+ if (!t)
91
+ this.tables.set(name, (t = new Map()));
92
+ return t;
93
+ }
94
+ available(table, row) {
95
+ const fn = this.fixtures.available?.[table];
96
+ if (fn)
97
+ return fn(row);
98
+ return Object.entries(this.schema.tables[table]?.actions ?? {})
99
+ .filter(([, a]) => a.perRow)
100
+ .map(([n]) => n);
101
+ }
102
+ wire(table, row) {
103
+ return { ...row, $actions: this.available(table, row) };
104
+ }
105
+ records(table, ids) {
106
+ return ids.map((i) => this.wire(table, this.table(table).get(i)));
107
+ }
108
+ query(spec) {
109
+ return query(this.schema, [...this.table(spec.table).values()], spec);
110
+ }
111
+ /** Start or attach to a run (see ./mock-runs). */
112
+ invoke(client, id, frame) {
113
+ invokeRun(this.runs, client, id, frame, (frame) => {
114
+ const refuse = (code, message) => ({ t: "error", id, kind: code, code, message });
115
+ const handler = this.handlers.get(frame.action);
116
+ if (!handler)
117
+ return refuse("forbidden", `no action \`${frame.action}\``);
118
+ const [table, name] = frame.action.includes(".") ? frame.action.split(".") : [undefined, frame.action];
119
+ const row = table && frame.rid ? this.table(table).get(frame.rid) : undefined;
120
+ if (table && frame.rid && !row)
121
+ return refuse("forbidden", "no record");
122
+ if (row && table && !this.available(table, row).includes(name))
123
+ return refuse("conflict", "not available for this record");
124
+ const meta = (table ? this.schema.tables[table]?.actions[name] : this.schema.actions[name]) ?? {};
125
+ const run = new MockRun(frame.run, frame.action, frame.rid, frame.args ?? {}, meta);
126
+ if (this.fixtures.eager)
127
+ void this.execute(run, handler, table, row);
128
+ else
129
+ queueMicrotask(() => void this.execute(run, handler, table, row));
130
+ return run;
131
+ });
132
+ }
133
+ async execute(run, handler, table, row) {
134
+ try {
135
+ const out = await handler(row, run.args, run);
136
+ if (row && table && out && typeof out === "object")
137
+ this.upsert(table, { ...row, ...out, id: row.id });
138
+ run.end(run.cancelled ? "cancelled" : "done", out ?? null);
139
+ }
140
+ catch (e) {
141
+ run.end(run.cancelled ? "cancelled" : "failed", undefined, e instanceof Error ? e.message : String(e));
142
+ }
143
+ }
144
+ /** Apply one write, checked by the fixtures' `validate`. */
145
+ flash(id, item) {
146
+ const table = this.table(item.table);
147
+ const rid = item.rid ?? (String(item.patch.id ?? "").includes(":") ? String(item.patch.id) : `${item.table}:${item.patch.id ?? Math.random().toString(36).slice(2)}`);
148
+ if (item.rid && !table.has(rid))
149
+ return { t: "rejected", id, message: "no such record" };
150
+ const next = { ...table.get(rid), ...item.patch, id: rid };
151
+ const invalid = this.fixtures.validate?.(item.table, next);
152
+ if (invalid) {
153
+ return typeof invalid === "string" ? { t: "rejected", id, message: invalid } : { t: "rejected", id, message: "invalid", field_errors: invalid };
154
+ }
155
+ this.upsert(item.table, next);
156
+ return { t: "flashed", id, rid, seq: this.seq, record: this.wire(item.table, next) };
157
+ }
158
+ }
@@ -0,0 +1,16 @@
1
+ import type { ClientFrame } from "./protocol.js";
2
+ import type { Transport, TransportHandlers } from "./transport.js";
3
+ import type { MockServer } from "./testing.js";
4
+ export declare class MockTransport implements Transport {
5
+ private server;
6
+ private handlers?;
7
+ ready: boolean;
8
+ constructor(server: MockServer);
9
+ connect(handlers: TransportHandlers): void;
10
+ send(frame: ClientFrame & {
11
+ id?: number;
12
+ }): void;
13
+ /** Simulate a dropped connection; the client reconnects unless `code` is a refusal (1008). */
14
+ drop(code?: number, reason?: string): void;
15
+ close(): void;
16
+ }
@@ -0,0 +1,38 @@
1
+ // The in-memory link between a client and the MockServer. Frames are
2
+ // copied through JSON and delivered on a microtask, like a socket would.
3
+ export class MockTransport {
4
+ server;
5
+ handlers;
6
+ ready = false;
7
+ constructor(server) {
8
+ this.server = server;
9
+ }
10
+ connect(handlers) {
11
+ this.handlers = handlers;
12
+ this.server.attach(this);
13
+ queueMicrotask(() => {
14
+ this.ready = true;
15
+ handlers.open();
16
+ });
17
+ }
18
+ send(frame) {
19
+ const copy = JSON.parse(JSON.stringify(frame));
20
+ queueMicrotask(() => this.server.handle(this, copy));
21
+ }
22
+ /** @internal Frames sent while the connection is down are lost, as on a real socket. */
23
+ deliver(frame) {
24
+ if (!this.ready)
25
+ return;
26
+ const copy = JSON.parse(JSON.stringify(frame));
27
+ queueMicrotask(() => this.ready && this.handlers?.message(copy));
28
+ }
29
+ /** Simulate a dropped connection; the client reconnects unless `code` is a refusal (1008). */
30
+ drop(code, reason) {
31
+ this.ready = false;
32
+ this.server.detach(this);
33
+ this.handlers?.close(code, reason);
34
+ }
35
+ close() {
36
+ this.drop();
37
+ }
38
+ }
@@ -0,0 +1,5 @@
1
+ import type { Vantage } from "./client.js";
2
+ import type { RecordCell } from "./store.js";
3
+ import type { Model, TableMeta } from "./types.js";
4
+ export type AnyModel = Model & Record<string, any>;
5
+ export declare function buildModelClass(db: Vantage<any>, table: string, meta: TableMeta): new (cell: RecordCell) => AnyModel;
package/dist/model.js ADDED
@@ -0,0 +1,169 @@
1
+ // Model classes: one generated class per table, with the table's columns,
2
+ // references, has-many collections and row actions on its prototype, plus
3
+ // the edit draft (Servo) a model's `edit()` returns.
4
+ import { State } from "./signal.js";
5
+ import { reservedClash } from "./reserved.js";
6
+ import { Reference } from "./table.js";
7
+ import { pascal, sameValue, toColumns } from "./util.js";
8
+ export function buildModelClass(db, table, meta) {
9
+ const clash = reservedClash(table, [
10
+ ...Object.entries(meta.columns)
11
+ .filter(([name, info]) => name !== "id" && !info.hidden)
12
+ .map(([name]) => ({ kind: "column", name })),
13
+ ...[...Object.keys(meta.references), ...Object.keys(meta.hasMany)].map((name) => ({ kind: "reference", name })),
14
+ ...Object.entries(meta.actions)
15
+ .filter(([, action]) => action.perRow)
16
+ .map(([name]) => ({ kind: "action", name })),
17
+ ]);
18
+ if (clash)
19
+ throw new Error(`vantage: ${clash}`);
20
+ const refs = new WeakMap();
21
+ const memo = (cell, key, make) => {
22
+ let m = refs.get(cell);
23
+ if (!m)
24
+ refs.set(cell, (m = new Map()));
25
+ if (!m.has(key))
26
+ m.set(key, make());
27
+ return m.get(key);
28
+ };
29
+ class ModelImpl {
30
+ $cell;
31
+ constructor($cell) {
32
+ this.$cell = $cell;
33
+ }
34
+ }
35
+ const proto = ModelImpl.prototype;
36
+ const define = (name, get) => Object.defineProperty(proto, name, {
37
+ get,
38
+ set() {
39
+ throw new TypeError(`vantage: \`${table}.${name}\` is read-only — use .edit() and .$flash(), or .patch({ ${name}: … })`);
40
+ },
41
+ enumerable: true,
42
+ });
43
+ define("$table", () => table);
44
+ define("id", function () {
45
+ return this.$cell.id;
46
+ });
47
+ define("$status", function () {
48
+ return this.$cell.status.get();
49
+ });
50
+ define("$errors", function () {
51
+ return this.$cell.errors.get();
52
+ });
53
+ define("$actions", function () {
54
+ return this.$cell.get("$actions") ?? [];
55
+ });
56
+ define("$title", function () {
57
+ return meta.title ? String(this.$cell.get(meta.title) ?? this.$cell.id) : this.$cell.id;
58
+ });
59
+ const shadowed = new Set(Object.keys(meta.references));
60
+ for (const [column, info] of Object.entries(meta.columns)) {
61
+ if (column === "id" || info.hidden || shadowed.has(column))
62
+ continue;
63
+ define(column, function () {
64
+ return this.$cell.get(column);
65
+ });
66
+ }
67
+ for (const [name, ref] of Object.entries(meta.references)) {
68
+ define(name, function () {
69
+ const cell = this.$cell;
70
+ return memo(cell, `ref:${name}`, () => new Reference(db, ref.table, () => cell.get(ref.column) ?? null));
71
+ });
72
+ }
73
+ for (const [name, many] of Object.entries(meta.hasMany)) {
74
+ define(name, function () {
75
+ return db.collection({ table: many.table, via: { table, id: this.$cell.id, ref: name } });
76
+ });
77
+ }
78
+ const rowActions = Object.entries(meta.actions).filter(([, action]) => action.perRow);
79
+ for (const [name] of rowActions) {
80
+ // A run awaited to its end; `$run.<name>` gives the run itself.
81
+ proto[name] = function (input, opts) {
82
+ return db.invoke(`${table}.${name}`, this.$cell.id, input ?? {}, opts);
83
+ };
84
+ }
85
+ define("$run", function () {
86
+ const id = this.$cell.id;
87
+ return memo(this.$cell, "$run", () => Object.fromEntries(rowActions.map(([name]) => [name, db.runs(`${table}.${name}`, id)])));
88
+ });
89
+ proto.edit = function () {
90
+ return createServo(db, this, meta);
91
+ };
92
+ proto.patch = function (fields) {
93
+ return db.write(table, this.id, toColumns(meta, fields));
94
+ };
95
+ proto.delete = function () {
96
+ return db.remove(table, this.id);
97
+ };
98
+ proto.hydrate = async function (keys) {
99
+ const wanted = keys ?? [...Object.keys(meta.references), ...Object.keys(meta.hasMany)];
100
+ await Promise.all(wanted.map((k) => this[k]?.load?.()));
101
+ return this;
102
+ };
103
+ Object.defineProperty(ModelImpl, "name", { value: pascal(table) });
104
+ return ModelImpl;
105
+ }
106
+ function createServo(db, model, meta) {
107
+ const cell = model.$cell;
108
+ const setpoints = new Map();
109
+ const version = new State(0);
110
+ const status = new State("tracking");
111
+ const column = (name) => meta.references[name]?.column ?? name;
112
+ const dirty = (name) => setpoints.has(name) && !sameValue(setpoints.get(name).peek(), cell.field(column(name)).peek());
113
+ const api = {
114
+ $model: model,
115
+ $dirty(name) {
116
+ version.get();
117
+ return dirty(name);
118
+ },
119
+ $revert(name) {
120
+ if (name)
121
+ setpoints.delete(name);
122
+ else
123
+ setpoints.clear();
124
+ version.set(version.peek() + 1);
125
+ },
126
+ async $flash() {
127
+ const changed = [...setpoints.keys()].filter(dirty);
128
+ if (changed.length === 0)
129
+ return { ok: true, value: model };
130
+ status.set("pending");
131
+ const result = await db.write(model.$table, cell.id, Object.fromEntries(changed.map((k) => [column(k), setpoints.get(k).peek()])));
132
+ if (result.ok) {
133
+ for (const k of changed)
134
+ setpoints.delete(k);
135
+ status.set("tracking");
136
+ }
137
+ else
138
+ status.set("failed");
139
+ version.set(version.peek() + 1);
140
+ return result;
141
+ },
142
+ };
143
+ return new Proxy(api, {
144
+ get(target, prop) {
145
+ if (prop === "$isDirty") {
146
+ version.get();
147
+ return [...setpoints.keys()].some(dirty);
148
+ }
149
+ if (prop === "$status")
150
+ return status.get();
151
+ if (prop === "$errors")
152
+ return cell.errors.get();
153
+ if (prop in target)
154
+ return target[prop];
155
+ version.get();
156
+ const setpoint = setpoints.get(prop);
157
+ return setpoint ? setpoint.get() : cell.get(column(prop));
158
+ },
159
+ set(_, prop, value) {
160
+ let state = setpoints.get(prop);
161
+ if (!state)
162
+ setpoints.set(prop, (state = new State(value)));
163
+ else
164
+ state.set(value);
165
+ version.set(version.peek() + 1);
166
+ return true;
167
+ },
168
+ });
169
+ }
@@ -0,0 +1,47 @@
1
+ import type { Cursor, FlashItem, WireRecord } from "./protocol.js";
2
+ export interface Persisted {
3
+ records: {
4
+ table: string;
5
+ record: WireRecord;
6
+ }[];
7
+ cursor: Cursor | null;
8
+ outbox: FlashItem[];
9
+ }
10
+ export interface PersistChange {
11
+ /** `record: null` removes it. */
12
+ records?: {
13
+ table: string;
14
+ id: string;
15
+ record: WireRecord | null;
16
+ }[];
17
+ cursor?: Cursor;
18
+ outbox?: FlashItem[];
19
+ }
20
+ export interface Storage {
21
+ load(): Promise<Persisted>;
22
+ save(change: PersistChange): Promise<void>;
23
+ clear(): Promise<void>;
24
+ }
25
+ /** Keeps everything in memory (tests; SSR). */
26
+ export declare class MemoryStorage implements Storage {
27
+ private records;
28
+ private cursor;
29
+ private outbox;
30
+ load(): Promise<Persisted>;
31
+ save(change: PersistChange): Promise<void>;
32
+ clear(): Promise<void>;
33
+ }
34
+ /**
35
+ * IndexedDB, one database per server + schema. A schema change starts a
36
+ * fresh database (`name` includes the hash), so stale shapes never load.
37
+ */
38
+ export declare class IndexedDBStorage implements Storage {
39
+ private readonly name;
40
+ private readonly schemaHash;
41
+ private db?;
42
+ constructor(name: string, schemaHash: string);
43
+ private open;
44
+ load(): Promise<Persisted>;
45
+ save(change: PersistChange): Promise<void>;
46
+ clear(): Promise<void>;
47
+ }