@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.
Files changed (88) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/bin/vantage-client.js +5 -0
  3. package/dist/client-connection.d.ts +41 -0
  4. package/dist/client-connection.js +260 -0
  5. package/dist/client-core.d.ts +70 -0
  6. package/dist/client-core.js +140 -0
  7. package/dist/client-loading.d.ts +13 -0
  8. package/dist/client-loading.js +229 -0
  9. package/dist/client-runs.d.ts +18 -0
  10. package/dist/client-runs.js +88 -0
  11. package/dist/client-writes.d.ts +14 -0
  12. package/dist/client-writes.js +114 -0
  13. package/dist/client.d.ts +32 -0
  14. package/dist/client.js +61 -0
  15. package/dist/codegen/cli.d.ts +8 -0
  16. package/dist/codegen/cli.js +54 -0
  17. package/dist/codegen/emit.d.ts +3 -0
  18. package/dist/codegen/emit.js +190 -0
  19. package/dist/codegen/fetch.d.ts +4 -0
  20. package/dist/codegen/fetch.js +41 -0
  21. package/dist/codegen/meta.d.ts +13 -0
  22. package/dist/codegen/meta.js +59 -0
  23. package/dist/codegen/runs.d.ts +20 -0
  24. package/dist/codegen/runs.js +58 -0
  25. package/dist/codegen/schema.d.ts +97 -0
  26. package/dist/codegen/schema.js +23 -0
  27. package/dist/collection-cell.d.ts +69 -0
  28. package/dist/collection-cell.js +153 -0
  29. package/dist/collection.d.ts +32 -0
  30. package/dist/collection.js +122 -0
  31. package/dist/index.d.ts +8 -0
  32. package/dist/index.js +14 -0
  33. package/dist/mock-query.d.ts +9 -0
  34. package/dist/mock-query.js +75 -0
  35. package/dist/mock-runs.d.ts +61 -0
  36. package/dist/mock-runs.js +165 -0
  37. package/dist/mock-store.d.ts +78 -0
  38. package/dist/mock-store.js +158 -0
  39. package/dist/mock-transport.d.ts +16 -0
  40. package/dist/mock-transport.js +38 -0
  41. package/dist/model.d.ts +5 -0
  42. package/dist/model.js +169 -0
  43. package/dist/persist.d.ts +47 -0
  44. package/dist/persist.js +108 -0
  45. package/dist/protocol.d.ts +236 -0
  46. package/dist/protocol.js +5 -0
  47. package/dist/react.d.ts +59 -0
  48. package/dist/react.js +169 -0
  49. package/dist/reserved.d.ts +6 -0
  50. package/dist/reserved.js +13 -0
  51. package/dist/run/frames.d.ts +6 -0
  52. package/dist/run/frames.js +24 -0
  53. package/dist/run/hooks.d.ts +18 -0
  54. package/dist/run/hooks.js +87 -0
  55. package/dist/run/messages.d.ts +20 -0
  56. package/dist/run/messages.js +42 -0
  57. package/dist/run/outcome.d.ts +12 -0
  58. package/dist/run/outcome.js +27 -0
  59. package/dist/run/run.d.ts +82 -0
  60. package/dist/run/run.js +246 -0
  61. package/dist/run/storage.d.ts +3 -0
  62. package/dist/run/storage.js +20 -0
  63. package/dist/run/types.d.ts +92 -0
  64. package/dist/run/types.js +3 -0
  65. package/dist/run/variables.d.ts +26 -0
  66. package/dist/run/variables.js +70 -0
  67. package/dist/schema-meta.d.ts +91 -0
  68. package/dist/schema-meta.js +3 -0
  69. package/dist/signal.d.ts +45 -0
  70. package/dist/signal.js +163 -0
  71. package/dist/store.d.ts +45 -0
  72. package/dist/store.js +144 -0
  73. package/dist/table.d.ts +62 -0
  74. package/dist/table.js +82 -0
  75. package/dist/testing.d.ts +61 -0
  76. package/dist/testing.js +195 -0
  77. package/dist/transport.d.ts +28 -0
  78. package/dist/transport.js +32 -0
  79. package/dist/types.d.ts +159 -0
  80. package/dist/types.js +2 -0
  81. package/dist/util.d.ts +18 -0
  82. package/dist/util.js +46 -0
  83. package/package.json +54 -4
  84. package/skills/vantage-client/SKILL.md +390 -0
  85. package/skills/vantage-client/references/protocol.md +67 -0
  86. package/skills/vantage-client/references/react.md +242 -0
  87. package/skills/vantage-client/references/testing.md +173 -0
  88. package/README.md +0 -3
