@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.
- package/CHANGELOG.md +50 -0
- package/README.md +131 -2
- 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 +56 -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/dist/client.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// The client: a typed, reactive object graph over a vantage-server.
|
|
2
|
+
//
|
|
3
|
+
// `Vantage` is built in layers, each in its own file:
|
|
4
|
+
// client-core.ts state, request routing, status, persistence, models
|
|
5
|
+
// client-loading.ts records by id, collections and windows, the sweep
|
|
6
|
+
// client-writes.ts optimistic writes, undo
|
|
7
|
+
// client-runs.ts action runs: factories, followed runs, `run` frames
|
|
8
|
+
// client-connection.ts hello, reconnect, resync, server-pushed frames
|
|
9
|
+
import { ClientConnection } from "./client-connection.js";
|
|
10
|
+
import { Collection } from "./collection.js";
|
|
11
|
+
import { specKey } from "./collection-cell.js";
|
|
12
|
+
import { Reference, Table } from "./table.js";
|
|
13
|
+
export function createVantage(options) {
|
|
14
|
+
const db = new Vantage(options);
|
|
15
|
+
return new Proxy(db, {
|
|
16
|
+
get(target, prop, receiver) {
|
|
17
|
+
if (typeof prop === "string" && !(prop in target) && prop in options.schema.tables)
|
|
18
|
+
return target.table(prop);
|
|
19
|
+
return Reflect.get(target, prop, receiver);
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
export class Vantage extends ClientConnection {
|
|
24
|
+
/** `db.run.magic_promotion.start({ bakery })`: project actions, as runs. */
|
|
25
|
+
run;
|
|
26
|
+
constructor(options) {
|
|
27
|
+
super(options);
|
|
28
|
+
this.run = new Proxy({}, {
|
|
29
|
+
get: (_, action) => (typeof action === "string" ? this.runs(action) : undefined),
|
|
30
|
+
});
|
|
31
|
+
this.sweeper = setInterval(() => this.sweep(), 5_000);
|
|
32
|
+
void this.start();
|
|
33
|
+
}
|
|
34
|
+
table(name) {
|
|
35
|
+
let handle = this.handles.get(name);
|
|
36
|
+
if (!handle) {
|
|
37
|
+
handle = new Table(this, name, this.meta(name));
|
|
38
|
+
this.handles.set(name, handle);
|
|
39
|
+
}
|
|
40
|
+
return handle;
|
|
41
|
+
}
|
|
42
|
+
/** @internal */ ref(table, id) {
|
|
43
|
+
return new Reference(this, table, id);
|
|
44
|
+
}
|
|
45
|
+
/** @internal The collection `spec` describes: the same object for the same description while anything reads it. */
|
|
46
|
+
collection(spec) {
|
|
47
|
+
const key = specKey(spec);
|
|
48
|
+
let collection = this.interned.get(key);
|
|
49
|
+
if (!collection) {
|
|
50
|
+
collection = new Collection(this, spec);
|
|
51
|
+
this.interned.set(key, collection);
|
|
52
|
+
}
|
|
53
|
+
return collection;
|
|
54
|
+
}
|
|
55
|
+
/** Run loads ahead of navigation; resolves when they're done. */
|
|
56
|
+
async preload(fn) {
|
|
57
|
+
const result = fn();
|
|
58
|
+
const items = Array.isArray(result) ? result : [result];
|
|
59
|
+
await Promise.all(items.map((r) => (r && typeof r.then === "function" ? r : r && typeof r.load === "function" ? r.load() : r)));
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Run the CLI; the exit code. Messages go to `log` and `fail`, one line each. */
|
|
2
|
+
export declare function main(args: string[], log?: {
|
|
3
|
+
(...data: any[]): void;
|
|
4
|
+
(message?: any, ...optionalParams: any[]): void;
|
|
5
|
+
}, fail?: {
|
|
6
|
+
(...data: any[]): void;
|
|
7
|
+
(message?: any, ...optionalParams: any[]): void;
|
|
8
|
+
}): Promise<number>;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// `vantage-client generate --from <url-or-file> --out <file>`.
|
|
2
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
import { emit } from "./emit.js";
|
|
5
|
+
import { loadSchema } from "./fetch.js";
|
|
6
|
+
import { GenerateError } from "./schema.js";
|
|
7
|
+
const USAGE = "usage: vantage-client generate --from <url-to-/v1/schema | schema.json> --out <file>";
|
|
8
|
+
/** Run the CLI; the exit code. Messages go to `log` and `fail`, one line each. */
|
|
9
|
+
export async function main(args, log = console.log, fail = console.error) {
|
|
10
|
+
const [command, ...rest] = args;
|
|
11
|
+
if (command !== "generate") {
|
|
12
|
+
fail(USAGE);
|
|
13
|
+
return 1;
|
|
14
|
+
}
|
|
15
|
+
try {
|
|
16
|
+
const from = option(rest, "--from");
|
|
17
|
+
const out = option(rest, "--out");
|
|
18
|
+
if (!from || !out) {
|
|
19
|
+
fail(USAGE);
|
|
20
|
+
return 1;
|
|
21
|
+
}
|
|
22
|
+
const schema = await loadSchema(from);
|
|
23
|
+
await write(out, emit(schema, from));
|
|
24
|
+
log(`wrote ${out} (schema ${schema.hash})`);
|
|
25
|
+
return 0;
|
|
26
|
+
}
|
|
27
|
+
catch (e) {
|
|
28
|
+
if (!(e instanceof GenerateError))
|
|
29
|
+
throw e;
|
|
30
|
+
fail(`vantage-client: ${e.message}`);
|
|
31
|
+
return 1;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** The value after `name`; undefined when `name` isn't given. */
|
|
35
|
+
function option(args, name) {
|
|
36
|
+
const at = args.indexOf(name);
|
|
37
|
+
if (at < 0)
|
|
38
|
+
return undefined;
|
|
39
|
+
const value = args[at + 1];
|
|
40
|
+
if (value === undefined || value.startsWith("--"))
|
|
41
|
+
throw new GenerateError(`${name} needs a value`);
|
|
42
|
+
return value;
|
|
43
|
+
}
|
|
44
|
+
/** Write `text` to `path`, creating its directory when missing. */
|
|
45
|
+
async function write(path, text) {
|
|
46
|
+
try {
|
|
47
|
+
await mkdir(dirname(path), { recursive: true });
|
|
48
|
+
await writeFile(path, text);
|
|
49
|
+
}
|
|
50
|
+
catch (e) {
|
|
51
|
+
const code = e.code ?? (e instanceof Error ? e.message : String(e));
|
|
52
|
+
throw new GenerateError(`cannot write ${path}: ${code}`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// `generate`: TypeScript for one API, from its `/v1/schema`.
|
|
2
|
+
//
|
|
3
|
+
// Per table it writes:
|
|
4
|
+
// - `XFields`: the record as data (what a draft edits);
|
|
5
|
+
// - `XInput`: what `create()` takes (mandatory columns required);
|
|
6
|
+
// - `X`: the model the app reads. Reference columns become
|
|
7
|
+
// `LazyReference`s, `has_many` references `LazyCollection`s, and
|
|
8
|
+
// row actions become methods and `$run` factories;
|
|
9
|
+
// - `X<Action>Run`: a row action's run types (args, state, inputs, messages).
|
|
10
|
+
//
|
|
11
|
+
// plus `<Action>Run` per project action, `Schema` (types) and `schema`
|
|
12
|
+
// (runtime metadata the client builds models from). Everything comes out in the schema's own order (the API's
|
|
13
|
+
// and the YAML's), so the same schema always gives the same file.
|
|
14
|
+
import { pascal } from "../util.js";
|
|
15
|
+
import { fieldMeta, metaTable, required, runMeta } from "./meta.js";
|
|
16
|
+
import { RunNames, runInterface } from "./runs.js";
|
|
17
|
+
import { refuseReservedNames } from "./schema.js";
|
|
18
|
+
const stringify = (value) => JSON.stringify(value ?? null);
|
|
19
|
+
/** Schema text inside a generated comment: it can neither close the comment nor break the line. */
|
|
20
|
+
const doc = (text) => String(text ?? "").replace(/\*\//g, "*\\/").replace(/[^\S ]+/g, " ");
|
|
21
|
+
/** Throws a `GenerateError` when an entity member takes a name the model reserves. */
|
|
22
|
+
export function emit(schema, source) {
|
|
23
|
+
refuseReservedNames(schema);
|
|
24
|
+
const hash = typeof schema.hash === "string" ? schema.hash : "";
|
|
25
|
+
let out = "";
|
|
26
|
+
const line = (text = "") => (out += text + "\n");
|
|
27
|
+
line(`// Generated by @vantage-ui/client generate from ${doc(source)} (schema ${doc(hash)}). Never edit by hand.`);
|
|
28
|
+
line("// When the API changes, the Vantage web dev container regenerates it from schema.json;");
|
|
29
|
+
line("// elsewhere, run `npx vantage-client generate --from <api>/v1/schema` again.\n");
|
|
30
|
+
// Only the types the file uses, so it passes `noUnusedLocals`.
|
|
31
|
+
const tables = schema.tables ?? [];
|
|
32
|
+
const rowActionsAnywhere = tables.some((t) => (t.actions ?? []).some((a) => a.per_row === true));
|
|
33
|
+
const used = [
|
|
34
|
+
["ActionResult", rowActionsAnywhere],
|
|
35
|
+
["LazyCollection", tables.some((t) => (t.has_many ?? []).length > 0)],
|
|
36
|
+
["LazyReference", tables.some((t) => (t.references ?? []).length > 0)],
|
|
37
|
+
["Model", tables.length > 0],
|
|
38
|
+
["RunFactory", rowActionsAnywhere],
|
|
39
|
+
];
|
|
40
|
+
const types = used.filter(([, uses]) => uses).map(([name]) => name);
|
|
41
|
+
if (types.length > 0)
|
|
42
|
+
line(`import type { ${types.join(", ")} } from "@vantage-ui/client";\n`);
|
|
43
|
+
const models = [];
|
|
44
|
+
const fields = [];
|
|
45
|
+
const inputs = [];
|
|
46
|
+
const tableActions = [];
|
|
47
|
+
const metaTables = {};
|
|
48
|
+
const runNames = new RunNames([
|
|
49
|
+
...tables.flatMap((t) => ["", "Fields", "Input", "Action"].map((suffix) => `${pascal(t.name ?? "")}${suffix}`)),
|
|
50
|
+
...["Models", "Fields", "Inputs", "TableActions", "Schema", "ProjectActions"],
|
|
51
|
+
]);
|
|
52
|
+
for (const raw of tables) {
|
|
53
|
+
const table = { ...raw, columns: raw.columns ?? [], references: raw.references ?? [], has_many: raw.has_many ?? [], actions: raw.actions ?? [] };
|
|
54
|
+
const name = table.name ?? "";
|
|
55
|
+
const ty = pascal(name);
|
|
56
|
+
const readOnly = table.read_only === true;
|
|
57
|
+
const rowActions = table.actions.filter((a) => a.per_row === true);
|
|
58
|
+
const toolbar = table.actions.filter((a) => a.per_row !== true);
|
|
59
|
+
// --- fields (data)
|
|
60
|
+
line(`/** \`${doc(typeof table.key === "string" ? table.key : name)}\` as data${readOnly ? " (read-only)" : ""} */`);
|
|
61
|
+
line(`export interface ${ty}Fields {`);
|
|
62
|
+
line(" readonly id: string;");
|
|
63
|
+
for (const c of table.columns.filter((c) => c.name !== "id" && c.hidden !== true)) {
|
|
64
|
+
const ro = readOnly || c.readonly === true;
|
|
65
|
+
if (typeof c.label === "string")
|
|
66
|
+
line(` /** ${doc(c.label)} */`);
|
|
67
|
+
line(` ${ro ? "readonly " : ""}${key(c.name ?? "")}: ${columnType(c)} | null;`);
|
|
68
|
+
}
|
|
69
|
+
line("}\n");
|
|
70
|
+
// --- create input
|
|
71
|
+
if (!readOnly) {
|
|
72
|
+
line(`export interface ${ty}Input {`);
|
|
73
|
+
line(` /** Optional client-made id (\`${doc(name)}:<uuid>\` or bare); the server makes one otherwise. */`);
|
|
74
|
+
line(" id?: string;");
|
|
75
|
+
for (const c of table.columns.filter((c) => c.name !== "id" && c.readonly !== true)) {
|
|
76
|
+
const req = c.mandatory === true;
|
|
77
|
+
line(` ${key(c.name ?? "")}${req ? "" : "?"}: ${columnType(c)}${req ? "" : " | null"};`);
|
|
78
|
+
}
|
|
79
|
+
line("}\n");
|
|
80
|
+
inputs.push(` ${key(name)}: ${ty}Input;`);
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
inputs.push(` ${key(name)}: never;`);
|
|
84
|
+
}
|
|
85
|
+
// --- model
|
|
86
|
+
const relColumns = table.references.map((r) => r.column).filter((c) => typeof c === "string" && table.references.some((r) => r.name === c));
|
|
87
|
+
const omit = relColumns.length === 0 ? `${ty}Fields` : `Omit<${ty}Fields, ${relColumns.map(stringify).join(" | ")}>`;
|
|
88
|
+
line(`export interface ${ty} extends Model<${stringify(name)}, ${ty}Fields>, Readonly<${omit}> {`);
|
|
89
|
+
for (const r of table.references) {
|
|
90
|
+
line(` /** \`${doc(r.column)}\` → ${doc(r.table)} */`);
|
|
91
|
+
line(` readonly ${key(r.name ?? "")}: LazyReference<${pascal(r.table ?? "")}>;`);
|
|
92
|
+
}
|
|
93
|
+
for (const h of table.has_many)
|
|
94
|
+
line(` readonly ${key(h.name ?? "")}: LazyCollection<${pascal(h.table ?? "")}>;`);
|
|
95
|
+
line(" /** Row actions whose `when:` holds right now (decided by the server). */");
|
|
96
|
+
line(` readonly $actions: readonly ${rowActions.length === 0 ? "never" : `${ty}Action`}[];`);
|
|
97
|
+
for (const a of rowActions) {
|
|
98
|
+
const input = fieldsType(a.fields);
|
|
99
|
+
const arg = input === "{}" ? "" : inputOptional(a.fields) ? `input?: ${input}` : `input: ${input}`;
|
|
100
|
+
line(` /** ${doc(a.label)}: a run, awaited to its end */`);
|
|
101
|
+
line(` ${key(a.name ?? "")}(${arg}): Promise<ActionResult<${ty}>>;`);
|
|
102
|
+
}
|
|
103
|
+
const rowRuns = rowActions.map((a) => runNames.take(`${ty}${pascal(a.name ?? "")}`));
|
|
104
|
+
if (rowActions.length > 0) {
|
|
105
|
+
line(" /** The row actions as runs: `start`, `attach`, live `state` and messages. */");
|
|
106
|
+
line(` readonly $run: { ${rowActions.map((a, i) => `${key(a.name ?? "")}: RunFactory<${rowRuns[i]}>`).join("; ")} };`);
|
|
107
|
+
}
|
|
108
|
+
line("}\n");
|
|
109
|
+
if (rowActions.length > 0)
|
|
110
|
+
line(`export type ${ty}Action = ${rowActions.map((a) => stringify(a.name ?? "")).join(" | ")};\n`);
|
|
111
|
+
rowActions.forEach((a, i) => line(runInterface(rowRuns[i], fieldsType(a.fields), a, key)));
|
|
112
|
+
const entries = toolbar.map((a) => ` /** ${doc(a.label)} */\n ${key(a.name ?? "")}: ${fieldsType(a.fields)};`);
|
|
113
|
+
tableActions.push(` ${key(name)}: {\n${entries.join("\n")}\n };`);
|
|
114
|
+
models.push(` ${key(name)}: ${ty};`);
|
|
115
|
+
fields.push(` ${key(name)}: ${ty}Fields;`);
|
|
116
|
+
metaTables[name] = metaTable(table);
|
|
117
|
+
}
|
|
118
|
+
const projectActions = [];
|
|
119
|
+
const metaActions = {};
|
|
120
|
+
for (const action of schema.actions ?? []) {
|
|
121
|
+
const name = action.name ?? "";
|
|
122
|
+
const title = typeof action.title === "string" ? action.title : typeof action.key === "string" ? action.key : "";
|
|
123
|
+
const kind = typeof action.kind === "string" ? action.kind : "";
|
|
124
|
+
let args;
|
|
125
|
+
if (kind === "http_request") {
|
|
126
|
+
const params = (action.params ?? []).filter((p) => typeof p === "string").map((p) => `${key(p)}: string`);
|
|
127
|
+
args = params.length === 0 ? "{}" : `{ ${params.join("; ")} }`;
|
|
128
|
+
}
|
|
129
|
+
else
|
|
130
|
+
args = fieldsType(action.fields);
|
|
131
|
+
const run = runNames.take(pascal(name));
|
|
132
|
+
line(`/** ${doc(title)}${kind ? ` (${doc(kind)})` : ""} */`);
|
|
133
|
+
line(runInterface(run, args, action, key));
|
|
134
|
+
projectActions.push(` ${key(name)}: ${run};`);
|
|
135
|
+
metaActions[name] = { key: action.key ?? null, kind: action.kind ?? null, fields: fieldMeta(action.fields), ...runMeta(action) };
|
|
136
|
+
}
|
|
137
|
+
line("/** Project actions (`action/*.yaml`) a client can run: `db.run.<name>.start(args)`. */");
|
|
138
|
+
line(projectActions.length ? `export interface ProjectActions {\n${projectActions.join("\n")}\n}\n` : "export interface ProjectActions {}\n");
|
|
139
|
+
line(`export interface Models {\n${models.join("\n")}\n}\n`);
|
|
140
|
+
line(`export interface Fields {\n${fields.join("\n")}\n}\n`);
|
|
141
|
+
line(`export interface Inputs {\n${inputs.join("\n")}\n}\n`);
|
|
142
|
+
line(`export interface TableActions {\n${tableActions.join("\n")}\n}\n`);
|
|
143
|
+
line("export interface Schema {\n models: Models;\n fields: Fields;\n inputs: Inputs;\n tableActions: TableActions;\n projectActions: ProjectActions;\n}\n");
|
|
144
|
+
line("/** Runtime metadata `createVantage({ schema })` builds models from. */");
|
|
145
|
+
line(`export const schema = ${JSON.stringify({ hash, tables: metaTables, actions: metaActions }, null, 2)} as const;`);
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
function columnType(column) {
|
|
149
|
+
if (Array.isArray(column.enum) && column.enum.length > 0)
|
|
150
|
+
return column.enum.map(stringify).join(" | ");
|
|
151
|
+
switch (typeof column.type === "string" ? column.type : "string") {
|
|
152
|
+
case "int":
|
|
153
|
+
case "float":
|
|
154
|
+
return "number";
|
|
155
|
+
// Exact: a string on the wire, so pence never become 0.30000000000000004.
|
|
156
|
+
case "decimal":
|
|
157
|
+
return "number | string";
|
|
158
|
+
case "bool":
|
|
159
|
+
return "boolean";
|
|
160
|
+
// date, time, timestamp, datetime, duration: ISO strings on the wire.
|
|
161
|
+
default:
|
|
162
|
+
return "string";
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
function inputOptional(fields) {
|
|
166
|
+
return (fields ?? []).every((f) => !required(f));
|
|
167
|
+
}
|
|
168
|
+
function fieldsType(list) {
|
|
169
|
+
const fields = list ?? [];
|
|
170
|
+
if (fields.length === 0)
|
|
171
|
+
return "{}";
|
|
172
|
+
const parts = fields.map((f) => {
|
|
173
|
+
let ty;
|
|
174
|
+
if (Array.isArray(f.choices) && f.widget !== "checklist")
|
|
175
|
+
ty = f.choices.map(stringify).join(" | ");
|
|
176
|
+
else if (f.type === "int" || f.type === "float" || f.type === "decimal")
|
|
177
|
+
ty = "number";
|
|
178
|
+
else if (f.type === "bool")
|
|
179
|
+
ty = "boolean";
|
|
180
|
+
else
|
|
181
|
+
ty = "string";
|
|
182
|
+
return `${key(f.name ?? "")}${required(f) ? "" : "?"}: ${ty}`;
|
|
183
|
+
});
|
|
184
|
+
return `{ ${parts.join("; ")} }`;
|
|
185
|
+
}
|
|
186
|
+
/** A property name: bare when it is an identifier, quoted otherwise. */
|
|
187
|
+
function key(name) {
|
|
188
|
+
const plain = name !== "" && /^[A-Za-z0-9_$]+$/.test(name) && !/^[0-9]/.test(name);
|
|
189
|
+
return plain ? name : stringify(name);
|
|
190
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Where `generate --from` reads the schema: an `http(s)://` URL (usually a
|
|
2
|
+
// server's `/v1/schema`), or anything else as a path to a JSON file.
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import { GenerateError, isSchema } from "./schema.js";
|
|
5
|
+
export async function loadSchema(from) {
|
|
6
|
+
const text = /^https?:\/\//i.test(from) ? await fetchText(from) : await readText(from);
|
|
7
|
+
let body;
|
|
8
|
+
try {
|
|
9
|
+
body = JSON.parse(text);
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
throw new GenerateError(`not a schema: ${from} did not return JSON`);
|
|
13
|
+
}
|
|
14
|
+
if (!isSchema(body))
|
|
15
|
+
throw new GenerateError(`not a schema: ${from} has no \`tables\` list`);
|
|
16
|
+
return { ...body, hash: typeof body.hash === "string" ? body.hash : "" };
|
|
17
|
+
}
|
|
18
|
+
async function fetchText(url) {
|
|
19
|
+
let response;
|
|
20
|
+
try {
|
|
21
|
+
response = await fetch(url);
|
|
22
|
+
}
|
|
23
|
+
catch (e) {
|
|
24
|
+
throw new GenerateError(`cannot reach ${url}: ${reason(e)}`);
|
|
25
|
+
}
|
|
26
|
+
if (!response.ok)
|
|
27
|
+
throw new GenerateError(`cannot reach ${url}: HTTP ${response.status}`);
|
|
28
|
+
return response.text();
|
|
29
|
+
}
|
|
30
|
+
async function readText(path) {
|
|
31
|
+
try {
|
|
32
|
+
return await readFile(path, "utf8");
|
|
33
|
+
}
|
|
34
|
+
catch (e) {
|
|
35
|
+
throw new GenerateError(`cannot read ${path}: ${reason(e)}`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function reason(e) {
|
|
39
|
+
const cause = e.cause;
|
|
40
|
+
return cause?.code ?? cause?.message ?? (e instanceof Error ? e.message : String(e));
|
|
41
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { ServerField, ServerMessage, ServerStateVar, ServerTable } from "./schema.js";
|
|
2
|
+
type Meta = Record<string, unknown>;
|
|
3
|
+
export declare function metaTable(table: ServerTable): Meta;
|
|
4
|
+
/** A run's variables (type, starting value, whether the client writes it) and message names. */
|
|
5
|
+
export declare function runMeta(action: {
|
|
6
|
+
state?: ServerStateVar[] | null;
|
|
7
|
+
messages?: ServerMessage[] | null;
|
|
8
|
+
}): Meta;
|
|
9
|
+
/** What a client form needs to render a field. */
|
|
10
|
+
export declare function fieldMeta(fields: ServerField[] | undefined | null): Meta[];
|
|
11
|
+
/** Required unless it says `required: false` or has a default. */
|
|
12
|
+
export declare function required(f: ServerField): boolean;
|
|
13
|
+
export {};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// The runtime metadata (`export const schema = …`) a generated file carries:
|
|
2
|
+
// per table its load policy, the API's sort and filter whitelists, columns
|
|
3
|
+
// (with enum values), references, has-many and actions, and the project
|
|
4
|
+
// actions, in the shape `createVantage({ schema })` reads.
|
|
5
|
+
const or = (value) => value ?? null;
|
|
6
|
+
export function metaTable(table) {
|
|
7
|
+
const columns = Object.fromEntries((table.columns ?? []).map((c) => [
|
|
8
|
+
String(c.name ?? ""),
|
|
9
|
+
{
|
|
10
|
+
type: or(c.type),
|
|
11
|
+
...(c.readonly === true && { readonly: true }),
|
|
12
|
+
...(c.hidden === true && { hidden: true }),
|
|
13
|
+
...(Array.isArray(c.enum) && c.enum.length > 0 && { enum: c.enum }),
|
|
14
|
+
},
|
|
15
|
+
]));
|
|
16
|
+
const references = Object.fromEntries((table.references ?? []).map((r) => [String(r.name ?? ""), { column: or(r.column), table: or(r.table) }]));
|
|
17
|
+
const hasMany = Object.fromEntries((table.has_many ?? []).map((h) => [String(h.name ?? ""), { table: or(h.table), foreignKey: or(h.foreign_key) }]));
|
|
18
|
+
const actions = Object.fromEntries((table.actions ?? []).map((a) => [String(a.name ?? ""), { perRow: a.per_row === true, label: or(a.label), fields: fieldMeta(a.fields), ...runMeta(a) }]));
|
|
19
|
+
return {
|
|
20
|
+
key: or(table.key),
|
|
21
|
+
load: or(table.load),
|
|
22
|
+
persist: or(table.persist),
|
|
23
|
+
live: or(table.live),
|
|
24
|
+
readOnly: or(table.read_only),
|
|
25
|
+
idColumn: or(table.id_column),
|
|
26
|
+
title: or(table.title),
|
|
27
|
+
collectionLimit: or(table.collection_limit),
|
|
28
|
+
// The API's whitelists: what a collection may sort and filter by, and its default sort.
|
|
29
|
+
sortable: or(table.sortable),
|
|
30
|
+
filter: or(table.filter),
|
|
31
|
+
defaultSort: or(table.default_sort),
|
|
32
|
+
columns,
|
|
33
|
+
references,
|
|
34
|
+
hasMany,
|
|
35
|
+
actions,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/** A run's variables (type, starting value, whether the client writes it) and message names. */
|
|
39
|
+
export function runMeta(action) {
|
|
40
|
+
const state = Object.fromEntries((action.state ?? []).map((v) => [String(v.name ?? ""), { type: typeof v.type === "string" ? v.type : "any", default: or(v.default), input: v.input === true }]));
|
|
41
|
+
return { state, messages: (action.messages ?? []).map((m) => String(m.name ?? "")) };
|
|
42
|
+
}
|
|
43
|
+
/** What a client form needs to render a field. */
|
|
44
|
+
export function fieldMeta(fields) {
|
|
45
|
+
return (fields ?? []).map((f) => ({
|
|
46
|
+
name: or(f.name),
|
|
47
|
+
type: typeof f.type === "string" ? f.type : "string",
|
|
48
|
+
label: or(f.label),
|
|
49
|
+
required: required(f),
|
|
50
|
+
choices: or(f.choices),
|
|
51
|
+
default: or(f.default),
|
|
52
|
+
widget: or(f.widget),
|
|
53
|
+
options: f.options != null,
|
|
54
|
+
}));
|
|
55
|
+
}
|
|
56
|
+
/** Required unless it says `required: false` or has a default. */
|
|
57
|
+
export function required(f) {
|
|
58
|
+
return (typeof f.required === "boolean" ? f.required : true) && f.default == null;
|
|
59
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ServerMessage, ServerStateVar } from "./schema.js";
|
|
2
|
+
/**
|
|
3
|
+
* Hands out run type names (`<Base>Run`) that are valid identifiers and
|
|
4
|
+
* clash with nothing else in the file: a base starting with a digit gets
|
|
5
|
+
* `_`, an empty one (a name with no ASCII letters) becomes `Action`, and a
|
|
6
|
+
* taken name gets `2`, `3`, … in the order names are asked for.
|
|
7
|
+
*/
|
|
8
|
+
export declare class RunNames {
|
|
9
|
+
private used;
|
|
10
|
+
constructor(taken: Iterable<string>);
|
|
11
|
+
take(base: string): string;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* `export interface <name> { args; state; inputs; messages }` for an action
|
|
15
|
+
* that takes `args` (a type) and declares `state` and `messages`.
|
|
16
|
+
*/
|
|
17
|
+
export declare function runInterface(name: string, args: string, action: {
|
|
18
|
+
state?: ServerStateVar[] | null;
|
|
19
|
+
messages?: ServerMessage[] | null;
|
|
20
|
+
}, key: (name: string) => string): string;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// The run types of one action: what `start` takes (`args`), the shared
|
|
2
|
+
// variables (`state`), the ones a client may write (`inputs`) and the
|
|
3
|
+
// messages (`messages`), as one interface the client's `Run<T>` reads.
|
|
4
|
+
/** A run variable's or message field's type (`int`, `list`, …) in TypeScript. */
|
|
5
|
+
function typeOf(type) {
|
|
6
|
+
switch (type) {
|
|
7
|
+
case "string":
|
|
8
|
+
return "string";
|
|
9
|
+
case "int":
|
|
10
|
+
case "float":
|
|
11
|
+
return "number";
|
|
12
|
+
case "bool":
|
|
13
|
+
return "boolean";
|
|
14
|
+
case "list":
|
|
15
|
+
return "unknown[]";
|
|
16
|
+
case "map":
|
|
17
|
+
return "Record<string, unknown>";
|
|
18
|
+
default:
|
|
19
|
+
return "unknown";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Hands out run type names (`<Base>Run`) that are valid identifiers and
|
|
24
|
+
* clash with nothing else in the file: a base starting with a digit gets
|
|
25
|
+
* `_`, an empty one (a name with no ASCII letters) becomes `Action`, and a
|
|
26
|
+
* taken name gets `2`, `3`, … in the order names are asked for.
|
|
27
|
+
*/
|
|
28
|
+
export class RunNames {
|
|
29
|
+
used;
|
|
30
|
+
constructor(taken) {
|
|
31
|
+
this.used = new Set(taken);
|
|
32
|
+
}
|
|
33
|
+
take(base) {
|
|
34
|
+
const root = `${base === "" ? "Action" : /^[0-9]/.test(base) ? `_${base}` : base}Run`;
|
|
35
|
+
let name = root;
|
|
36
|
+
for (let n = 2; this.used.has(name); n++)
|
|
37
|
+
name = `${root}${n}`;
|
|
38
|
+
this.used.add(name);
|
|
39
|
+
return name;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* `export interface <name> { args; state; inputs; messages }` for an action
|
|
44
|
+
* that takes `args` (a type) and declares `state` and `messages`.
|
|
45
|
+
*/
|
|
46
|
+
export function runInterface(name, args, action, key) {
|
|
47
|
+
const vars = action.state ?? [];
|
|
48
|
+
const object = (parts) => (parts.length === 0 ? "{}" : `{ ${parts.join("; ")} }`);
|
|
49
|
+
const variable = (v) => `${key(v.name ?? "")}: ${typeOf(v.type)}`;
|
|
50
|
+
const state = object(vars.map(variable));
|
|
51
|
+
const inputs = object(vars.filter((v) => v.input === true).map(variable));
|
|
52
|
+
const messages = object((action.messages ?? []).map((m) => {
|
|
53
|
+
const fields = Object.entries(m.fields ?? {}).map(([field, type]) => `${key(field)}: ${typeOf(type)}`);
|
|
54
|
+
// A message without fields may be sent with no payload at all (`emit("done")`).
|
|
55
|
+
return `${key(m.name ?? "")}: ${fields.length === 0 ? "Record<string, never> | null" : object(fields)}`;
|
|
56
|
+
}));
|
|
57
|
+
return `export interface ${name} {\n args: ${args};\n state: ${state};\n inputs: ${inputs};\n messages: ${messages};\n}\n`;
|
|
58
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { Json } from "../protocol.js";
|
|
2
|
+
/** A one-line reason `generate` stops; the CLI prints it and exits 1. */
|
|
3
|
+
export declare class GenerateError extends Error {
|
|
4
|
+
}
|
|
5
|
+
export interface ServerColumn {
|
|
6
|
+
name?: string;
|
|
7
|
+
type?: string;
|
|
8
|
+
enum?: Json[] | null;
|
|
9
|
+
label?: string | null;
|
|
10
|
+
hidden?: boolean;
|
|
11
|
+
readonly?: boolean;
|
|
12
|
+
mandatory?: boolean;
|
|
13
|
+
[key: string]: unknown;
|
|
14
|
+
}
|
|
15
|
+
export interface ServerField {
|
|
16
|
+
name?: string;
|
|
17
|
+
type?: string;
|
|
18
|
+
label?: string | null;
|
|
19
|
+
required?: boolean;
|
|
20
|
+
choices?: Json[] | null;
|
|
21
|
+
default?: Json;
|
|
22
|
+
widget?: string | null;
|
|
23
|
+
options?: Json;
|
|
24
|
+
[key: string]: unknown;
|
|
25
|
+
}
|
|
26
|
+
/** A run's shared variable. */
|
|
27
|
+
export interface ServerStateVar {
|
|
28
|
+
name?: string;
|
|
29
|
+
type?: string;
|
|
30
|
+
default?: Json;
|
|
31
|
+
input?: boolean;
|
|
32
|
+
}
|
|
33
|
+
/** A message a run may send, with its payload fields' types. */
|
|
34
|
+
export interface ServerMessage {
|
|
35
|
+
name?: string;
|
|
36
|
+
fields?: Record<string, string> | null;
|
|
37
|
+
}
|
|
38
|
+
export interface ServerTableAction {
|
|
39
|
+
name?: string;
|
|
40
|
+
label?: string | null;
|
|
41
|
+
per_row?: boolean;
|
|
42
|
+
fields?: ServerField[] | null;
|
|
43
|
+
state?: ServerStateVar[] | null;
|
|
44
|
+
messages?: ServerMessage[] | null;
|
|
45
|
+
[key: string]: unknown;
|
|
46
|
+
}
|
|
47
|
+
export interface ServerTable {
|
|
48
|
+
name?: string;
|
|
49
|
+
key?: string;
|
|
50
|
+
load?: string;
|
|
51
|
+
persist?: boolean;
|
|
52
|
+
live?: Json;
|
|
53
|
+
read_only?: boolean;
|
|
54
|
+
id_column?: string;
|
|
55
|
+
title?: string | null;
|
|
56
|
+
collection_limit?: number;
|
|
57
|
+
sortable?: string[] | null;
|
|
58
|
+
filter?: string[] | null;
|
|
59
|
+
default_sort?: {
|
|
60
|
+
column: string;
|
|
61
|
+
direction: "asc" | "desc";
|
|
62
|
+
} | null;
|
|
63
|
+
columns?: ServerColumn[] | null;
|
|
64
|
+
references?: {
|
|
65
|
+
name?: string;
|
|
66
|
+
column?: string;
|
|
67
|
+
table?: string;
|
|
68
|
+
}[] | null;
|
|
69
|
+
has_many?: {
|
|
70
|
+
name?: string;
|
|
71
|
+
table?: string;
|
|
72
|
+
foreign_key?: string;
|
|
73
|
+
}[] | null;
|
|
74
|
+
actions?: ServerTableAction[] | null;
|
|
75
|
+
[key: string]: unknown;
|
|
76
|
+
}
|
|
77
|
+
export interface ServerAction {
|
|
78
|
+
name?: string;
|
|
79
|
+
key?: string;
|
|
80
|
+
kind?: string;
|
|
81
|
+
title?: string | null;
|
|
82
|
+
fields?: ServerField[] | null;
|
|
83
|
+
params?: Json[] | null;
|
|
84
|
+
state?: ServerStateVar[] | null;
|
|
85
|
+
messages?: ServerMessage[] | null;
|
|
86
|
+
[key: string]: unknown;
|
|
87
|
+
}
|
|
88
|
+
export interface ServerSchema {
|
|
89
|
+
hash?: string;
|
|
90
|
+
tables?: ServerTable[] | null;
|
|
91
|
+
actions?: ServerAction[] | null;
|
|
92
|
+
[key: string]: unknown;
|
|
93
|
+
}
|
|
94
|
+
/** Refuse the first entity member that takes a name the model reserves. */
|
|
95
|
+
export declare function refuseReservedNames(schema: ServerSchema): void;
|
|
96
|
+
/** Whether `value` looks like a schema at all: an object with a `tables` array. */
|
|
97
|
+
export declare function isSchema(value: unknown): value is ServerSchema;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// The shape of `/v1/schema` as far as code generation reads it. Every
|
|
2
|
+
// field is optional: the emitter treats a missing one like the server's
|
|
3
|
+
// default.
|
|
4
|
+
import { reservedClash } from "../reserved.js";
|
|
5
|
+
/** A one-line reason `generate` stops; the CLI prints it and exits 1. */
|
|
6
|
+
export class GenerateError extends Error {
|
|
7
|
+
}
|
|
8
|
+
/** Refuse the first entity member that takes a name the model reserves. */
|
|
9
|
+
export function refuseReservedNames(schema) {
|
|
10
|
+
for (const table of schema.tables ?? []) {
|
|
11
|
+
const clash = reservedClash(table.name ?? "", [
|
|
12
|
+
...(table.columns ?? []).filter((c) => c.name !== "id" && c.hidden !== true).map((c) => ({ kind: "column", name: c.name ?? "" })),
|
|
13
|
+
...[...(table.references ?? []), ...(table.has_many ?? [])].map((r) => ({ kind: "reference", name: r.name ?? "" })),
|
|
14
|
+
...(table.actions ?? []).filter((a) => a.per_row === true).map((a) => ({ kind: "action", name: a.name ?? "" })),
|
|
15
|
+
]);
|
|
16
|
+
if (clash)
|
|
17
|
+
throw new GenerateError(clash);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/** Whether `value` looks like a schema at all: an object with a `tables` array. */
|
|
21
|
+
export function isSchema(value) {
|
|
22
|
+
return value !== null && typeof value === "object" && Array.isArray(value.tables);
|
|
23
|
+
}
|