@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,95 @@
1
+ import { defaultColumn } from "../infer/default-column.js";
2
+ import { camelCase } from "../infer/names.js";
3
+ import { parseSource } from "./project.js";
4
+ import path from "node:path";
5
+ import { readFile, readdir } from "node:fs/promises";
6
+ import { Node, SyntaxKind } from "ts-morph";
7
+ //#region src/scaffold/existing-config.ts
8
+ const unquote = (text) => text.replace(/^['"`]|['"`]$/g, "");
9
+ const stringArg = (call, index = 0) => {
10
+ const arg = call.getArguments()[index];
11
+ return arg && Node.isStringLiteral(arg) ? arg.getLiteralValue() : void 0;
12
+ };
13
+ const methodCalls = (root, method) => [root, ...root.getDescendantsOfKind(SyntaxKind.CallExpression)].filter((c) => {
14
+ const expression = c.getExpression();
15
+ return Node.isPropertyAccessExpression(expression) && expression.getName() === method;
16
+ });
17
+ const describeField = (name, initializer) => {
18
+ if (!initializer || Node.isNullLiteral(initializer)) return {
19
+ name,
20
+ column: defaultColumn(name),
21
+ ignored: true
22
+ };
23
+ let column;
24
+ let type;
25
+ let enumValues;
26
+ let node = initializer;
27
+ while (Node.isCallExpression(node)) {
28
+ const expression = node.getExpression();
29
+ if (!Node.isPropertyAccessExpression(expression)) break;
30
+ if (expression.getName() === "column") column ??= stringArg(node);
31
+ if (expression.getExpression().getText() === "f") {
32
+ type = expression.getName();
33
+ const arg = node.getArguments()[0];
34
+ if (type === "enum" && arg && Node.isArrayLiteralExpression(arg)) enumValues = arg.getElements().flatMap((el) => Node.isStringLiteral(el) ? [el.getLiteralValue()] : []);
35
+ break;
36
+ }
37
+ node = expression.getExpression();
38
+ }
39
+ return {
40
+ name,
41
+ column: column ?? defaultColumn(name),
42
+ ignored: false,
43
+ ...type && { type },
44
+ ...enumValues && { enumValues }
45
+ };
46
+ };
47
+ const fieldsObject = (root) => {
48
+ const arg = methodCalls(root, "fields")[0]?.getArguments()[0];
49
+ return arg && Node.isObjectLiteralExpression(arg) ? arg : void 0;
50
+ };
51
+ const resourceChain = (source) => {
52
+ const root = source.getDescendantsOfKind(SyntaxKind.CallExpression).find((call) => call.getExpression().getText() === "resource");
53
+ const chainTop = root?.getFirstAncestor((a) => Node.isVariableDeclaration(a))?.getInitializer();
54
+ if (!root || !chainTop || !Node.isCallExpression(chainTop)) return void 0;
55
+ return {
56
+ root,
57
+ chainTop
58
+ };
59
+ };
60
+ const parseResource = (text, dir) => {
61
+ const chain = resourceChain(parseSource(text));
62
+ if (!chain) return void 0;
63
+ const { root, chainTop } = chain;
64
+ const name = stringArg(root);
65
+ const tableCall = methodCalls(chainTop, "table")[0];
66
+ const table = tableCall && stringArg(tableCall);
67
+ const fields = fieldsObject(chainTop);
68
+ if (!name || !table || !fields) return void 0;
69
+ return {
70
+ name,
71
+ table,
72
+ dir,
73
+ text,
74
+ fields: fields.getProperties().flatMap((property) => {
75
+ if (!Node.isPropertyAssignment(property)) return [];
76
+ return [describeField(unquote(property.getName()), property.getInitializer())];
77
+ })
78
+ };
79
+ };
80
+ const findField = (resource, column) => resource.fields.find((field) => field.ignored ? field.column.toLowerCase() === column.toLowerCase() || field.name === camelCase(column) : field.column === column);
81
+ const readExistingResources = async (configDir) => {
82
+ const entries = await readdir(configDir, { withFileTypes: true }).catch((error) => {
83
+ if (error.code === "ENOENT") return [];
84
+ throw error;
85
+ });
86
+ return (await Promise.all(entries.filter((entry) => entry.isDirectory()).map(async (entry) => {
87
+ const text = await readFile(path.join(configDir, entry.name, "data.ts"), "utf8").catch((error) => {
88
+ if (error.code === "ENOENT") return void 0;
89
+ throw error;
90
+ });
91
+ return text === void 0 ? void 0 : parseResource(text, entry.name);
92
+ }))).filter((resource) => resource !== void 0);
93
+ };
94
+ //#endregion
95
+ export { fieldsObject, findField, methodCalls, parseResource, readExistingResources, resourceChain };
@@ -0,0 +1,18 @@
1
+ import { parseSource } from "./project.js";
2
+ //#region src/scaffold/index-file.ts
3
+ const ensureExports = (text, wanted) => {
4
+ const source = parseSource(text);
5
+ for (const { from, names } of wanted) {
6
+ const declaration = source.getExportDeclarations().find((d) => d.getModuleSpecifierValue() === from);
7
+ if (!declaration) {
8
+ source.addStatements(`export { ${names.join(", ")} } from '${from}'`);
9
+ continue;
10
+ }
11
+ const present = declaration.getNamedExports().map((e) => e.getName());
12
+ declaration.addNamedExports(names.filter((name) => !present.includes(name)));
13
+ }
14
+ const result = source.getFullText();
15
+ return result.endsWith("\n") ? result : `${result}\n`;
16
+ };
17
+ //#endregion
18
+ export { ensureExports };
@@ -0,0 +1,128 @@
1
+ import { tableKey } from "../db/model.js";
2
+ import { keyColumns } from "../db/keys.js";
3
+ import { findField } from "./existing-config.js";
4
+ import { proposeField } from "../infer/proposal.js";
5
+ import { addFieldsToDataFile, renderDataFile } from "./data-file.js";
6
+ import { detectDrift } from "./drift.js";
7
+ import { ensureExports } from "./index-file.js";
8
+ import { assignNames } from "./resource-names.js";
9
+ import { planResource } from "./resource-plan.js";
10
+ import { isExcluded, matchesTarget } from "./targets.js";
11
+ import { uiFields } from "./ui-fields.js";
12
+ import { addUiSections, renderUiFile, viewName } from "./ui-file.js";
13
+ import path from "node:path";
14
+ //#region src/scaffold/plan.ts
15
+ const decideAll = async (table, columns, context, input) => {
16
+ const key = keyColumns(table) ?? [];
17
+ const decisions = [];
18
+ for (const column of columns) {
19
+ const proposal = proposeField(table, column, context);
20
+ const decided = await input.decide(proposal, {
21
+ table: tableKey(table),
22
+ required: key.includes(column.name)
23
+ });
24
+ decisions.push(decided ? {
25
+ proposal: decided,
26
+ ignored: false
27
+ } : {
28
+ proposal,
29
+ ignored: true
30
+ });
31
+ }
32
+ return decisions;
33
+ };
34
+ const change = (file, before, after) => before === after ? [] : [{
35
+ path: file,
36
+ ...before !== void 0 && { before },
37
+ after
38
+ }];
39
+ const planScaffold = async (input) => {
40
+ const { configDir, existing, ui } = input;
41
+ const names = assignNames(input.tables, existing);
42
+ const byTable = new Map(existing.map((resource) => [resource.table, resource]));
43
+ const context = (table) => ({
44
+ table,
45
+ resourceNameOf: (schema, name) => names.get(`${schema}.${name}`) ?? name
46
+ });
47
+ const targets = input.tables.filter((table) => matchesTarget(input.target, table) && !isExcluded(input.excludes, table)).sort((a, b) => tableKey(a).localeCompare(tableKey(b)));
48
+ const changes = [];
49
+ const drift = [];
50
+ const warnings = [];
51
+ const registrations = [];
52
+ const writeUi = async (dir, resource, table, current, decisions) => {
53
+ if (ui.length === 0) return;
54
+ const file = path.join(configDir, dir, "ui.ts");
55
+ const before = await input.readText(file);
56
+ const target = {
57
+ resource,
58
+ tableName: table.name,
59
+ fields: uiFields(table, current, decisions)
60
+ };
61
+ changes.push(...change(file, before, before === void 0 ? renderUiFile(target, ui) : addUiSections(before, target, ui)));
62
+ if (before === void 0) registrations.push({
63
+ from: `./${dir}/ui`,
64
+ names: [viewName(resource)]
65
+ });
66
+ };
67
+ for (const table of targets) {
68
+ const key = tableKey(table);
69
+ const current = byTable.get(key);
70
+ if (current) {
71
+ const pending = table.columns.filter((c) => !input.target.column || c.name === input.target.column).filter((column) => !findField(current, column.name));
72
+ const decisions = await decideAll(table, pending, context(table), input);
73
+ const clash = decisions.find((d) => current.fields.some((f) => f.name === d.proposal.name));
74
+ if (clash) throw new Error(`${key}: field "${clash.proposal.name}" already exists, edit the name`);
75
+ const file = path.join(configDir, current.dir, "data.ts");
76
+ changes.push(...change(file, current.text, addFieldsToDataFile(current.text, decisions)));
77
+ drift.push(...detectDrift(current, table, context(table)));
78
+ await writeUi(current.dir, current.name, table, current, decisions);
79
+ continue;
80
+ }
81
+ const name = names.get(key);
82
+ if (!keyColumns(table)) {
83
+ warnings.push(`${key}: no primary key or unique NOT NULL index, skipped`);
84
+ continue;
85
+ }
86
+ const file = path.join(configDir, name, "data.ts");
87
+ if (await input.readText(file) !== void 0) throw new Error(`${file} exists but does not declare a resource for ${key}`);
88
+ const decisions = await decideAll(table, table.columns, context(table), input);
89
+ const plan = planResource(name, table, decisions);
90
+ changes.push({
91
+ path: file,
92
+ after: renderDataFile(plan)
93
+ });
94
+ registrations.push({
95
+ from: `./${name}/data`,
96
+ names: [name]
97
+ });
98
+ await writeUi(name, name, table, void 0, decisions);
99
+ }
100
+ const indexFile = path.join(configDir, "index.ts");
101
+ const indexBefore = await input.readText(indexFile);
102
+ const wanted = [...indexBefore === void 0 ? await registerExisting(input, registrations) : [], ...registrations];
103
+ if (wanted.length > 0 || indexBefore === void 0) changes.push(...change(indexFile, indexBefore, ensureExports(indexBefore ?? "", wanted)));
104
+ return {
105
+ changes,
106
+ drift,
107
+ warnings
108
+ };
109
+ };
110
+ const registerExisting = async (input, planned) => {
111
+ const known = new Set(planned.map((r) => r.from));
112
+ const registrations = [];
113
+ for (const resource of input.existing) {
114
+ const data = `./${resource.dir}/data`;
115
+ if (!known.has(data)) registrations.push({
116
+ from: data,
117
+ names: [resource.name]
118
+ });
119
+ const ui = `./${resource.dir}/ui`;
120
+ if (await input.readText(path.join(input.configDir, resource.dir, "ui.ts")) !== void 0 && !known.has(ui)) registrations.push({
121
+ from: ui,
122
+ names: [viewName(resource.name)]
123
+ });
124
+ }
125
+ return registrations;
126
+ };
127
+ //#endregion
128
+ export { planScaffold };
@@ -0,0 +1,13 @@
1
+ import { IndentationText, Project, QuoteKind } from "ts-morph";
2
+ //#region src/scaffold/project.ts
3
+ const project = new Project({
4
+ useInMemoryFileSystem: true,
5
+ skipAddingFilesFromTsConfig: true,
6
+ manipulationSettings: {
7
+ indentationText: IndentationText.TwoSpaces,
8
+ quoteKind: QuoteKind.Single
9
+ }
10
+ });
11
+ const parseSource = (text, name = "source.ts") => project.createSourceFile(name, text, { overwrite: true });
12
+ //#endregion
13
+ export { parseSource };
@@ -0,0 +1,70 @@
1
+ import { renderField } from "../infer/render-field.js";
2
+ import { createInterface } from "node:readline/promises";
3
+ //#region src/scaffold/prompt.ts
4
+ const acceptAll = async (proposal) => proposal;
5
+ const types = [
6
+ "text",
7
+ "integer",
8
+ "bigint",
9
+ "decimal",
10
+ "boolean",
11
+ "date",
12
+ "timestamp",
13
+ "uuid",
14
+ "json",
15
+ "enum",
16
+ "relation",
17
+ "currency",
18
+ "country"
19
+ ];
20
+ const retype = async (proposal, ask) => {
21
+ const answer = (await ask(` type (${types.join(", ")}) [${proposal.type}]: `)).trim();
22
+ if (!answer || answer === proposal.type) return proposal;
23
+ const type = types.find((t) => t === answer);
24
+ if (!type) {
25
+ process.stdout.write(` unknown type "${answer}", keeping ${proposal.type}\n`);
26
+ return proposal;
27
+ }
28
+ const { enumValues, relation, precision, scale, defaultValue, ...rest } = proposal;
29
+ const next = {
30
+ ...rest,
31
+ type
32
+ };
33
+ if (type === "enum") next.enumValues = (await ask(" enum values, comma separated: ")).split(",").map((v) => v.trim()).filter(Boolean);
34
+ if (type === "relation") next.relation = { resource: (await ask(" related resource: ")).trim() };
35
+ if (type === "decimal") {
36
+ next.precision = Number(await ask(" precision [38]: ") || 38);
37
+ next.scale = Number(await ask(" scale [9]: ") || 9);
38
+ }
39
+ return next;
40
+ };
41
+ const edit = async (proposal, ask) => {
42
+ const name = (await ask(` field name [${proposal.name}]: `)).trim() || proposal.name;
43
+ return retype({
44
+ ...proposal,
45
+ name
46
+ }, ask);
47
+ };
48
+ const interactive = () => {
49
+ const rl = createInterface({
50
+ input: process.stdin,
51
+ output: process.stdout
52
+ });
53
+ const decide = async (initial, { table, required }) => {
54
+ let proposal = initial;
55
+ for (;;) {
56
+ process.stdout.write(`\n${table}.${proposal.column}\n ${proposal.name}: ${renderField(proposal)}\n`);
57
+ const options = required ? "[a]ccept, [e]dit" : "[a]ccept, [e]dit, [i]gnore";
58
+ const answer = (await rl.question(` ${options} (a): `)).trim().toLowerCase() || "a";
59
+ if (answer === "a") return proposal;
60
+ if (answer === "e") proposal = await edit(proposal, (q) => rl.question(q));
61
+ if (answer === "i" && !required) return null;
62
+ }
63
+ };
64
+ return {
65
+ decide,
66
+ close: () => rl.close()
67
+ };
68
+ };
69
+ //#endregion
70
+ export { acceptAll, interactive };
@@ -0,0 +1,19 @@
1
+ import { tableKey } from "../db/model.js";
2
+ import { camelCase } from "../infer/names.js";
3
+ //#region src/scaffold/resource-names.ts
4
+ const assignNames = (tables, existing) => {
5
+ const names = new Map(existing.map((resource) => [resource.table, resource.name]));
6
+ const taken = new Set(names.values());
7
+ const fresh = tables.filter((table) => !names.has(tableKey(table)));
8
+ const counts = /* @__PURE__ */ new Map();
9
+ for (const table of tables) counts.set(camelCase(table.name), (counts.get(camelCase(table.name)) ?? 0) + 1);
10
+ for (const table of fresh) {
11
+ const plain = camelCase(table.name);
12
+ const name = counts.get(plain) === 1 && !taken.has(plain) ? plain : camelCase(`${table.schema}_${table.name}`);
13
+ names.set(tableKey(table), name);
14
+ taken.add(name);
15
+ }
16
+ return names;
17
+ };
18
+ //#endregion
19
+ export { assignNames };
@@ -0,0 +1,33 @@
1
+ import { tableKey } from "../db/model.js";
2
+ import { keyColumns } from "../db/keys.js";
3
+ import { isTenantColumn } from "../infer/tenant.js";
4
+ //#region src/scaffold/resource-plan.ts
5
+ const softDeleteColumn = /^(deleted|removed|archived)_(at|on)$/;
6
+ const isSoftDeleteColumn = (table, column) => softDeleteColumn.test(column) && table.columns.some((c) => c.name === column && !c.notNull && [
7
+ "timestamp",
8
+ "timestamptz",
9
+ "date"
10
+ ].includes(c.typeName));
11
+ const planResource = (name, table, decisions) => {
12
+ const key = keyColumns(table);
13
+ if (!key) return void 0;
14
+ const kept = decisions.filter((d) => !d.ignored).map((d) => d.proposal);
15
+ const nameOf = (column) => kept.find((p) => p.column === column)?.name;
16
+ const find = (test) => {
17
+ return kept.find((p) => test(p.column))?.name;
18
+ };
19
+ const primaryKey = key.map(nameOf);
20
+ if (primaryKey.some((n) => n === void 0)) throw new Error(`${tableKey(table)}: key columns cannot be ignored`);
21
+ const primary = table.indexes.find((index) => index.primary);
22
+ return {
23
+ name,
24
+ table,
25
+ decisions,
26
+ primaryKey,
27
+ softDelete: find((c) => isSoftDeleteColumn(table, c)),
28
+ tenant: find(isTenantColumn),
29
+ unique: table.indexes.filter((index) => index.unique && !index.partial && index !== primary).flatMap((index) => index.columns.every((c) => c !== null) ? [index.columns] : [])
30
+ };
31
+ };
32
+ //#endregion
33
+ export { isSoftDeleteColumn, planResource };
@@ -0,0 +1,53 @@
1
+ import { connect } from "../db/connect.js";
2
+ import { introspect } from "../db/introspect.js";
3
+ import { readExistingResources } from "./existing-config.js";
4
+ import { parseTarget } from "./targets.js";
5
+ import { planScaffold } from "./plan.js";
6
+ import { acceptAll, interactive } from "./prompt.js";
7
+ import path from "node:path";
8
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
9
+ import { createTwoFilesPatch } from "diff";
10
+ //#region src/scaffold/run.ts
11
+ const readText = (file) => readFile(file, "utf8").catch((error) => {
12
+ if (error.code === "ENOENT") return void 0;
13
+ throw error;
14
+ });
15
+ const write = async ({ path: file, after }) => {
16
+ await mkdir(path.dirname(file), { recursive: true });
17
+ await writeFile(file, after);
18
+ };
19
+ const runScaffold = async (options, out) => {
20
+ if (!options.yes && !process.stdin.isTTY) throw new Error("Interactive scaffolding needs a terminal; pass --yes to accept all proposals");
21
+ const sql = connect(options.url);
22
+ const prompter = options.yes ? void 0 : interactive();
23
+ const plan = await (async () => {
24
+ try {
25
+ return await planScaffold({
26
+ tables: await introspect(sql),
27
+ existing: await readExistingResources(options.configDir),
28
+ configDir: options.configDir,
29
+ target: parseTarget(options.target),
30
+ excludes: options.excludes,
31
+ decide: prompter?.decide ?? acceptAll,
32
+ ui: options.ui,
33
+ readText
34
+ });
35
+ } finally {
36
+ prompter?.close();
37
+ await sql.end();
38
+ }
39
+ })();
40
+ for (const change of plan.changes) if (options.dryRun) out(createTwoFilesPatch(change.path, change.path, change.before ?? "", change.after));
41
+ else await write(change);
42
+ for (const change of plan.changes) if (!options.dryRun) out(`${change.before === void 0 ? "created" : "updated"} ${change.path}\n`);
43
+ if (plan.changes.length === 0) out("Nothing to do, config is up to date.\n");
44
+ else if (options.dryRun) out(`${plan.changes.length} file(s) would change; nothing was written (--dry-run).\n`);
45
+ for (const warning of plan.warnings) out(`warning: ${warning}\n`);
46
+ if (plan.drift.length > 0) {
47
+ out("\nDrift:\n");
48
+ for (const d of plan.drift) out(` ${d.resource}.${d.field}: ${d.problem}\n suggested: ${d.suggestion}\n`);
49
+ }
50
+ return plan;
51
+ };
52
+ //#endregion
53
+ export { runScaffold };
@@ -0,0 +1,15 @@
1
+ //#region src/scaffold/targets.ts
2
+ const parseTarget = (text) => {
3
+ if (!text || text === "all") return {};
4
+ const [schema, table, column, ...extra] = text.split(".");
5
+ if (extra.length > 0) throw new Error(`Invalid target "${text}", expected all, schema, schema.table or schema.table.column`);
6
+ return {
7
+ schema,
8
+ table,
9
+ column
10
+ };
11
+ };
12
+ const matchesTarget = (target, table) => (!target.schema || target.schema === table.schema) && (!target.table || target.table === table.name);
13
+ const isExcluded = (excludes, table) => excludes.includes(table.schema) || excludes.includes(`${table.schema}.${table.name}`);
14
+ //#endregion
15
+ export { isExcluded, matchesTarget, parseTarget };
@@ -0,0 +1,30 @@
1
+ import { keyColumns } from "../db/keys.js";
2
+ import { findField } from "./existing-config.js";
3
+ import { isTenantColumn } from "../infer/tenant.js";
4
+ import { isSoftDeleteColumn } from "./resource-plan.js";
5
+ //#region src/scaffold/ui-fields.ts
6
+ const uiFields = (table, existing, decisions) => {
7
+ const key = keyColumns(table) ?? [];
8
+ return table.columns.flatMap((column) => {
9
+ const declared = existing && findField(existing, column.name);
10
+ const decided = decisions.find((d) => d.proposal.column === column.name);
11
+ const resolved = declared ? {
12
+ name: declared.name,
13
+ type: declared.type ?? "text",
14
+ ignored: declared.ignored
15
+ } : decided && {
16
+ name: decided.proposal.name,
17
+ type: decided.proposal.type,
18
+ ignored: decided.ignored
19
+ };
20
+ if (!resolved || resolved.ignored) return [];
21
+ const role = key.includes(column.name) ? "key" : isTenantColumn(column.name) ? "tenant" : isSoftDeleteColumn(table, column.name) ? "softDelete" : "plain";
22
+ return [{
23
+ name: resolved.name,
24
+ type: resolved.type,
25
+ role
26
+ }];
27
+ });
28
+ };
29
+ //#endregion
30
+ export { uiFields };
@@ -0,0 +1,40 @@
1
+ import { humanize, humanizeSingular } from "../infer/names.js";
2
+ import { parseSource } from "./project.js";
3
+ import { methodCalls } from "./existing-config.js";
4
+ import { Node, SyntaxKind } from "ts-morph";
5
+ //#region src/scaffold/ui-file.ts
6
+ const auditField = /^(created|updated)(At|On)$/;
7
+ const longText = /^(notes?|description|body|comment|comments)$/;
8
+ const listSize = 5;
9
+ const listColumns = (fields) => {
10
+ const plain = fields.filter((f) => f.role === "plain" && f.type !== "json" && !auditField.test(f.name));
11
+ const scalars = plain.filter((f) => f.type !== "relation" && !longText.test(f.name));
12
+ const relations = plain.filter((f) => f.type === "relation");
13
+ const picked = [...scalars.slice(0, listSize), ...scalars.length < 3 ? relations : []].slice(0, listSize);
14
+ return (picked.length > 0 ? picked : fields.filter((f) => f.role === "key")).map((f) => f.name);
15
+ };
16
+ const viewName = (resource) => `${resource}View`;
17
+ const sectionText = {
18
+ names: (t) => `.names({ singular: '${humanizeSingular(t.tableName)}', plural: '${humanize(t.tableName)}' })`,
19
+ list: (t) => `.list((r) => ({ columns: [${listColumns(t.fields).map((n) => `r.${n}`).join(", ")}] }))`
20
+ };
21
+ const lines = (sections, target) => sections.map((s) => ` ${sectionText[s](target)}`);
22
+ const renderUiFile = (target, sections) => [
23
+ `import { view } from '@protobase/schema'`,
24
+ `import type { ${target.resource} } from './data'`,
25
+ "",
26
+ `export const ${viewName(target.resource)} = view<typeof ${target.resource}>('${target.resource}')`,
27
+ ...lines(sections, target),
28
+ ""
29
+ ].join("\n");
30
+ const addUiSections = (text, target, sections) => {
31
+ const source = parseSource(text);
32
+ const chain = source.getDescendantsOfKind(SyntaxKind.CallExpression).find((call) => call.getExpression().getText() === "view")?.getFirstAncestor((a) => Node.isVariableDeclaration(a))?.getInitializer();
33
+ if (!chain || !Node.isCallExpression(chain)) throw new Error("ui.ts has no view(...) export to extend");
34
+ const missing = sections.filter((s) => methodCalls(chain, s).length === 0);
35
+ if (missing.length === 0) return text;
36
+ chain.replaceWithText(`${chain.getText()}\n${lines(missing, target).join("\n")}`);
37
+ return source.getFullText();
38
+ };
39
+ //#endregion
40
+ export { addUiSections, listColumns, renderUiFile, viewName };
@@ -0,0 +1,30 @@
1
+ import { isApiPath } from "../bundle/manifest.js";
2
+ import { serveStaticSite } from "./static-site.js";
3
+ import { Hono } from "hono";
4
+ import { logger } from "hono/logger";
5
+ import { configExports, createAdmin } from "@protobase/server";
6
+ //#region src/serve/app.ts
7
+ const createServeApp = ({ project, db, requestLog, site }) => {
8
+ const { resources, views, pages, userMenu } = configExports(project.config);
9
+ const admin = createAdmin({
10
+ resources,
11
+ views,
12
+ pages,
13
+ ...userMenu && { userMenu },
14
+ db,
15
+ authenticate: project.authenticate,
16
+ ...project.auth && { auth: project.auth },
17
+ options: {
18
+ onUnhandledError: (error) => console.error(error),
19
+ ...project.options
20
+ }
21
+ });
22
+ const app = new Hono();
23
+ if (requestLog) app.use(logger());
24
+ app.get("/health", (c) => c.json({ status: "ok" }));
25
+ if (site) app.use("*", (c, next) => isApiPath(site.api, c.req.path) ? next() : serveStaticSite(site, c.req.raw));
26
+ app.route("/", admin);
27
+ return app;
28
+ };
29
+ //#endregion
30
+ export { createServeApp };
@@ -0,0 +1,16 @@
1
+ import { manifestFile } from "../bundle/manifest.js";
2
+ import { protobaseVersion } from "../version/version.js";
3
+ import { bundleVersionProblem } from "../version/compatibility.js";
4
+ import { existsSync } from "node:fs";
5
+ import path from "node:path";
6
+ import { readFile } from "node:fs/promises";
7
+ //#region src/serve/bundle-version.ts
8
+ const checkBundleVersion = async (configFile, runtimeVersion = protobaseVersion) => {
9
+ const file = path.join(path.dirname(configFile), manifestFile);
10
+ if (!existsSync(file)) throw new Error(`${file} is missing; serve the config module of a bundle folder from \`protobase build\``);
11
+ const manifest = JSON.parse(await readFile(file, "utf8"));
12
+ const problem = bundleVersionProblem(runtimeVersion, manifest?.protobase);
13
+ if (problem) throw new Error(`${file}: ${problem}`);
14
+ };
15
+ //#endregion
16
+ export { checkBundleVersion };
@@ -0,0 +1,13 @@
1
+ //#region src/serve/env.ts
2
+ const defaultPort = 8787;
3
+ const readServeEnv = (env) => {
4
+ const port = env.PORT ? Number(env.PORT) : defaultPort;
5
+ if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error(`PORT must be a port number, not "${env.PORT}"`);
6
+ return {
7
+ port,
8
+ databaseUrl: env.DATABASE_URL || void 0,
9
+ requestLog: Boolean(env.REQUEST_LOG)
10
+ };
11
+ };
12
+ //#endregion
13
+ export { defaultPort, readServeEnv };
@@ -0,0 +1,21 @@
1
+ //#region src/serve/host-module-ids.ts
2
+ const hostModuleIds = [
3
+ "@protobase/schema",
4
+ "@protobase/layout",
5
+ "@protobase/layout/jsx-runtime",
6
+ "@protobase/query",
7
+ "@protobase/server",
8
+ "kysely",
9
+ "kysely/helpers/postgres",
10
+ "pg",
11
+ "postgres",
12
+ "better-auth",
13
+ "hono",
14
+ "jose",
15
+ "zod",
16
+ "aip-parsers",
17
+ "@hono/node-server",
18
+ "@scalar/hono-api-reference"
19
+ ];
20
+ //#endregion
21
+ export { hostModuleIds };
@@ -0,0 +1,49 @@
1
+ import * as postgres$1 from "postgres";
2
+ import * as schema from "@protobase/schema";
3
+ import * as nodeServer from "@hono/node-server";
4
+ import * as pg$1 from "pg";
5
+ import * as kysely from "kysely";
6
+ import * as hono from "hono";
7
+ import * as server from "@protobase/server";
8
+ import * as apiReference from "@scalar/hono-api-reference";
9
+ import * as aipParsers from "aip-parsers";
10
+ import * as betterAuth from "better-auth";
11
+ import * as jose from "jose";
12
+ import * as kyselyPostgres from "kysely/helpers/postgres";
13
+ import * as zod from "zod";
14
+ import * as layout from "@protobase/layout";
15
+ import * as query from "@protobase/query";
16
+ //#region src/serve/host-modules.ts
17
+ const hostModules = {
18
+ "@protobase/schema": schema,
19
+ "@protobase/layout": layout,
20
+ "@protobase/layout/jsx-runtime": layout.jsxRuntime,
21
+ "@protobase/query": query,
22
+ "@protobase/server": server,
23
+ kysely,
24
+ "kysely/helpers/postgres": kyselyPostgres,
25
+ pg: pg$1,
26
+ postgres: postgres$1,
27
+ "better-auth": betterAuth,
28
+ hono,
29
+ jose,
30
+ zod,
31
+ "aip-parsers": aipParsers,
32
+ "@hono/node-server": nodeServer,
33
+ "@scalar/hono-api-reference": apiReference
34
+ };
35
+ const provideHostModules = () => {
36
+ const bun = globalThis.Bun;
37
+ if (!bun) return;
38
+ bun.plugin({
39
+ name: "protobase-host-modules",
40
+ setup: (build) => {
41
+ for (const [id, exports] of Object.entries(hostModules)) build.module(id, () => ({
42
+ exports,
43
+ loader: "object"
44
+ }));
45
+ }
46
+ });
47
+ };
48
+ //#endregion
49
+ export { provideHostModules };