@protobase/cli 0.0.0-stage → 0.1.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/LICENSE +202 -0
- package/bin/protobase.mjs +4 -0
- package/dist/build/config-bundle.js +70 -0
- package/dist/build/deploy-bundle.js +42 -0
- package/dist/build/entry-code.js +23 -0
- package/dist/build/layout-check.js +50 -0
- package/dist/build/packages/binary-platform.js +81 -0
- package/dist/build/packages/copy-packages.js +59 -0
- package/dist/build/packages/host-packages.js +37 -0
- package/dist/build/packages/native-package.js +22 -0
- package/dist/build/packages/package-dir.js +32 -0
- package/dist/build/packages/package-tree.js +53 -0
- package/dist/build/packages/targets.js +38 -0
- package/dist/build/serve-runtime.js +16 -0
- package/dist/build/ui-bundle.js +33 -0
- package/dist/build/vite-bundle.js +36 -0
- package/dist/bundle/manifest.js +35 -0
- package/dist/db/connect.js +18 -0
- package/dist/db/introspect.js +47 -0
- package/dist/db/keys.js +15 -0
- package/dist/db/model.js +4 -0
- package/dist/db/queries.js +62 -0
- package/dist/dev/check-database.js +38 -0
- package/dist/dev/create-db.js +13 -0
- package/dist/dev/entry.d.ts +4 -0
- package/dist/dev/entry.js +32 -0
- package/dist/dev/env.js +15 -0
- package/dist/dev/hosts.js +4 -0
- package/dist/dev/project.js +39 -0
- package/dist/dev/run.js +88 -0
- package/dist/doctor/check.js +76 -0
- package/dist/doctor/load-config.js +10 -0
- package/dist/doctor/run.js +22 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/infer/check-values.js +14 -0
- package/dist/infer/compat.js +17 -0
- package/dist/infer/default-column.js +12 -0
- package/dist/infer/defaults.js +28 -0
- package/dist/infer/names.js +28 -0
- package/dist/infer/proposal.js +68 -0
- package/dist/infer/render-field.js +26 -0
- package/dist/infer/tenant.js +5 -0
- package/dist/infer/type-map.js +52 -0
- package/dist/main.d.ts +4 -0
- package/dist/main.js +84 -0
- package/dist/module-file.js +6 -0
- package/dist/package.js +4 -0
- package/dist/project/admin-app.js +9 -0
- package/dist/project/convention.js +8 -0
- package/dist/project/import-project-file.js +6 -0
- package/dist/project/pg-db.js +11 -0
- package/dist/project/project-ui.js +17 -0
- package/dist/scaffold/data-file.js +44 -0
- package/dist/scaffold/drift.js +30 -0
- package/dist/scaffold/existing-config.js +95 -0
- package/dist/scaffold/index-file.js +18 -0
- package/dist/scaffold/plan.js +128 -0
- package/dist/scaffold/project.js +13 -0
- package/dist/scaffold/prompt.js +70 -0
- package/dist/scaffold/resource-names.js +19 -0
- package/dist/scaffold/resource-plan.js +33 -0
- package/dist/scaffold/run.js +53 -0
- package/dist/scaffold/targets.js +15 -0
- package/dist/scaffold/ui-fields.js +30 -0
- package/dist/scaffold/ui-file.js +40 -0
- package/dist/serve/app.js +30 -0
- package/dist/serve/bundle-version.js +16 -0
- package/dist/serve/env.js +13 -0
- package/dist/serve/host-module-ids.js +21 -0
- package/dist/serve/host-modules.js +49 -0
- package/dist/serve/lifecycle.js +39 -0
- package/dist/serve/load-bundle.js +17 -0
- package/dist/serve/main.d.ts +1 -0
- package/dist/serve/main.js +13 -0
- package/dist/serve/serve-bundle.js +21 -0
- package/dist/serve/serve-dir.js +15 -0
- package/dist/serve/signals.js +11 -0
- package/dist/serve/static-site.js +46 -0
- package/dist/users/commands.js +31 -0
- package/dist/users/load-auth.js +15 -0
- package/dist/users/manage.js +21 -0
- package/dist/users/password.js +64 -0
- package/dist/users/register.js +94 -0
- package/dist/users/ttl.js +16 -0
- package/dist/version/compatibility.js +31 -0
- package/dist/version/version.js +5 -0
- package/package.json +55 -3
- package/README.md +0 -3
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { tableKey } from "../db/model.js";
|
|
2
|
+
import { isUniqueSet, keyColumns, leadingIndex } from "../db/keys.js";
|
|
3
|
+
import { checkEnumValues } from "../infer/check-values.js";
|
|
4
|
+
import { typesCompatible } from "../infer/compat.js";
|
|
5
|
+
import { columnDefault } from "../infer/defaults.js";
|
|
6
|
+
import { inferKind } from "../infer/type-map.js";
|
|
7
|
+
//#region src/doctor/check.ts
|
|
8
|
+
const quoteIdent = (name) => /^[a-z_][a-z0-9_]*$/.test(name) ? name : `"${name.replaceAll("\"", "\"\"")}"`;
|
|
9
|
+
const sameValues = (a = [], b = []) => a.length === b.length && a.every((v) => b.includes(v));
|
|
10
|
+
const checkField = (model, table, name) => {
|
|
11
|
+
const field = model.fields[name];
|
|
12
|
+
const issues = [];
|
|
13
|
+
const error = (message, suggestion) => ({
|
|
14
|
+
severity: "error",
|
|
15
|
+
resource: model.name,
|
|
16
|
+
message: `${name}: ${message}`,
|
|
17
|
+
...suggestion && { suggestion }
|
|
18
|
+
});
|
|
19
|
+
const columns = field.relation?.columns ?? [field.column];
|
|
20
|
+
for (const columnName of columns) if (!table.columns.some((c) => c.name === columnName)) issues.push(error(`column "${columnName}" does not exist in ${tableKey(table)}`));
|
|
21
|
+
const column = table.columns.find((c) => c.name === columns[0]);
|
|
22
|
+
if (!column) return issues;
|
|
23
|
+
const kind = inferKind(column, checkEnumValues(table.checks, column.name));
|
|
24
|
+
if (!typesCompatible(field.type, kind.type)) issues.push(error(`declared ${field.type}, but ${column.name} is ${column.formatted}`));
|
|
25
|
+
else if (field.type === "enum" && kind.type === "enum" && !sameValues(field.enumValues, kind.enumValues)) issues.push({
|
|
26
|
+
severity: "warning",
|
|
27
|
+
resource: model.name,
|
|
28
|
+
message: `${name}: enum values differ from the database (${kind.enumValues?.join(", ")})`
|
|
29
|
+
});
|
|
30
|
+
const declared = field.default && "db" in field.default;
|
|
31
|
+
const fallback = columnDefault(column, kind).kind;
|
|
32
|
+
if (fallback === "db" && !field.default) issues.push({
|
|
33
|
+
severity: "warning",
|
|
34
|
+
resource: model.name,
|
|
35
|
+
message: `${name}: ${column.name} has a database default; add .dbDefault() (or .default(value))`
|
|
36
|
+
});
|
|
37
|
+
if (declared && fallback !== "db") issues.push({
|
|
38
|
+
severity: "warning",
|
|
39
|
+
resource: model.name,
|
|
40
|
+
message: `${name}: declared .dbDefault() but ${column.name} has no default the database computes`
|
|
41
|
+
});
|
|
42
|
+
if (field.filterable && !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
|
+
return issues;
|
|
49
|
+
};
|
|
50
|
+
const checkKey = (model, table) => {
|
|
51
|
+
if (!keyColumns(table)) return [{
|
|
52
|
+
severity: "error",
|
|
53
|
+
resource: model.name,
|
|
54
|
+
message: `${tableKey(table)} has no primary key or unique NOT NULL index`,
|
|
55
|
+
suggestion: `ALTER TABLE ${quoteIdent(table.schema)}.${quoteIdent(table.name)} ADD PRIMARY KEY (...);`
|
|
56
|
+
}];
|
|
57
|
+
const columns = model.primaryKey.map((name) => model.fields[name]?.column);
|
|
58
|
+
if (columns.some((c) => c === void 0) || isUniqueSet(table, columns)) return [];
|
|
59
|
+
return [{
|
|
60
|
+
severity: "error",
|
|
61
|
+
resource: model.name,
|
|
62
|
+
message: `primary key (${columns.join(", ")}) is not backed by a unique index`
|
|
63
|
+
}];
|
|
64
|
+
};
|
|
65
|
+
const checkResource = (model, tables) => {
|
|
66
|
+
const schema = model.table.schema ?? "public";
|
|
67
|
+
const table = tables.find((t) => t.schema === schema && t.name === model.table.name);
|
|
68
|
+
if (!table) return [{
|
|
69
|
+
severity: "error",
|
|
70
|
+
resource: model.name,
|
|
71
|
+
message: `table ${schema}.${model.table.name} does not exist`
|
|
72
|
+
}];
|
|
73
|
+
return [...Object.keys(model.fields).flatMap((name) => checkField(model, table, name)), ...checkKey(model, table)];
|
|
74
|
+
};
|
|
75
|
+
//#endregion
|
|
76
|
+
export { checkResource };
|
|
@@ -0,0 +1,10 @@
|
|
|
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 };
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { connect } from "../db/connect.js";
|
|
2
|
+
import { introspect } from "../db/introspect.js";
|
|
3
|
+
import { checkResource } from "./check.js";
|
|
4
|
+
import { loadResourceModels } from "./load-config.js";
|
|
5
|
+
//#region src/doctor/run.ts
|
|
6
|
+
const format = (issue) => ` ${issue.severity.padEnd(7)} ${issue.resource}: ${issue.message}${issue.suggestion ? `\n fix: ${issue.suggestion}` : ""}\n`;
|
|
7
|
+
const runDoctor = async (options, out) => {
|
|
8
|
+
const models = await loadResourceModels(options.configDir);
|
|
9
|
+
if (models.length === 0) throw new Error(`No resources exported from ${options.configDir}/index.ts`);
|
|
10
|
+
const sql = connect(options.url);
|
|
11
|
+
const tables = await introspect(sql).finally(() => sql.end());
|
|
12
|
+
const issues = models.flatMap((model) => checkResource(model, tables));
|
|
13
|
+
for (const issue of issues) out(format(issue));
|
|
14
|
+
const errors = issues.filter((i) => i.severity === "error").length;
|
|
15
|
+
out(`Checked ${models.length} resources: ${errors} errors, ${issues.length - errors} warnings.\n`);
|
|
16
|
+
return {
|
|
17
|
+
errors,
|
|
18
|
+
warnings: issues.length - errors
|
|
19
|
+
};
|
|
20
|
+
};
|
|
21
|
+
//#endregion
|
|
22
|
+
export { runDoctor };
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
//#region src/infer/check-values.ts
|
|
2
|
+
const literal = /'((?:[^']|'')*)'(?:::[\w ]+)?/g;
|
|
3
|
+
const parseAnyArray = (definition) => {
|
|
4
|
+
const match = /= ANY \(\(?ARRAY\[(.+)\]\)/.exec(definition);
|
|
5
|
+
if (!match) return void 0;
|
|
6
|
+
const list = match[1];
|
|
7
|
+
if (list.replace(literal, "").replace(/[\s,]/g, "") !== "") return void 0;
|
|
8
|
+
return [...list.matchAll(literal)].map((m) => m[1].replaceAll("''", "'"));
|
|
9
|
+
};
|
|
10
|
+
const checkEnumValues = (checks, column) => {
|
|
11
|
+
return checks.filter((check) => check.columns.length === 1 && check.columns[0] === column).map((check) => parseAnyArray(check.definition)).find((values) => values);
|
|
12
|
+
};
|
|
13
|
+
//#endregion
|
|
14
|
+
export { checkEnumValues };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
//#region src/infer/compat.ts
|
|
2
|
+
const relationTargets = [
|
|
3
|
+
"integer",
|
|
4
|
+
"bigint",
|
|
5
|
+
"uuid",
|
|
6
|
+
"text"
|
|
7
|
+
];
|
|
8
|
+
const typesCompatible = (declared, base) => {
|
|
9
|
+
if (declared === base) return true;
|
|
10
|
+
if (declared === "relation") return relationTargets.includes(base);
|
|
11
|
+
if (declared === "currency" || declared === "country") return base === "text";
|
|
12
|
+
if (declared === "enum") return base === "text";
|
|
13
|
+
if (declared === "text") return base === "enum";
|
|
14
|
+
return false;
|
|
15
|
+
};
|
|
16
|
+
//#endregion
|
|
17
|
+
export { typesCompatible };
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { f, resource } from "@protobase/schema";
|
|
2
|
+
//#region src/infer/default-column.ts
|
|
3
|
+
const cache = /* @__PURE__ */ new Map();
|
|
4
|
+
const defaultColumn = (fieldName) => {
|
|
5
|
+
const cached = cache.get(fieldName);
|
|
6
|
+
if (cached) return cached;
|
|
7
|
+
const column = resource("probe").table("probe").fields({ [fieldName]: f.text() }).primaryKey((r) => r[fieldName]).toModel().fields[fieldName].column;
|
|
8
|
+
cache.set(fieldName, column);
|
|
9
|
+
return column;
|
|
10
|
+
};
|
|
11
|
+
//#endregion
|
|
12
|
+
export { defaultColumn };
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
//#region src/infer/defaults.ts
|
|
2
|
+
const quoted = /^\(?'((?:[^']|'')*)'(?:::[\w. "\[\]]+)?\)?$/;
|
|
3
|
+
const number = /^\(?(-?\d+(?:\.\d+)?)\)?(?:::\w+)?$/;
|
|
4
|
+
const parseDefault = (expression, kind) => {
|
|
5
|
+
const text = expression.trim();
|
|
6
|
+
if (kind.type === "boolean") return text === "true" ? true : text === "false" ? false : void 0;
|
|
7
|
+
const quote = quoted.exec(text)?.[1]?.replaceAll("''", "'");
|
|
8
|
+
const digits = number.exec(text)?.[1];
|
|
9
|
+
if (kind.type === "integer") {
|
|
10
|
+
const value = digits ?? quote;
|
|
11
|
+
return value !== void 0 && Number.isInteger(Number(value)) ? Number(value) : void 0;
|
|
12
|
+
}
|
|
13
|
+
if (kind.type === "bigint" || kind.type === "decimal") return digits ?? quote;
|
|
14
|
+
if (kind.type === "json") return void 0;
|
|
15
|
+
return quote;
|
|
16
|
+
};
|
|
17
|
+
const columnDefault = (column, kind) => {
|
|
18
|
+
if (column.generated || column.identityAlways) return { kind: "owned" };
|
|
19
|
+
if (column.identity) return { kind: "db" };
|
|
20
|
+
if (column.defaultExpr === void 0) return { kind: "none" };
|
|
21
|
+
const value = kind.type === "json" ? void 0 : parseDefault(column.defaultExpr, kind);
|
|
22
|
+
return value === void 0 ? { kind: "db" } : {
|
|
23
|
+
kind: "literal",
|
|
24
|
+
value
|
|
25
|
+
};
|
|
26
|
+
};
|
|
27
|
+
//#endregion
|
|
28
|
+
export { columnDefault, parseDefault };
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
//#region src/infer/names.ts
|
|
2
|
+
const words = (name) => name.split(/[^A-Za-z0-9]+/).filter(Boolean);
|
|
3
|
+
const capitalize = (word) => word.charAt(0).toUpperCase() + word.slice(1);
|
|
4
|
+
const lowerFirst = (word) => word.charAt(0).toLowerCase() + word.slice(1);
|
|
5
|
+
const identifier = (name) => /^[0-9]/.test(name) ? `c${name}` : name;
|
|
6
|
+
const camelCase = (name) => {
|
|
7
|
+
const parts = words(name);
|
|
8
|
+
if (parts.length === 0) throw new Error(`Cannot derive a field name from "${name}"`);
|
|
9
|
+
if (/[a-z]/.test(name) && /[A-Z]/.test(name) && parts.length === 1) return identifier(lowerFirst(parts[0]));
|
|
10
|
+
const [first, ...rest] = parts.map((part) => part.toLowerCase());
|
|
11
|
+
return identifier(first + rest.map(capitalize).join(""));
|
|
12
|
+
};
|
|
13
|
+
const singular = (word) => {
|
|
14
|
+
if (word.endsWith("ies")) return `${word.slice(0, -3)}y`;
|
|
15
|
+
if (/(sses|xes|ches|shes)$/.test(word)) return word.slice(0, -2);
|
|
16
|
+
if (word.endsWith("s") && !word.endsWith("ss") && !word.endsWith("us")) return word.slice(0, -1);
|
|
17
|
+
return word;
|
|
18
|
+
};
|
|
19
|
+
const humanize = (name) => {
|
|
20
|
+
const parts = words(name.replace(/([a-z0-9])([A-Z])/g, "$1 $2")).map((part) => part.toLowerCase());
|
|
21
|
+
return capitalize(parts.join(" "));
|
|
22
|
+
};
|
|
23
|
+
const humanizeSingular = (name) => {
|
|
24
|
+
const parts = humanize(name).split(" ");
|
|
25
|
+
return [...parts.slice(0, -1), singular(parts.at(-1).toLowerCase())].join(" ").replace(/^./, (c) => c.toUpperCase());
|
|
26
|
+
};
|
|
27
|
+
//#endregion
|
|
28
|
+
export { camelCase, humanize, humanizeSingular, singular };
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { leadingIndex } from "../db/keys.js";
|
|
2
|
+
import { checkEnumValues } from "./check-values.js";
|
|
3
|
+
import { columnDefault } from "./defaults.js";
|
|
4
|
+
import { inferKind } from "./type-map.js";
|
|
5
|
+
import { defaultColumn } from "./default-column.js";
|
|
6
|
+
import { camelCase } from "./names.js";
|
|
7
|
+
import { isTenantColumn } from "./tenant.js";
|
|
8
|
+
//#region src/infer/proposal.ts
|
|
9
|
+
const columnIndexing = (table, column) => {
|
|
10
|
+
const tenant = table.columns.find((c) => isTenantColumn(c.name))?.name;
|
|
11
|
+
const index = leadingIndex(table, column, tenant);
|
|
12
|
+
return {
|
|
13
|
+
filterable: Boolean(index),
|
|
14
|
+
sortable: index?.method === "btree"
|
|
15
|
+
};
|
|
16
|
+
};
|
|
17
|
+
const relationOf = (table, column, context) => {
|
|
18
|
+
const fk = table.foreignKeys.find((candidate) => candidate.columns[0] === column);
|
|
19
|
+
if (!fk) return void 0;
|
|
20
|
+
const resource = context.resourceNameOf(fk.refSchema, fk.refTable);
|
|
21
|
+
return fk.columns.length === 1 ? { resource } : {
|
|
22
|
+
resource,
|
|
23
|
+
columns: fk.columns
|
|
24
|
+
};
|
|
25
|
+
};
|
|
26
|
+
const auditColumn = /^(created|inserted|updated|modified)_at$/;
|
|
27
|
+
const isServerManaged = (table, column) => column.identity || auditColumn.test(column.name) || table.indexes.some((index) => index.primary && index.columns.includes(column.name));
|
|
28
|
+
const defaultsOf = (table, column, kind) => {
|
|
29
|
+
const fallback = columnDefault(column, kind);
|
|
30
|
+
if (fallback.kind === "owned") return { readOnly: true };
|
|
31
|
+
if (fallback.kind === "db") return {
|
|
32
|
+
readOnly: isServerManaged(table, column),
|
|
33
|
+
dbDefault: true
|
|
34
|
+
};
|
|
35
|
+
if (fallback.kind === "literal") return {
|
|
36
|
+
readOnly: false,
|
|
37
|
+
defaultValue: fallback.value
|
|
38
|
+
};
|
|
39
|
+
return { readOnly: false };
|
|
40
|
+
};
|
|
41
|
+
const proposeField = (table, column, context) => {
|
|
42
|
+
const kind = inferKind(column, checkEnumValues(table.checks, column.name));
|
|
43
|
+
const relation = relationOf(table, column.name, context);
|
|
44
|
+
const name = camelCase(column.name);
|
|
45
|
+
const defaults = defaultsOf(table, column, kind);
|
|
46
|
+
return {
|
|
47
|
+
name,
|
|
48
|
+
column: column.name,
|
|
49
|
+
type: relation ? "relation" : kind.type,
|
|
50
|
+
base: kind.type,
|
|
51
|
+
...kind.precision !== void 0 && {
|
|
52
|
+
precision: kind.precision,
|
|
53
|
+
scale: kind.scale
|
|
54
|
+
},
|
|
55
|
+
...kind.enumValues && { enumValues: kind.enumValues },
|
|
56
|
+
...relation && { relation },
|
|
57
|
+
optional: !column.notNull,
|
|
58
|
+
...defaults,
|
|
59
|
+
readOnly: defaults.readOnly || Boolean(kind.unsupported),
|
|
60
|
+
...kind.type === "json" ? {
|
|
61
|
+
filterable: false,
|
|
62
|
+
sortable: false
|
|
63
|
+
} : columnIndexing(table, column.name)
|
|
64
|
+
};
|
|
65
|
+
};
|
|
66
|
+
const needsColumnCall = (proposal) => defaultColumn(proposal.name) !== proposal.column;
|
|
67
|
+
//#endregion
|
|
68
|
+
export { auditColumn, isServerManaged, needsColumnCall, proposeField };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { needsColumnCall } from "./proposal.js";
|
|
2
|
+
//#region src/infer/render-field.ts
|
|
3
|
+
const quote = (text) => `'${text.replaceAll("\\", "\\\\").replaceAll("'", "\\'")}'`;
|
|
4
|
+
const literal = (value) => typeof value === "string" ? quote(value) : String(value);
|
|
5
|
+
const base = (p) => {
|
|
6
|
+
if (p.type === "relation") {
|
|
7
|
+
const { resource, columns } = p.relation;
|
|
8
|
+
const call = `f.relation(${quote(resource)})`;
|
|
9
|
+
return columns ? `${call}.columns([${columns.map(quote).join(", ")}])` : call;
|
|
10
|
+
}
|
|
11
|
+
if (p.type === "decimal") return `f.decimal({ precision: ${p.precision}, scale: ${p.scale} })`;
|
|
12
|
+
if (p.type === "enum") return `f.enum([${(p.enumValues ?? []).map(quote).join(", ")}])`;
|
|
13
|
+
return `f.${p.type}()`;
|
|
14
|
+
};
|
|
15
|
+
const renderField = (p) => [
|
|
16
|
+
base(p),
|
|
17
|
+
p.optional && ".optional()",
|
|
18
|
+
p.defaultValue !== void 0 && `.default(${literal(p.defaultValue)})`,
|
|
19
|
+
p.dbDefault && ".dbDefault()",
|
|
20
|
+
p.readOnly && ".readOnly()",
|
|
21
|
+
needsColumnCall(p) && `.column(${quote(p.column)})`,
|
|
22
|
+
p.filterable && ".filterable()",
|
|
23
|
+
p.sortable && ".sortable()"
|
|
24
|
+
].filter(Boolean).join("");
|
|
25
|
+
//#endregion
|
|
26
|
+
export { quote, renderField };
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
//#region src/infer/type-map.ts
|
|
2
|
+
const simple = {
|
|
3
|
+
int2: "integer",
|
|
4
|
+
int4: "integer",
|
|
5
|
+
int8: "bigint",
|
|
6
|
+
text: "text",
|
|
7
|
+
varchar: "text",
|
|
8
|
+
bpchar: "text",
|
|
9
|
+
name: "text",
|
|
10
|
+
citext: "text",
|
|
11
|
+
bool: "boolean",
|
|
12
|
+
date: "date",
|
|
13
|
+
timestamp: "timestamp",
|
|
14
|
+
timestamptz: "timestamp",
|
|
15
|
+
uuid: "uuid",
|
|
16
|
+
json: "json",
|
|
17
|
+
jsonb: "json"
|
|
18
|
+
};
|
|
19
|
+
const numericLimits = (formatted) => {
|
|
20
|
+
const match = /\((\d+),\s*(\d+)\)/.exec(formatted);
|
|
21
|
+
if (!match) return {
|
|
22
|
+
precision: 38,
|
|
23
|
+
scale: 9
|
|
24
|
+
};
|
|
25
|
+
return {
|
|
26
|
+
precision: Number(match[1]),
|
|
27
|
+
scale: Number(match[2])
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
const textLike = (typeName) => simple[typeName] === "text";
|
|
31
|
+
const inferKind = (column, checkValues) => {
|
|
32
|
+
if (column.isEnum) return {
|
|
33
|
+
type: "enum",
|
|
34
|
+
enumValues: column.enumValues
|
|
35
|
+
};
|
|
36
|
+
if (column.typeName === "numeric") return {
|
|
37
|
+
type: "decimal",
|
|
38
|
+
...numericLimits(column.formatted)
|
|
39
|
+
};
|
|
40
|
+
const type = simple[column.typeName];
|
|
41
|
+
if (!type) return {
|
|
42
|
+
type: "text",
|
|
43
|
+
unsupported: true
|
|
44
|
+
};
|
|
45
|
+
if (checkValues && textLike(column.typeName)) return {
|
|
46
|
+
type: "enum",
|
|
47
|
+
enumValues: checkValues
|
|
48
|
+
};
|
|
49
|
+
return { type };
|
|
50
|
+
};
|
|
51
|
+
//#endregion
|
|
52
|
+
export { inferKind };
|
package/dist/main.d.ts
ADDED
package/dist/main.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { manifestFile } from "./bundle/manifest.js";
|
|
2
|
+
import { buildDeployBundle } from "./build/deploy-bundle.js";
|
|
3
|
+
import { buildServeRuntime } from "./build/serve-runtime.js";
|
|
4
|
+
import { resolveConnectionUrl } from "./db/connect.js";
|
|
5
|
+
import { parseHosts } from "./dev/hosts.js";
|
|
6
|
+
import { loadNearestEnvFile } from "./dev/env.js";
|
|
7
|
+
import { runDev } from "./dev/run.js";
|
|
8
|
+
import { runDoctor } from "./doctor/run.js";
|
|
9
|
+
import { runScaffold } from "./scaffold/run.js";
|
|
10
|
+
import { serveBundle } from "./serve/serve-bundle.js";
|
|
11
|
+
import { serveBundleDir } from "./serve/serve-dir.js";
|
|
12
|
+
import { registerToken, registerUsers } from "./users/register.js";
|
|
13
|
+
import { statSync } from "node:fs";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { Command, InvalidArgumentError } from "commander";
|
|
16
|
+
//#region src/main.ts
|
|
17
|
+
const out = (text) => process.stdout.write(text);
|
|
18
|
+
const collect = (value, previous) => [...previous, value];
|
|
19
|
+
const uiSection = (value) => {
|
|
20
|
+
if (value === "names" || value === "list") return value;
|
|
21
|
+
throw new InvalidArgumentError("expected names or list");
|
|
22
|
+
};
|
|
23
|
+
const collectSection = (value, previous) => [...previous, uiSection(value)];
|
|
24
|
+
const program = new Command("protobase").description("Protobase command line");
|
|
25
|
+
program.command("scaffold").description("Generate or update config/<resource>/data.ts from a Postgres database").argument("[args...]", "connection string (unless --env) followed by an optional target: all, schema, schema.table, schema.table.column").option("--env <variable>", "read the connection string from this environment variable").option("--config <dir>", "config folder", "config").option("--yes", "accept all proposals without asking", false).option("--dry-run", "print a diff instead of writing files", false).option("--ui", "also write config/<resource>/ui.ts (names and list)", false).option("--ui-list", "write only the list section of ui.ts", false).option("--ui-section <name>", "write only this ui.ts section (names or list), repeatable", collectSection, []).option("--exclude <table>", "skip a schema or schema.table, repeatable", collect, []).action(async (args, opts) => {
|
|
26
|
+
const [first, ...rest] = args;
|
|
27
|
+
const url = resolveConnectionUrl(opts.env ? void 0 : first, opts.env);
|
|
28
|
+
const [target, ...extra] = opts.env ? args : rest;
|
|
29
|
+
if (extra.length > 0) throw new Error(`Unexpected argument "${extra[0]}"`);
|
|
30
|
+
const ui = opts.ui ? ["names", "list"] : [...opts.uiSection, ...opts.uiList ? ["list"] : []];
|
|
31
|
+
await runScaffold({
|
|
32
|
+
url,
|
|
33
|
+
target,
|
|
34
|
+
configDir: path.resolve(opts.config),
|
|
35
|
+
yes: opts.yes,
|
|
36
|
+
dryRun: opts.dryRun,
|
|
37
|
+
ui: [...new Set(ui)],
|
|
38
|
+
excludes: opts.exclude
|
|
39
|
+
}, out);
|
|
40
|
+
});
|
|
41
|
+
program.command("doctor").description("Check a config folder against the database").argument("[connection]", "connection string").option("--env <variable>", "read the connection string from this environment variable").option("--config <dir>", "config folder", "config").action(async (connection, opts) => {
|
|
42
|
+
const url = resolveConnectionUrl(connection, opts.env);
|
|
43
|
+
const { errors } = await runDoctor({
|
|
44
|
+
url,
|
|
45
|
+
configDir: path.resolve(opts.config)
|
|
46
|
+
}, out);
|
|
47
|
+
if (errors > 0) process.exitCode = 1;
|
|
48
|
+
});
|
|
49
|
+
program.command("dev").description("Serve the admin app and API for the project in the current directory").option("--port <port>", "port for the app and API", "5173").option("--env <variable>", "environment variable holding the database URL", "DATABASE_URL").option("--allowed-hosts <hosts>", "extra Host headers to accept, comma separated; a leading dot matches subdomains (.trycloudflare.com)").option("--cache-dir <dir>", "Vite's dependency cache, so parallel dev servers do not share one").action(async (opts) => {
|
|
50
|
+
const server = await runDev({
|
|
51
|
+
port: Number(opts.port),
|
|
52
|
+
envVar: opts.env,
|
|
53
|
+
projectDir: process.cwd(),
|
|
54
|
+
cacheDir: opts.cacheDir && path.resolve(opts.cacheDir),
|
|
55
|
+
allowedHosts: parseHosts(opts.allowedHosts)
|
|
56
|
+
}, out);
|
|
57
|
+
process.once("SIGINT", () => void server.close().then(() => process.exit(0)));
|
|
58
|
+
process.once("SIGTERM", () => void server.close().then(() => process.exit(0)));
|
|
59
|
+
});
|
|
60
|
+
program.command("build").description("Build the deploy bundle of the project in the current directory: its config module, admin UI and manifest").option("--out <dir>", "output folder", "dist").action(async (opts) => {
|
|
61
|
+
const manifest = await buildDeployBundle({
|
|
62
|
+
projectDir: process.cwd(),
|
|
63
|
+
outDir: path.resolve(opts.out)
|
|
64
|
+
});
|
|
65
|
+
const packages = manifest.nodeModules ? `, ${manifest.nodeModules}/` : "";
|
|
66
|
+
out(`built ${opts.out}: ${manifest.server}${packages}, ${manifest.public}/ and ${manifestFile} (api ${manifest.api.join(", ")})\n`);
|
|
67
|
+
});
|
|
68
|
+
program.command("build-serve").description("Bundle the serve runtime into one file for Bun: bun --no-install protobase-serve.js <bundle.js>").option("--out <file>", "output file", "dist/protobase-serve.js").action(async (opts) => {
|
|
69
|
+
await buildServeRuntime({ outFile: path.resolve(opts.out) });
|
|
70
|
+
out(`built ${opts.out}\n`);
|
|
71
|
+
});
|
|
72
|
+
program.command("serve").description("Serve a bundle written by `protobase build` until SIGTERM or SIGINT: a folder as a host serves it, a config module as its API alone").argument("<bundle>", "the bundle folder, e.g. dist, or its dist/protobase.config.js").action(async (bundle) => {
|
|
73
|
+
loadNearestEnvFile(process.cwd());
|
|
74
|
+
if (statSync(bundle).isDirectory()) await serveBundleDir(bundle, process.env, out);
|
|
75
|
+
else await serveBundle(bundle, process.env, out);
|
|
76
|
+
});
|
|
77
|
+
registerUsers(program);
|
|
78
|
+
registerToken(program);
|
|
79
|
+
const run = (argv) => program.parseAsync(argv).catch((error) => {
|
|
80
|
+
process.stderr.write(`error: ${error.message}\n`);
|
|
81
|
+
process.exitCode = 1;
|
|
82
|
+
});
|
|
83
|
+
//#endregion
|
|
84
|
+
export { run };
|
package/dist/package.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
//#region src/project/admin-app.ts
|
|
4
|
+
const uiEntry = createRequire(import.meta.url).resolve("@protobase/ui");
|
|
5
|
+
const uiPackageDir = path.resolve(path.dirname(uiEntry), "..");
|
|
6
|
+
const adminAppDir = path.join(path.dirname(uiEntry), "app");
|
|
7
|
+
const adminAppMain = `main${path.extname(uiEntry) === ".ts" ? ".tsx" : ".js"}`;
|
|
8
|
+
//#endregion
|
|
9
|
+
export { adminAppDir, adminAppMain, uiPackageDir };
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
//#region src/project/convention.ts
|
|
2
|
+
const conventionConfig = (index, uiModules) => {
|
|
3
|
+
const config = { ...index };
|
|
4
|
+
for (const [dir, exports] of uiModules) for (const [key, value] of Object.entries(exports)) if (!Object.values(config).includes(value)) config[`${dir}:${key}`] = value;
|
|
5
|
+
return config;
|
|
6
|
+
};
|
|
7
|
+
//#endregion
|
|
8
|
+
export { conventionConfig };
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Kysely, PostgresDialect } from "kysely";
|
|
2
|
+
//#region src/project/pg-db.ts
|
|
3
|
+
const createPgDb = (pg, url, max = 10) => {
|
|
4
|
+
return new Kysely({ dialect: new PostgresDialect({ pool: new pg.Pool({
|
|
5
|
+
connectionString: url,
|
|
6
|
+
max,
|
|
7
|
+
types: { getTypeParser: (oid, format) => oid === 1082 || oid === 1114 ? (value) => value : pg.types.getTypeParser(oid, format) }
|
|
8
|
+
}) }) });
|
|
9
|
+
};
|
|
10
|
+
//#endregion
|
|
11
|
+
export { createPgDb };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
//#region src/project/project-ui.ts
|
|
4
|
+
const projectUiId = "virtual:protobase-project-ui";
|
|
5
|
+
const empty = `\0${projectUiId}`;
|
|
6
|
+
const projectUiFile = (projectDir) => path.join(projectDir, "protobase.ui.tsx");
|
|
7
|
+
const projectUiPlugin = (projectDir) => ({
|
|
8
|
+
name: "protobase-project-ui",
|
|
9
|
+
resolveId: (id) => {
|
|
10
|
+
if (id !== "virtual:protobase-project-ui") return void 0;
|
|
11
|
+
const file = projectDir === void 0 ? void 0 : projectUiFile(projectDir);
|
|
12
|
+
return file && existsSync(file) ? file : empty;
|
|
13
|
+
},
|
|
14
|
+
load: (id) => id === empty ? "export default {}\n" : void 0
|
|
15
|
+
});
|
|
16
|
+
//#endregion
|
|
17
|
+
export { projectUiFile, projectUiId, projectUiPlugin };
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { tableKey } from "../db/model.js";
|
|
2
|
+
import { parseSource } from "./project.js";
|
|
3
|
+
import { fieldsObject, resourceChain } from "./existing-config.js";
|
|
4
|
+
import { renderField } from "../infer/render-field.js";
|
|
5
|
+
//#region src/scaffold/data-file.ts
|
|
6
|
+
const propertyText = ({ proposal, ignored }) => ignored ? `${proposal.name}: null` : `${proposal.name}: ${renderField(proposal)}`;
|
|
7
|
+
const refs = (names) => names.length === 1 ? `r.${names[0]}` : `[${names.map((n) => `r.${n}`).join(", ")}]`;
|
|
8
|
+
const docComment = (plan) => {
|
|
9
|
+
const unique = plan.unique.map((columns) => `(${columns.join(", ")})`).join(", ");
|
|
10
|
+
return `/** ${tableKey(plan.table)}${unique ? `. Unique: ${unique}` : ""} */\n`;
|
|
11
|
+
};
|
|
12
|
+
const renderDataFile = (plan) => {
|
|
13
|
+
const properties = plan.decisions.map((d) => ` ${propertyText(d)},`).join("\n");
|
|
14
|
+
const tail = [
|
|
15
|
+
` .primaryKey((r) => ${refs(plan.primaryKey)})`,
|
|
16
|
+
plan.softDelete && ` .softDelete((r) => r.${plan.softDelete})`,
|
|
17
|
+
plan.tenant && ` .tenant((r) => r.${plan.tenant})`
|
|
18
|
+
].filter(Boolean);
|
|
19
|
+
return [
|
|
20
|
+
`import { f, resource } from '@protobase/schema'`,
|
|
21
|
+
"",
|
|
22
|
+
`${docComment(plan)}export const ${plan.name} = resource('${plan.name}')`,
|
|
23
|
+
` .table('${tableKey(plan.table)}')`,
|
|
24
|
+
` .fields({`,
|
|
25
|
+
properties,
|
|
26
|
+
` })`,
|
|
27
|
+
...tail,
|
|
28
|
+
""
|
|
29
|
+
].join("\n");
|
|
30
|
+
};
|
|
31
|
+
const addFieldsToDataFile = (text, decisions) => {
|
|
32
|
+
if (decisions.length === 0) return text;
|
|
33
|
+
const source = parseSource(text);
|
|
34
|
+
const chain = resourceChain(source);
|
|
35
|
+
const object = chain && fieldsObject(chain.chainTop);
|
|
36
|
+
if (!object) throw new Error("data.ts has no resource(...).fields({...}) object to extend");
|
|
37
|
+
for (const decision of decisions) object.addPropertyAssignment({
|
|
38
|
+
name: decision.proposal.name,
|
|
39
|
+
initializer: decision.ignored ? "null" : renderField(decision.proposal)
|
|
40
|
+
});
|
|
41
|
+
return source.getFullText();
|
|
42
|
+
};
|
|
43
|
+
//#endregion
|
|
44
|
+
export { addFieldsToDataFile, renderDataFile };
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { typesCompatible } from "../infer/compat.js";
|
|
2
|
+
import { proposeField } from "../infer/proposal.js";
|
|
3
|
+
import { renderField } from "../infer/render-field.js";
|
|
4
|
+
//#region src/scaffold/drift.ts
|
|
5
|
+
const sameValues = (a = [], b = []) => a.length === b.length && a.every((v) => b.includes(v));
|
|
6
|
+
const detectDrift = (existing, table, context) => existing.fields.filter((field) => !field.ignored).flatMap((field) => {
|
|
7
|
+
const column = table.columns.find((c) => c.name === field.column);
|
|
8
|
+
if (!column) return [{
|
|
9
|
+
resource: existing.name,
|
|
10
|
+
field: field.name,
|
|
11
|
+
problem: `column "${field.column}" no longer exists`,
|
|
12
|
+
suggestion: `remove "${field.name}" or point .column() at its new name`
|
|
13
|
+
}];
|
|
14
|
+
if (!field.type) return [];
|
|
15
|
+
const proposal = proposeField(table, column, context);
|
|
16
|
+
const typeDrift = !typesCompatible(field.type, proposal.base);
|
|
17
|
+
const enumDrift = field.type === "enum" && proposal.base === "enum" && !sameValues(field.enumValues, proposal.enumValues);
|
|
18
|
+
if (!typeDrift && !enumDrift) return [];
|
|
19
|
+
return [{
|
|
20
|
+
resource: existing.name,
|
|
21
|
+
field: field.name,
|
|
22
|
+
problem: typeDrift ? `declared ${field.type}, database is ${column.formatted}` : `enum values differ from the database (${proposal.enumValues?.join(", ")})`,
|
|
23
|
+
suggestion: `${field.name}: ${renderField({
|
|
24
|
+
...proposal,
|
|
25
|
+
name: field.name
|
|
26
|
+
})}`
|
|
27
|
+
}];
|
|
28
|
+
});
|
|
29
|
+
//#endregion
|
|
30
|
+
export { detectDrift };
|