@protobase/cli 0.0.0-stage → 0.1.0
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,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 };
|