@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.
Files changed (89) hide show
  1. package/LICENSE +202 -0
  2. package/bin/protobase.mjs +4 -0
  3. package/dist/build/config-bundle.js +70 -0
  4. package/dist/build/deploy-bundle.js +42 -0
  5. package/dist/build/entry-code.js +23 -0
  6. package/dist/build/layout-check.js +50 -0
  7. package/dist/build/packages/binary-platform.js +81 -0
  8. package/dist/build/packages/copy-packages.js +59 -0
  9. package/dist/build/packages/host-packages.js +37 -0
  10. package/dist/build/packages/native-package.js +22 -0
  11. package/dist/build/packages/package-dir.js +32 -0
  12. package/dist/build/packages/package-tree.js +53 -0
  13. package/dist/build/packages/targets.js +38 -0
  14. package/dist/build/serve-runtime.js +16 -0
  15. package/dist/build/ui-bundle.js +33 -0
  16. package/dist/build/vite-bundle.js +36 -0
  17. package/dist/bundle/manifest.js +35 -0
  18. package/dist/db/connect.js +18 -0
  19. package/dist/db/introspect.js +47 -0
  20. package/dist/db/keys.js +15 -0
  21. package/dist/db/model.js +4 -0
  22. package/dist/db/queries.js +62 -0
  23. package/dist/dev/check-database.js +38 -0
  24. package/dist/dev/create-db.js +13 -0
  25. package/dist/dev/entry.d.ts +4 -0
  26. package/dist/dev/entry.js +32 -0
  27. package/dist/dev/env.js +15 -0
  28. package/dist/dev/hosts.js +4 -0
  29. package/dist/dev/project.js +39 -0
  30. package/dist/dev/run.js +88 -0
  31. package/dist/doctor/check.js +76 -0
  32. package/dist/doctor/load-config.js +10 -0
  33. package/dist/doctor/run.js +22 -0
  34. package/dist/index.d.ts +2 -0
  35. package/dist/index.js +2 -0
  36. package/dist/infer/check-values.js +14 -0
  37. package/dist/infer/compat.js +17 -0
  38. package/dist/infer/default-column.js +12 -0
  39. package/dist/infer/defaults.js +28 -0
  40. package/dist/infer/names.js +28 -0
  41. package/dist/infer/proposal.js +68 -0
  42. package/dist/infer/render-field.js +26 -0
  43. package/dist/infer/tenant.js +5 -0
  44. package/dist/infer/type-map.js +52 -0
  45. package/dist/main.d.ts +4 -0
  46. package/dist/main.js +84 -0
  47. package/dist/module-file.js +6 -0
  48. package/dist/package.js +4 -0
  49. package/dist/project/admin-app.js +9 -0
  50. package/dist/project/convention.js +8 -0
  51. package/dist/project/import-project-file.js +6 -0
  52. package/dist/project/pg-db.js +11 -0
  53. package/dist/project/project-ui.js +17 -0
  54. package/dist/scaffold/data-file.js +44 -0
  55. package/dist/scaffold/drift.js +30 -0
  56. package/dist/scaffold/existing-config.js +95 -0
  57. package/dist/scaffold/index-file.js +18 -0
  58. package/dist/scaffold/plan.js +128 -0
  59. package/dist/scaffold/project.js +13 -0
  60. package/dist/scaffold/prompt.js +70 -0
  61. package/dist/scaffold/resource-names.js +19 -0
  62. package/dist/scaffold/resource-plan.js +33 -0
  63. package/dist/scaffold/run.js +53 -0
  64. package/dist/scaffold/targets.js +15 -0
  65. package/dist/scaffold/ui-fields.js +30 -0
  66. package/dist/scaffold/ui-file.js +40 -0
  67. package/dist/serve/app.js +30 -0
  68. package/dist/serve/bundle-version.js +16 -0
  69. package/dist/serve/env.js +13 -0
  70. package/dist/serve/host-module-ids.js +21 -0
  71. package/dist/serve/host-modules.js +49 -0
  72. package/dist/serve/lifecycle.js +39 -0
  73. package/dist/serve/load-bundle.js +17 -0
  74. package/dist/serve/main.d.ts +1 -0
  75. package/dist/serve/main.js +13 -0
  76. package/dist/serve/serve-bundle.js +21 -0
  77. package/dist/serve/serve-dir.js +15 -0
  78. package/dist/serve/signals.js +11 -0
  79. package/dist/serve/static-site.js +46 -0
  80. package/dist/users/commands.js +31 -0
  81. package/dist/users/load-auth.js +15 -0
  82. package/dist/users/manage.js +21 -0
  83. package/dist/users/password.js +64 -0
  84. package/dist/users/register.js +94 -0
  85. package/dist/users/ttl.js +16 -0
  86. package/dist/version/compatibility.js +31 -0
  87. package/dist/version/version.js +5 -0
  88. package/package.json +55 -3
  89. 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 };
@@ -0,0 +1,2 @@
1
+ import { run } from "./main.js";
2
+ export { run };
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ import { run } from "./main.js";
2
+ export { run };
@@ -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,5 @@
1
+ //#region src/infer/tenant.ts
2
+ const tenantColumn = /^(organization|tenant)_id$/i;
3
+ const isTenantColumn = (column) => tenantColumn.test(column);
4
+ //#endregion
5
+ export { isTenantColumn };
@@ -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
@@ -0,0 +1,4 @@
1
+ import { Command } from "commander";
2
+ //#region src/main.d.ts
3
+ export declare const run: (argv: string[]) => Promise<void | Command>;
4
+ //#endregion
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 };
@@ -0,0 +1,6 @@
1
+ import path from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+ //#region src/module-file.ts
4
+ const moduleFile = (relative, from) => fileURLToPath(new URL(`${relative}${path.extname(from)}`, from));
5
+ //#endregion
6
+ export { moduleFile };
@@ -0,0 +1,4 @@
1
+ //#region package.json
2
+ var version = "0.1.1";
3
+ //#endregion
4
+ export { version };
@@ -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,6 @@
1
+ import { pathToFileURL } from "node:url";
2
+ import { tsImport } from "tsx/esm/api";
3
+ //#region src/project/import-project-file.ts
4
+ const importProjectFile = (file) => tsImport(pathToFileURL(file).href, import.meta.url);
5
+ //#endregion
6
+ export { importProjectFile };
@@ -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 };