@protobase/cli 0.18.0 → 0.19.1
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/dist/build/deploy-bundle.js +2 -0
- package/dist/build/entry-code.js +2 -2
- package/dist/build/view-check.js +18 -0
- package/dist/db/read-only-db.js +11 -0
- package/dist/doctor/check.js +1 -7
- package/dist/doctor/field-shapes.js +58 -0
- package/dist/doctor/index-issues.js +36 -0
- package/dist/doctor/page-shapes.js +43 -0
- package/dist/doctor/read-shapes.js +18 -0
- package/dist/doctor/run.js +11 -3
- package/dist/doctor/shape-filters.js +96 -0
- package/dist/doctor/view-shapes.js +167 -0
- package/dist/package.js +1 -1
- package/dist/project/convention-files.js +7 -0
- package/dist/project/import-project-file.js +13 -3
- package/dist/project/load-data-config.js +30 -0
- package/dist/serve/host-modules.js +4 -4
- package/dist/serve/lifecycle.js +1 -1
- package/package.json +6 -6
- package/dist/doctor/load-config.js +0 -10
|
@@ -6,6 +6,7 @@ import { copyPackages } from "./packages/copy-packages.js";
|
|
|
6
6
|
import { buildTheme } from "./theme.js";
|
|
7
7
|
import { shellForBuild, shellManifestFile } from "./shell.js";
|
|
8
8
|
import { checkUi } from "./ui-check.js";
|
|
9
|
+
import { checkViews } from "./view-check.js";
|
|
9
10
|
import { existsSync } from "node:fs";
|
|
10
11
|
import path from "node:path";
|
|
11
12
|
import { cp, readFile, rm, writeFile } from "node:fs/promises";
|
|
@@ -49,6 +50,7 @@ const buildDeployBundle = async ({ projectDir, outDir, bun, extensions = [], onW
|
|
|
49
50
|
extensions,
|
|
50
51
|
components
|
|
51
52
|
});
|
|
53
|
+
await checkViews(projectDir);
|
|
52
54
|
await checkUi(projectDir, extensions, components);
|
|
53
55
|
const packages = native.length > 0 ? await copyPackages(native, path.join(outDir, bundleNodeModules)) : [];
|
|
54
56
|
const publicDir = path.join(outDir, bundleLayout.public);
|
package/dist/build/entry-code.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { moduleFile } from "../module-file.js";
|
|
2
|
+
import { uiDirs } from "../project/convention-files.js";
|
|
2
3
|
import { functionFiles } from "../project/functions-folder.js";
|
|
3
|
-
import { existsSync
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
4
5
|
import path from "node:path";
|
|
5
6
|
//#region src/build/entry-code.ts
|
|
6
7
|
const conventionModule = moduleFile("../project/convention", import.meta.url);
|
|
7
|
-
const uiDirs = (configDir) => readdirSync(configDir, { withFileTypes: true }).filter((entry) => entry.isDirectory() && existsSync(path.join(configDir, entry.name, "ui.ts"))).map((entry) => entry.name).sort();
|
|
8
8
|
const bundleEntryCode = (projectDir, extensions = []) => {
|
|
9
9
|
const custom = path.join(projectDir, "protobase.config.ts");
|
|
10
10
|
const configDir = path.join(projectDir, "config");
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { loadDataConfig } from "../project/load-data-config.js";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { checkViewQueries } from "@protobase/server";
|
|
5
|
+
//#region src/build/view-check.ts
|
|
6
|
+
/**
|
|
7
|
+
* Fails the build, instead of a page once it asks, when a view filters, counts, sorts or searches by a field that its
|
|
8
|
+
* resource does not mark for it. It loads the config folder once in Node, config/index.ts and config/<dir>/ui.ts with
|
|
9
|
+
* what they import; protobase.config.ts does not run, so the build still needs no secrets and no database.
|
|
10
|
+
*/
|
|
11
|
+
const checkViews = async (projectDir) => {
|
|
12
|
+
const configDir = path.join(projectDir, "config");
|
|
13
|
+
if (!existsSync(path.join(configDir, "index.ts"))) return;
|
|
14
|
+
const { models, views } = await loadDataConfig(configDir);
|
|
15
|
+
checkViewQueries(models, views);
|
|
16
|
+
};
|
|
17
|
+
//#endregion
|
|
18
|
+
export { checkViews };
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Kysely, PostgresDialect } from "kysely";
|
|
2
|
+
import pg from "pg";
|
|
3
|
+
//#region src/db/read-only-db.ts
|
|
4
|
+
/** Kysely over one read-only session, for what the query package reads of the database, as the server reads it. */
|
|
5
|
+
const readOnlyDb = (url) => new Kysely({ dialect: new PostgresDialect({ pool: new pg.Pool({
|
|
6
|
+
connectionString: url,
|
|
7
|
+
max: 1,
|
|
8
|
+
options: "-c default_transaction_read_only=on"
|
|
9
|
+
}) }) });
|
|
10
|
+
//#endregion
|
|
11
|
+
export { readOnlyDb };
|
package/dist/doctor/check.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { tableKey } from "../db/model.js";
|
|
2
|
-
import { isUniqueSet, keyColumns
|
|
2
|
+
import { isUniqueSet, keyColumns } from "../db/keys.js";
|
|
3
3
|
import { checkEnumValues } from "../infer/check-values.js";
|
|
4
4
|
import { typesCompatible } from "../infer/compat.js";
|
|
5
5
|
import { columnDefault } from "../infer/defaults.js";
|
|
@@ -39,12 +39,6 @@ const checkField = (model, table, name) => {
|
|
|
39
39
|
resource: model.name,
|
|
40
40
|
message: `${name}: declared .dbDefault() but ${column.name} has no default the database computes`
|
|
41
41
|
});
|
|
42
|
-
if (field.filterable && table.kind !== "view" && !leadingIndex(table, column.name, model.tenant && model.fields[model.tenant]?.column)) issues.push({
|
|
43
|
-
severity: "warning",
|
|
44
|
-
resource: model.name,
|
|
45
|
-
message: `${name}: filterable but ${column.name} has no index`,
|
|
46
|
-
suggestion: `CREATE INDEX ON ${quoteIdent(table.schema)}.${quoteIdent(table.name)} (${quoteIdent(column.name)});`
|
|
47
|
-
});
|
|
48
42
|
return issues;
|
|
49
43
|
};
|
|
50
44
|
const checkKey = (model, table) => {
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { anyOf, compare, holds, searchFor } from "./shape-filters.js";
|
|
2
|
+
//#region src/doctor/field-shapes.ts
|
|
3
|
+
const filterOn = (field) => field.type === "json" ? holds(field) : compare(field);
|
|
4
|
+
/**
|
|
5
|
+
* What the API takes of a resource from any caller, whatever the views show: a filter on each `.filterable()` field, a
|
|
6
|
+
* sort by each `.sortable()` one, `search("...")` over its search fields, and the rows of a role limited to its own
|
|
7
|
+
* records, which `.owner()` names.
|
|
8
|
+
*/
|
|
9
|
+
const fieldShapes = (model) => {
|
|
10
|
+
const fields = Object.values(model.fields);
|
|
11
|
+
const at = `GET /${model.name}`;
|
|
12
|
+
return [
|
|
13
|
+
...fields.filter((field) => field.sortable).map((field) => ({
|
|
14
|
+
resource: model.name,
|
|
15
|
+
request: `${at}?order_by=${field.name} asc`,
|
|
16
|
+
from: `"${field.name}" is sortable`,
|
|
17
|
+
query: { sort: [[field.name, "asc"]] }
|
|
18
|
+
})),
|
|
19
|
+
...fields.filter((field) => field.filterable).map((field) => ({
|
|
20
|
+
resource: model.name,
|
|
21
|
+
request: `${at}?filter=${field.name} = …`,
|
|
22
|
+
from: `"${field.name}" is filterable`,
|
|
23
|
+
query: { filter: filterOn(field) }
|
|
24
|
+
})),
|
|
25
|
+
...model.search?.length ? [{
|
|
26
|
+
resource: model.name,
|
|
27
|
+
request: `${at}?filter=search("…")`,
|
|
28
|
+
from: `the search over ${model.search.join(", ")}`,
|
|
29
|
+
query: { filter: searchFor }
|
|
30
|
+
}] : [],
|
|
31
|
+
...ownerShapes(model)
|
|
32
|
+
];
|
|
33
|
+
};
|
|
34
|
+
const ownerShapes = (model) => {
|
|
35
|
+
const owner = model.owner === void 0 ? void 0 : model.fields[model.owner];
|
|
36
|
+
if (!owner) return [];
|
|
37
|
+
return [{
|
|
38
|
+
resource: model.name,
|
|
39
|
+
request: `GET /${model.name} by a role with own capabilities`,
|
|
40
|
+
from: `"${owner.name}" is the owner`,
|
|
41
|
+
query: {},
|
|
42
|
+
scope: { rowFilter: compare(owner) }
|
|
43
|
+
}];
|
|
44
|
+
};
|
|
45
|
+
/** The names of the records a relation points at, which the app reads by their keys. */
|
|
46
|
+
const lookupShapes = (model, models) => Object.values(model.fields).flatMap((field) => {
|
|
47
|
+
const target = field.relation ? models[field.relation.resource] : void 0;
|
|
48
|
+
const key = target?.primaryKey.length === 1 ? target.fields[target.primaryKey[0]] : void 0;
|
|
49
|
+
if (!target || !key) return [];
|
|
50
|
+
return [{
|
|
51
|
+
resource: target.name,
|
|
52
|
+
request: `GET /${target.name}?filter=${key.name} in (…)`,
|
|
53
|
+
from: `the names ${model.name}.${field.name} shows`,
|
|
54
|
+
query: { filter: anyOf(key) }
|
|
55
|
+
}];
|
|
56
|
+
});
|
|
57
|
+
//#endregion
|
|
58
|
+
export { fieldShapes, lookupShapes };
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { uncoveredColumns } from "@protobase/query";
|
|
2
|
+
//#region src/doctor/index-issues.ts
|
|
3
|
+
const kinds = {
|
|
4
|
+
btree: "index",
|
|
5
|
+
trigram: "pg_trgm index",
|
|
6
|
+
digits: "pg_trgm index",
|
|
7
|
+
gin: "GIN index"
|
|
8
|
+
};
|
|
9
|
+
const served = (column) => column.need === "digits" ? `the digits of "${column.column}"` : `the column "${column.column}"`;
|
|
10
|
+
/**
|
|
11
|
+
* The reads of `model` that the scan guard refuses with the table's indexes, by the scan guard's own rule: one error per
|
|
12
|
+
* index to add, naming each read that needs it and where it comes from.
|
|
13
|
+
*/
|
|
14
|
+
const indexIssues = (model, table, shapes) => {
|
|
15
|
+
const refusals = /* @__PURE__ */ new Map();
|
|
16
|
+
for (const shape of shapes) {
|
|
17
|
+
if (shape.resource !== model.name) continue;
|
|
18
|
+
for (const column of uncoveredColumns(model, table, shape.query, shape.scope)) {
|
|
19
|
+
const refusal = refusals.get(column.suggestion) ?? {
|
|
20
|
+
column,
|
|
21
|
+
requests: /* @__PURE__ */ new Map()
|
|
22
|
+
};
|
|
23
|
+
const from = refusal.requests.get(shape.request) ?? [];
|
|
24
|
+
if (!from.includes(shape.from)) refusal.requests.set(shape.request, [...from, shape.from]);
|
|
25
|
+
refusals.set(column.suggestion, refusal);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return [...refusals.values()].map(({ column, requests }) => ({
|
|
29
|
+
severity: "error",
|
|
30
|
+
resource: model.name,
|
|
31
|
+
message: `no ${kinds[column.need]} serves ${served(column)}, so the API refuses:${[...requests].map(([request, from]) => `\n ${request} (${from.join("; ")})`).join("")}`,
|
|
32
|
+
suggestion: `${column.suggestion};`
|
|
33
|
+
}));
|
|
34
|
+
};
|
|
35
|
+
//#endregion
|
|
36
|
+
export { indexIssues };
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { configFilter, configSort, sortText } from "./shape-filters.js";
|
|
2
|
+
import { listSort } from "./view-shapes.js";
|
|
3
|
+
import { walkTree } from "@protobase/layout";
|
|
4
|
+
//#region src/doctor/page-shapes.ts
|
|
5
|
+
const listing = [
|
|
6
|
+
"RecordRow",
|
|
7
|
+
"Table",
|
|
8
|
+
"CardRow",
|
|
9
|
+
"RecordCard",
|
|
10
|
+
"Stat"
|
|
11
|
+
];
|
|
12
|
+
const text = (value) => typeof value === "string" ? value : void 0;
|
|
13
|
+
const blockSort = (node, model, view) => {
|
|
14
|
+
const given = configSort(model, text(node.props.sort));
|
|
15
|
+
if (given) return given;
|
|
16
|
+
return node.type === "RecordRow" ? listSort(model, view) : void 0;
|
|
17
|
+
};
|
|
18
|
+
const title = (node) => text(node.props.title) ?? text(node.props.label);
|
|
19
|
+
/** The reads of a composed page: each block that lists records, with its filter and sort. */
|
|
20
|
+
const pageShapes = (page, models, views) => {
|
|
21
|
+
const shapes = [];
|
|
22
|
+
walkTree(page.tree, (node) => {
|
|
23
|
+
const model = node.custom ? void 0 : models[text(node.props.resource) ?? ""];
|
|
24
|
+
if (!model || !listing.includes(node.type) || node.type === "RecordCard" && node.props.recordKey !== void 0) return;
|
|
25
|
+
const source = text(node.props.filter);
|
|
26
|
+
const filter = configFilter(model, source);
|
|
27
|
+
const sort = node.type === "Stat" ? void 0 : blockSort(node, model, views.find((view) => view.resource === model.name && !view.roles));
|
|
28
|
+
const params = [...source?.trim() ? [`filter=${source}`] : [], ...sort ? [`order_by=${sortText(sort)}`] : []];
|
|
29
|
+
const named = title(node);
|
|
30
|
+
shapes.push({
|
|
31
|
+
resource: model.name,
|
|
32
|
+
request: `GET /${model.name}${params.length > 0 ? `?${params.join("&")}` : ""}`,
|
|
33
|
+
from: `page "${page.name}", <${node.type}${named ? ` "${named}"` : ""}>`,
|
|
34
|
+
query: {
|
|
35
|
+
...filter && { filter },
|
|
36
|
+
...sort && { sort }
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
return shapes;
|
|
41
|
+
};
|
|
42
|
+
//#endregion
|
|
43
|
+
export { pageShapes };
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { fieldShapes, lookupShapes } from "./field-shapes.js";
|
|
2
|
+
import { viewShapes } from "./view-shapes.js";
|
|
3
|
+
import { pageShapes } from "./page-shapes.js";
|
|
4
|
+
//#region src/doctor/read-shapes.ts
|
|
5
|
+
/**
|
|
6
|
+
* Every read the API takes of the resources and the app makes of them, which the scan guard judges when it runs: what
|
|
7
|
+
* the fields allow, the relations' lookups, each view's pages and each composed page's blocks.
|
|
8
|
+
*/
|
|
9
|
+
const readShapes = ({ models, views, pages }) => {
|
|
10
|
+
const byName = Object.fromEntries(models.map((model) => [model.name, model]));
|
|
11
|
+
return [
|
|
12
|
+
...models.flatMap((model) => [...fieldShapes(model), ...lookupShapes(model, byName)]),
|
|
13
|
+
...views.flatMap((view) => viewShapes(view, byName)),
|
|
14
|
+
...pages.flatMap((page) => pageShapes(page, byName, views))
|
|
15
|
+
];
|
|
16
|
+
};
|
|
17
|
+
//#endregion
|
|
18
|
+
export { readShapes };
|
package/dist/doctor/run.js
CHANGED
|
@@ -1,15 +1,23 @@
|
|
|
1
|
+
import { loadDataConfig } from "../project/load-data-config.js";
|
|
1
2
|
import { connect } from "../db/connect.js";
|
|
2
3
|
import { introspect } from "../db/introspect.js";
|
|
4
|
+
import { readOnlyDb } from "../db/read-only-db.js";
|
|
3
5
|
import { checkResource } from "./check.js";
|
|
4
|
-
import {
|
|
6
|
+
import { indexIssues } from "./index-issues.js";
|
|
7
|
+
import { readShapes } from "./read-shapes.js";
|
|
8
|
+
import { tableIndexes } from "@protobase/query";
|
|
5
9
|
//#region src/doctor/run.ts
|
|
6
10
|
const format = (issue) => ` ${issue.severity.padEnd(7)} ${issue.resource}: ${issue.message}${issue.suggestion ? `\n fix: ${issue.suggestion}` : ""}\n`;
|
|
7
11
|
const runDoctor = async (options, out) => {
|
|
8
|
-
const
|
|
12
|
+
const config = await loadDataConfig(options.configDir);
|
|
13
|
+
const { models } = config;
|
|
9
14
|
if (models.length === 0) throw new Error(`No resources exported from ${options.configDir}/index.ts`);
|
|
10
15
|
const sql = connect(options.url);
|
|
11
16
|
const tables = await introspect(sql, { views: true }).finally(() => sql.end());
|
|
12
|
-
const
|
|
17
|
+
const db = readOnlyDb(options.url);
|
|
18
|
+
const shapes = readShapes(config);
|
|
19
|
+
const indexes = await Promise.all(models.map(async (model) => indexIssues(model, await tableIndexes(db, model), shapes))).finally(() => db.destroy());
|
|
20
|
+
const issues = models.flatMap((model, i) => [...checkResource(model, tables), ...indexes[i]]);
|
|
13
21
|
for (const issue of issues) out(format(issue));
|
|
14
22
|
const errors = issues.filter((i) => i.severity === "error").length;
|
|
15
23
|
out(`Checked ${models.length} resources: ${errors} errors, ${issues.length - errors} warnings.\n`);
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { checkFilter, checkOrderBy, parseFilter, parseOrderBy } from "@protobase/schema";
|
|
2
|
+
//#region src/doctor/shape-filters.ts
|
|
3
|
+
const span = {
|
|
4
|
+
start: 0,
|
|
5
|
+
end: 0
|
|
6
|
+
};
|
|
7
|
+
const number = {
|
|
8
|
+
kind: "number",
|
|
9
|
+
value: 0,
|
|
10
|
+
raw: "0",
|
|
11
|
+
span
|
|
12
|
+
};
|
|
13
|
+
const compare = (field, op = "=") => ({
|
|
14
|
+
kind: "compare",
|
|
15
|
+
op,
|
|
16
|
+
field,
|
|
17
|
+
value: number,
|
|
18
|
+
span
|
|
19
|
+
});
|
|
20
|
+
const isTrue = (field) => ({
|
|
21
|
+
kind: "compare",
|
|
22
|
+
op: "=",
|
|
23
|
+
field,
|
|
24
|
+
value: {
|
|
25
|
+
kind: "boolean",
|
|
26
|
+
value: true,
|
|
27
|
+
span
|
|
28
|
+
},
|
|
29
|
+
span
|
|
30
|
+
});
|
|
31
|
+
const anyOf = (field) => ({
|
|
32
|
+
kind: "in",
|
|
33
|
+
field,
|
|
34
|
+
values: [number],
|
|
35
|
+
span
|
|
36
|
+
});
|
|
37
|
+
/** A JSON field is filtered by a key's value (`tags:urgent`), a GIN index's read. */
|
|
38
|
+
const holds = (field) => ({
|
|
39
|
+
kind: "has",
|
|
40
|
+
field,
|
|
41
|
+
value: {
|
|
42
|
+
kind: "string",
|
|
43
|
+
value: "",
|
|
44
|
+
span
|
|
45
|
+
},
|
|
46
|
+
span
|
|
47
|
+
});
|
|
48
|
+
const searchFor = {
|
|
49
|
+
kind: "search",
|
|
50
|
+
text: "…",
|
|
51
|
+
span
|
|
52
|
+
};
|
|
53
|
+
const all = (filters) => {
|
|
54
|
+
const present = filters.filter((filter) => filter !== void 0);
|
|
55
|
+
return present.length < 2 ? present[0] : {
|
|
56
|
+
kind: "and",
|
|
57
|
+
args: present,
|
|
58
|
+
span
|
|
59
|
+
};
|
|
60
|
+
};
|
|
61
|
+
const any = (filters) => filters.length === 1 ? filters[0] : {
|
|
62
|
+
kind: "or",
|
|
63
|
+
args: filters,
|
|
64
|
+
span
|
|
65
|
+
};
|
|
66
|
+
const trusted = (model) => ({
|
|
67
|
+
...model,
|
|
68
|
+
fields: Object.fromEntries(Object.entries(model.fields).map(([name, field]) => [name, {
|
|
69
|
+
...field,
|
|
70
|
+
filterable: true,
|
|
71
|
+
sortable: true
|
|
72
|
+
}]))
|
|
73
|
+
});
|
|
74
|
+
/** A filter the config writes, such as a related section's, resolved against the resource; undefined when empty. */
|
|
75
|
+
const configFilter = (model, source) => {
|
|
76
|
+
if (!source?.trim()) return void 0;
|
|
77
|
+
const parsed = parseFilter(source);
|
|
78
|
+
if (!parsed.ok) throw new Error(`The filter "${source}" of ${model.name} does not parse: ${parsed.errors[0].message}`);
|
|
79
|
+
if (!parsed.ast) return void 0;
|
|
80
|
+
const checked = checkFilter(trusted(model), parsed.ast);
|
|
81
|
+
if (!checked.ok) throw new Error(`The filter "${source}" does not fit ${model.name}: ${checked.errors[0].message}`);
|
|
82
|
+
return checked.filter;
|
|
83
|
+
};
|
|
84
|
+
/** A sort the config writes, such as `published_at desc`, resolved against the resource; undefined when empty. */
|
|
85
|
+
const configSort = (model, source) => {
|
|
86
|
+
if (!source?.trim()) return void 0;
|
|
87
|
+
const parsed = parseOrderBy(source);
|
|
88
|
+
if (!parsed.ok) throw new Error(`The sort "${source}" of ${model.name} does not parse: ${parsed.errors[0].message}`);
|
|
89
|
+
const checked = checkOrderBy(trusted(model), parsed.items);
|
|
90
|
+
if (!checked.ok) throw new Error(`The sort "${source}" does not fit ${model.name}: ${checked.errors[0].message}`);
|
|
91
|
+
return checked.sort;
|
|
92
|
+
};
|
|
93
|
+
/** A sort as `order_by` writes it: `name asc, id desc`. */
|
|
94
|
+
const sortText = (sort) => sort.map(([field, direction]) => `${field} ${direction}`).join(", ");
|
|
95
|
+
//#endregion
|
|
96
|
+
export { all, any, anyOf, compare, configFilter, configSort, holds, isTrue, searchFor, sortText };
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { all, any, anyOf, compare, configFilter, configSort, isTrue, sortText } from "./shape-filters.js";
|
|
2
|
+
//#region src/doctor/view-shapes.ts
|
|
3
|
+
const numberTypes = [
|
|
4
|
+
"integer",
|
|
5
|
+
"bigint",
|
|
6
|
+
"decimal"
|
|
7
|
+
];
|
|
8
|
+
const request = (resource, filter, sort) => {
|
|
9
|
+
const params = [...filter ? [`filter=${filter}`] : [], ...sort ? [`order_by=${sortText(sort)}`] : []];
|
|
10
|
+
return `GET /${resource}${params.length > 0 ? `?${params.join("&")}` : ""}`;
|
|
11
|
+
};
|
|
12
|
+
/** The order a list opens in, as the app asks for it: the view's sort, or the primary key's. */
|
|
13
|
+
const listSort = (model, view) => {
|
|
14
|
+
return [view?.list?.sort?.[0] ?? [model.primaryKey[0], "asc"]];
|
|
15
|
+
};
|
|
16
|
+
const widgetShapes = (model, widget, from) => {
|
|
17
|
+
const shape = (request, query) => ({
|
|
18
|
+
resource: model.name,
|
|
19
|
+
request,
|
|
20
|
+
from,
|
|
21
|
+
query
|
|
22
|
+
});
|
|
23
|
+
const field = (name) => model.fields[name];
|
|
24
|
+
if (widget.kind === "flags") {
|
|
25
|
+
const fields = widget.fields.flatMap((name) => field(name) ? [field(name)] : []);
|
|
26
|
+
if (fields.length === 0) return [];
|
|
27
|
+
return [...fields.map((flag) => shape(`GET /${model.name}:facets?field=${flag.name}`, { groupBy: flag.name })), shape(request(model.name, fields.map((flag) => `${flag.name} = true`).join(" OR "), void 0), { filter: any(fields.map(isTrue)) })];
|
|
28
|
+
}
|
|
29
|
+
const target = field(widget.field);
|
|
30
|
+
if (!target) return [];
|
|
31
|
+
if (widget.kind === "toggle") return [shape(request(model.name, `${target.name} = true`, void 0), { filter: isTrue(target) })];
|
|
32
|
+
if (widget.kind === "dateRange") return [shape(request(model.name, `${target.name} >= …`, void 0), { filter: compare(target, ">=") })];
|
|
33
|
+
if (widget.kind === "range" || numberTypes.includes(target.type)) return [shape(request(model.name, `${target.name} >= … AND ${target.name} <= …`, void 0), { filter: all([compare(target, ">="), compare(target, "<=")]) })];
|
|
34
|
+
return [shape(`GET /${model.name}:facets?field=${target.name}`, { groupBy: target.name }), shape(request(model.name, `${target.name} in (…)`, void 0), { filter: anyOf(target) })];
|
|
35
|
+
};
|
|
36
|
+
const relatedShapes = (item, owner, models) => {
|
|
37
|
+
const target = models[item.resource];
|
|
38
|
+
const match = target?.fields[item.field];
|
|
39
|
+
if (!target || !match) return [];
|
|
40
|
+
const from = `related "${item.title}" on the ${owner} record page`;
|
|
41
|
+
const filter = configFilter(target, item.filter);
|
|
42
|
+
const sort = configSort(target, item.sort);
|
|
43
|
+
const extra = item.filter ? ` AND (${item.filter})` : "";
|
|
44
|
+
const via = item.through && models[item.through.resource];
|
|
45
|
+
const viaField = via && via.fields[item.through.field];
|
|
46
|
+
const join = via && viaField ? [{
|
|
47
|
+
resource: via.name,
|
|
48
|
+
request: request(via.name, `${viaField.name} = …${item.through.filter ? ` AND (${item.through.filter})` : ""}`, void 0),
|
|
49
|
+
from,
|
|
50
|
+
query: { filter: all([compare(viaField), configFilter(via, item.through.filter)]) }
|
|
51
|
+
}] : [];
|
|
52
|
+
const matched = item.through ? anyOf(match) : compare(match);
|
|
53
|
+
const text = item.through ? `${match.name} in (…)` : `${match.name} = …`;
|
|
54
|
+
return [...join, {
|
|
55
|
+
resource: target.name,
|
|
56
|
+
request: request(target.name, `${text}${extra}`, sort),
|
|
57
|
+
from,
|
|
58
|
+
query: {
|
|
59
|
+
filter: all([matched, filter]),
|
|
60
|
+
...sort && { sort }
|
|
61
|
+
}
|
|
62
|
+
}];
|
|
63
|
+
};
|
|
64
|
+
const galleryShapes = (item, owner, models) => {
|
|
65
|
+
const holder = item.photos && models[item.photos.resource];
|
|
66
|
+
const field = holder?.fields[item.photos.field];
|
|
67
|
+
if (!holder || !field) return [];
|
|
68
|
+
const sort = configSort(holder, item.photos.sort);
|
|
69
|
+
return [{
|
|
70
|
+
resource: holder.name,
|
|
71
|
+
request: request(holder.name, `${field.name} = …`, sort),
|
|
72
|
+
from: `gallery "${item.title}" on the ${owner} record page`,
|
|
73
|
+
query: {
|
|
74
|
+
filter: compare(field),
|
|
75
|
+
...sort && { sort }
|
|
76
|
+
}
|
|
77
|
+
}];
|
|
78
|
+
};
|
|
79
|
+
const displayShapes = (model, view, models) => (view.list?.displays ?? []).flatMap((display) => {
|
|
80
|
+
if (display.kind === "map") {
|
|
81
|
+
const latitude = model.fields[display.latitude];
|
|
82
|
+
const longitude = model.fields[display.longitude];
|
|
83
|
+
if (!latitude || !longitude) return [];
|
|
84
|
+
const filter = all([
|
|
85
|
+
compare(latitude, ">="),
|
|
86
|
+
compare(latitude, "<="),
|
|
87
|
+
compare(longitude, ">="),
|
|
88
|
+
compare(longitude, "<=")
|
|
89
|
+
]);
|
|
90
|
+
return [{
|
|
91
|
+
resource: model.name,
|
|
92
|
+
request: `GET /${model.name}:map?filter=${latitude.name} >= … AND ${latitude.name} <= … AND ${longitude.name} >= … AND ${longitude.name} <= …`,
|
|
93
|
+
from: `the map display "${display.name}"`,
|
|
94
|
+
query: { filter }
|
|
95
|
+
}];
|
|
96
|
+
}
|
|
97
|
+
const photos = display.kind === "cards" ? display.photos : void 0;
|
|
98
|
+
const holder = photos && models[photos.resource];
|
|
99
|
+
const field = holder?.fields[photos.field];
|
|
100
|
+
if (!holder || !field) return [];
|
|
101
|
+
const sort = configSort(holder, photos.sort);
|
|
102
|
+
return [{
|
|
103
|
+
resource: holder.name,
|
|
104
|
+
request: request(holder.name, `${field.name} in (…)`, sort),
|
|
105
|
+
from: `the photos of the ${model.name} cards`,
|
|
106
|
+
query: {
|
|
107
|
+
filter: anyOf(field),
|
|
108
|
+
...sort && { sort }
|
|
109
|
+
}
|
|
110
|
+
}];
|
|
111
|
+
});
|
|
112
|
+
const searchesShapes = (view, models) => {
|
|
113
|
+
const unseen = view.searchesOf?.unseen;
|
|
114
|
+
const target = view.searchesOf && models[view.searchesOf.resource];
|
|
115
|
+
const field = unseen && target?.fields[unseen.field];
|
|
116
|
+
if (!target || !field) return [];
|
|
117
|
+
const sort = [[field.name, "desc"]];
|
|
118
|
+
return [{
|
|
119
|
+
resource: target.name,
|
|
120
|
+
request: request(target.name, `${field.name} > …`, sort),
|
|
121
|
+
from: `the new records of each saved search of ${view.resource}`,
|
|
122
|
+
query: {
|
|
123
|
+
filter: compare(field, ">"),
|
|
124
|
+
sort
|
|
125
|
+
}
|
|
126
|
+
}];
|
|
127
|
+
};
|
|
128
|
+
const recentShapes = (model, view) => {
|
|
129
|
+
const recent = view.nav?.recent;
|
|
130
|
+
if (!recent) return [];
|
|
131
|
+
const filter = configFilter(model, recent.filter);
|
|
132
|
+
const sort = configSort(model, recent.orderBy) ?? listSort(model, view);
|
|
133
|
+
return [{
|
|
134
|
+
resource: model.name,
|
|
135
|
+
request: request(model.name, recent.filter, sort),
|
|
136
|
+
from: "the recent records in the sidebar",
|
|
137
|
+
query: {
|
|
138
|
+
...filter && { filter },
|
|
139
|
+
sort
|
|
140
|
+
}
|
|
141
|
+
}];
|
|
142
|
+
};
|
|
143
|
+
/**
|
|
144
|
+
* The reads a view's pages make: its list in its order, the filter widgets and their counts, its displays' maps and
|
|
145
|
+
* photos, the records of its related sections and the photos of its galleries, the recent records in the sidebar and a saved search's new records.
|
|
146
|
+
*/
|
|
147
|
+
const viewShapes = (view, models) => {
|
|
148
|
+
const model = models[view.resource];
|
|
149
|
+
if (!model) return [];
|
|
150
|
+
const sort = listSort(model, view);
|
|
151
|
+
const list = view.roles ? `the ${model.name} list for ${view.roles.join(", ")}` : `the ${model.name} list`;
|
|
152
|
+
return [
|
|
153
|
+
{
|
|
154
|
+
resource: model.name,
|
|
155
|
+
request: request(model.name, void 0, sort),
|
|
156
|
+
from: `${list}'s order`,
|
|
157
|
+
query: { sort }
|
|
158
|
+
},
|
|
159
|
+
...view.filters.flatMap((widget) => widgetShapes(model, widget, `${list}'s ${widget.kind === "dateRange" ? "date range" : widget.kind} filter`)),
|
|
160
|
+
...displayShapes(model, view, models),
|
|
161
|
+
...view.layout.flatMap((item) => item.kind === "related" ? relatedShapes(item, model.name, models) : item.kind === "gallery" ? galleryShapes(item, model.name, models) : []),
|
|
162
|
+
...recentShapes(model, view),
|
|
163
|
+
...searchesShapes(view, models)
|
|
164
|
+
];
|
|
165
|
+
};
|
|
166
|
+
//#endregion
|
|
167
|
+
export { listSort, viewShapes };
|
package/dist/package.js
CHANGED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
//#region src/project/convention-files.ts
|
|
4
|
+
/** The folders of a config folder that hold a ui.ts, whose views the convention adds to config/index.ts's, in name order. */
|
|
5
|
+
const uiDirs = (configDir) => readdirSync(configDir, { withFileTypes: true }).filter((entry) => entry.isDirectory() && existsSync(path.join(configDir, entry.name, "ui.ts"))).map((entry) => entry.name).sort();
|
|
6
|
+
//#endregion
|
|
7
|
+
export { uiDirs };
|
|
@@ -1,11 +1,21 @@
|
|
|
1
1
|
import { runnerImport } from "vite";
|
|
2
2
|
//#region src/project/import-project-file.ts
|
|
3
|
-
const importProjectFile = async (file) => {
|
|
3
|
+
const importProjectFile = async (file, plugins = []) => {
|
|
4
4
|
const { module } = await runnerImport(file, {
|
|
5
5
|
configFile: false,
|
|
6
|
-
logLevel: "error"
|
|
6
|
+
logLevel: "error",
|
|
7
|
+
plugins
|
|
7
8
|
});
|
|
8
9
|
return module;
|
|
9
10
|
};
|
|
11
|
+
/**
|
|
12
|
+
* Imports `code` as if it were the project's file `file`, which need not exist: its imports resolve from there, and
|
|
13
|
+
* the modules it imports are loaded once, however many of them import each other.
|
|
14
|
+
*/
|
|
15
|
+
const importProjectCode = (file, code) => importProjectFile(file, [{
|
|
16
|
+
name: "protobase-project-code",
|
|
17
|
+
resolveId: (id) => id === file ? `\0${file}` : void 0,
|
|
18
|
+
load: (id) => id === `\0${file}` ? code : void 0
|
|
19
|
+
}]);
|
|
10
20
|
//#endregion
|
|
11
|
-
export { importProjectFile };
|
|
21
|
+
export { importProjectCode, importProjectFile };
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { importProjectCode } from "./import-project-file.js";
|
|
2
|
+
import { uiDirs } from "./convention-files.js";
|
|
3
|
+
import { conventionConfig } from "./convention.js";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { configExports } from "@protobase/server";
|
|
6
|
+
//#region src/project/load-data-config.ts
|
|
7
|
+
/**
|
|
8
|
+
* The resources, views and pages of a config folder, as the config module has them by convention: config/index.ts and
|
|
9
|
+
* every config/<dir>/ui.ts. Only those files and what they import run, not protobase.config.ts, which may need secrets.
|
|
10
|
+
*/
|
|
11
|
+
const loadDataConfig = async (configDir) => {
|
|
12
|
+
const dirs = uiDirs(configDir);
|
|
13
|
+
const code = [
|
|
14
|
+
`import * as index from ${JSON.stringify(path.join(configDir, "index.ts"))}`,
|
|
15
|
+
...dirs.map((dir, i) => `import * as ui${i} from ${JSON.stringify(path.join(configDir, dir, "ui.ts"))}`),
|
|
16
|
+
"export { index }",
|
|
17
|
+
`export const ui = [${dirs.map((dir, i) => `[${JSON.stringify(dir)}, ui${i}]`).join(", ")}]`,
|
|
18
|
+
""
|
|
19
|
+
].join("\n");
|
|
20
|
+
const loaded = await importProjectCode(path.join(configDir, "protobase-data-config.js"), code);
|
|
21
|
+
const config = conventionConfig(loaded.index, loaded.ui);
|
|
22
|
+
const { resources, views, pages } = configExports(config);
|
|
23
|
+
return {
|
|
24
|
+
models: resources.map((resource) => resource.toModel()),
|
|
25
|
+
views: views.map((view) => view.toModel()),
|
|
26
|
+
pages: pages.map((page) => page.toModel())
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
//#endregion
|
|
30
|
+
export { loadDataConfig };
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import * as server from "@protobase/server";
|
|
2
2
|
import * as postgres$1 from "postgres";
|
|
3
|
+
import * as query from "@protobase/query";
|
|
4
|
+
import * as kysely from "kysely";
|
|
5
|
+
import * as pg$1 from "pg";
|
|
3
6
|
import * as schema from "@protobase/schema";
|
|
7
|
+
import * as layout from "@protobase/layout";
|
|
4
8
|
import * as nodeServer from "@hono/node-server";
|
|
5
|
-
import * as pg$1 from "pg";
|
|
6
|
-
import * as kysely from "kysely";
|
|
7
9
|
import * as hono from "hono";
|
|
8
10
|
import * as reactEmailRender from "@react-email/render";
|
|
9
11
|
import * as apiReference from "@scalar/hono-api-reference";
|
|
@@ -17,8 +19,6 @@ import * as kyselyPostgres from "kysely/helpers/postgres";
|
|
|
17
19
|
import * as react from "react";
|
|
18
20
|
import * as reactJsxRuntime from "react/jsx-runtime";
|
|
19
21
|
import * as zod from "zod";
|
|
20
|
-
import * as layout from "@protobase/layout";
|
|
21
|
-
import * as query from "@protobase/query";
|
|
22
22
|
//#region src/serve/host-modules.ts
|
|
23
23
|
const hostModules = {
|
|
24
24
|
"@protobase/schema": schema,
|
package/dist/serve/lifecycle.js
CHANGED
|
@@ -3,8 +3,8 @@ import { createPgDb } from "../project/pg-db.js";
|
|
|
3
3
|
import { createServeApp } from "./app.js";
|
|
4
4
|
import { startCleanupTimer } from "./cleanup-timer.js";
|
|
5
5
|
import { checkAuthSchema, checkChanges, checkJobsSchema, checkWebhooksSchema, configExports, createFileCleanup, startJobs } from "@protobase/server";
|
|
6
|
-
import { createAdaptorServer } from "@hono/node-server";
|
|
7
6
|
import pg from "pg";
|
|
7
|
+
import { createAdaptorServer } from "@hono/node-server";
|
|
8
8
|
//#region src/serve/lifecycle.ts
|
|
9
9
|
const listen = (server, port) => new Promise((resolve, reject) => {
|
|
10
10
|
server.once("error", reject);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@protobase/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.1",
|
|
4
4
|
"description": "The protobase command: scaffold, doctor, dev, build and serve a Protobase project.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -34,11 +34,11 @@
|
|
|
34
34
|
"dependencies": {
|
|
35
35
|
"@hono/node-server": "^2.1.3",
|
|
36
36
|
"@hono/vite-dev-server": "^0.26.1",
|
|
37
|
-
"@protobase/layout": "0.
|
|
38
|
-
"@protobase/query": "0.
|
|
39
|
-
"@protobase/schema": "0.
|
|
40
|
-
"@protobase/server": "0.
|
|
41
|
-
"@protobase/ui": "0.
|
|
37
|
+
"@protobase/layout": "0.19.1",
|
|
38
|
+
"@protobase/query": "0.19.1",
|
|
39
|
+
"@protobase/schema": "0.19.1",
|
|
40
|
+
"@protobase/server": "0.19.1",
|
|
41
|
+
"@protobase/ui": "0.19.1",
|
|
42
42
|
"@react-email/render": "^2.1.0",
|
|
43
43
|
"@scalar/hono-api-reference": "^0.12.10",
|
|
44
44
|
"@tailwindcss/vite": "^4.3.3",
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
import { importProjectFile } from "../project/import-project-file.js";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
//#region src/doctor/load-config.ts
|
|
4
|
-
const isResource = (value) => typeof value === "object" && value !== null && "toModel" in value && "ignoredColumns" in value && typeof value.toModel === "function";
|
|
5
|
-
const loadResourceModels = async (configDir) => {
|
|
6
|
-
const exports = await importProjectFile(path.resolve(configDir, "index.ts"));
|
|
7
|
-
return Object.values(exports).filter(isResource).map((resource) => resource.toModel());
|
|
8
|
-
};
|
|
9
|
-
//#endregion
|
|
10
|
-
export { loadResourceModels };
|