@@ -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
+ }
@@ -0,0 +1,108 @@
1
+ // Offline mirror and outbox.
2
+ //
3
+ // Tables marked `persist: true` on the server are mirrored here; on the
4
+ // next start the app paints from the mirror (records marked `stale`)
5
+ // before the socket connects. Edits made while offline wait in memory and
6
+ // go out in order on reconnect, within the same page. They are also saved
7
+ // to the outbox, but nothing sends a saved outbox after a reload: edits
8
+ // still waiting when the page reloads are lost.
9
+ /** Keeps everything in memory (tests; SSR). */
10
+ export class MemoryStorage {
11
+ records = new Map();
12
+ cursor = null;
13
+ outbox = [];
14
+ async load() {
15
+ return { records: [...this.records.values()], cursor: this.cursor, outbox: [...this.outbox] };
16
+ }
17
+ async save(change) {
18
+ for (const r of change.records ?? []) {
19
+ const key = `${r.table}\u0000${r.id}`;
20
+ if (r.record)
21
+ this.records.set(key, { table: r.table, record: r.record });
22
+ else
23
+ this.records.delete(key);
24
+ }
25
+ if (change.cursor)
26
+ this.cursor = change.cursor;
27
+ if (change.outbox)
28
+ this.outbox = [...change.outbox];
29
+ }
30
+ async clear() {
31
+ this.records.clear();
32
+ this.cursor = null;
33
+ this.outbox = [];
34
+ }
35
+ }
36
+ /**
37
+ * IndexedDB, one database per server + schema. A schema change starts a
38
+ * fresh database (`name` includes the hash), so stale shapes never load.
39
+ */
40
+ export class IndexedDBStorage {
41
+ name;
42
+ schemaHash;
43
+ db;
44
+ constructor(name, schemaHash) {
45
+ this.name = name;
46
+ this.schemaHash = schemaHash;
47
+ }
48
+ open() {
49
+ this.db ??= new Promise((resolve, reject) => {
50
+ const request = indexedDB.open(`vantage:${this.name}:${this.schemaHash}`, 1);
51
+ request.onupgradeneeded = () => {
52
+ const db = request.result;
53
+ db.createObjectStore("records");
54
+ db.createObjectStore("meta");
55
+ };
56
+ request.onsuccess = () => resolve(request.result);
57
+ request.onerror = () => reject(request.error);
58
+ });
59
+ return this.db;
60
+ }
61
+ async load() {
62
+ const db = await this.open();
63
+ const tx = db.transaction(["records", "meta"], "readonly");
64
+ const [records, cursor, outbox] = await Promise.all([
65
+ req(tx.objectStore("records").getAll()),
66
+ req(tx.objectStore("meta").get("cursor")),
67
+ req(tx.objectStore("meta").get("outbox")),
68
+ ]);
69
+ return { records, cursor: cursor ?? null, outbox: outbox ?? [] };
70
+ }
71
+ async save(change) {
72
+ const db = await this.open();
73
+ const tx = db.transaction(["records", "meta"], "readwrite");
74
+ const records = tx.objectStore("records");
75
+ for (const r of change.records ?? []) {
76
+ const key = `${r.table}\u0000${r.id}`;
77
+ if (r.record)
78
+ records.put({ table: r.table, record: r.record }, key);
79
+ else
80
+ records.delete(key);
81
+ }
82
+ if (change.cursor)
83
+ tx.objectStore("meta").put(change.cursor, "cursor");
84
+ if (change.outbox)
85
+ tx.objectStore("meta").put(change.outbox, "outbox");
86
+ await done(tx);
87
+ }
88
+ async clear() {
89
+ const db = await this.open();
90
+ const tx = db.transaction(["records", "meta"], "readwrite");
91
+ tx.objectStore("records").clear();
92
+ tx.objectStore("meta").clear();
93
+ await done(tx);
94
+ }
95
+ }
96
+ function req(request) {
97
+ return new Promise((resolve, reject) => {
98
+ request.onsuccess = () => resolve(request.result);
99
+ request.onerror = () => reject(request.error);
100
+ });
101
+ }
102
+ function done(tx) {
103
+ return new Promise((resolve, reject) => {
104
+ tx.oncomplete = () => resolve();
105
+ tx.onerror = () => reject(tx.error);
106
+ tx.onabort = () => reject(tx.error);
107
+ });
108
+ }