@protobase/cli 0.67.3 → 0.67.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/build/app-icons.js +1 -1
- package/dist/build/favicon.js +2 -2
- package/dist/cli/package.js +1 -1
- package/dist/doctor/run.js +2 -1
- package/dist/doctor/user-table-models.js +15 -8
- package/dist/eject/eject-files.js +73 -0
- package/dist/eject/read-ejected.js +37 -0
- package/dist/eject/register.js +23 -0
- package/dist/eject/run.js +112 -0
- package/dist/eject/table-ddl.js +17 -0
- package/dist/eject/table-migration.js +32 -0
- package/dist/infer/proposal.d.ts +1 -0
- package/dist/infer/render-field.js +1 -0
- package/dist/main.js +2 -0
- package/dist/migration/parts.js +6 -1
- package/dist/project/favicon-link.js +1 -1
- package/dist/scaffold/data-file.js +9 -2
- package/dist/scaffold/ui-file.js +10 -1
- package/package.json +7 -7
- package/skills/protobase/references/features/look-and-language.md +2 -1
- package/skills/protobase/references/features/start-a-project.md +1 -1
- package/skills/protobase/references/features/user-tables.md +1 -0
- package/skills/protobase/references/getting-started.md +1 -1
- package/skills/protobase/references/reference/app-identity.md +13 -5
- package/skills/protobase/references/reference/auth.md +1 -1
- package/skills/protobase/references/reference/calls.md +2 -1
- package/skills/protobase/references/reference/cli.md +29 -3
- package/skills/protobase/references/reference/custom-components.md +1 -1
- package/skills/protobase/references/reference/notifications.md +1 -1
- package/skills/protobase/references/reference/theme.md +2 -2
- package/skills/protobase/references/reference/ui-config.md +1 -1
- package/skills/protobase/references/reference/user-tables.md +21 -2
- package/skills/protobase/references/reference/versioning.md +1 -1
package/dist/build/app-icons.js
CHANGED
|
@@ -35,7 +35,7 @@ const projectIcons = (projectDir) => ({
|
|
|
35
35
|
png: Object.keys(pngIcons).filter((icon) => existsSync(path.join(projectDir, pngIcons[icon].file)))
|
|
36
36
|
});
|
|
37
37
|
/**
|
|
38
|
-
* Writes the app's icons into `publicDir`: the favicon, the project's or
|
|
38
|
+
* Writes the app's icons into `publicDir`: the favicon, the project's or the neutral mark (see `buildFavicon`), and the PNGs
|
|
39
39
|
* the project has, each checked to be its size. Says which are the project's own, which the server's manifest names.
|
|
40
40
|
*/
|
|
41
41
|
const buildIcons = async ({ projectDir, publicDir }) => {
|
package/dist/build/favicon.js
CHANGED
|
@@ -5,12 +5,12 @@ import path from "node:path";
|
|
|
5
5
|
import { copyFile } from "node:fs/promises";
|
|
6
6
|
//#region src/build/favicon.ts
|
|
7
7
|
const defaultFavicon = path.join(adminAppDir, "favicon.svg");
|
|
8
|
-
/** The favicon the app shows for the project in `projectDir`: its protobase.favicon.svg, or
|
|
8
|
+
/** The favicon the app shows for the project in `projectDir`: its protobase.favicon.svg, or a neutral mark without one. */
|
|
9
9
|
const projectFavicon = (projectDir) => {
|
|
10
10
|
const own = projectDir === void 0 ? void 0 : path.join(projectDir, faviconFile);
|
|
11
11
|
return own && existsSync(own) ? own : defaultFavicon;
|
|
12
12
|
};
|
|
13
|
-
/** Writes the project's favicon, or
|
|
13
|
+
/** Writes the project's favicon, or the neutral mark when it has none, to `outFile`, so the app's link to it always resolves. */
|
|
14
14
|
const buildFavicon = ({ projectDir, outFile }) => copyFile(projectFavicon(projectDir), outFile);
|
|
15
15
|
//#endregion
|
|
16
16
|
export { buildFavicon, faviconFile, projectFavicon };
|
package/dist/cli/package.js
CHANGED
package/dist/doctor/run.js
CHANGED
|
@@ -52,7 +52,8 @@ const runDoctor = async (options, out) => {
|
|
|
52
52
|
out(`Checked ${builtIn.models.length > 0 ? `${counted} and ${builtIn.models.length} built-in ones` : counted}: ${errors} errors, ${issues.length - errors} warnings.\n`);
|
|
53
53
|
return {
|
|
54
54
|
errors,
|
|
55
|
-
warnings: issues.length - errors
|
|
55
|
+
warnings: issues.length - errors,
|
|
56
|
+
issues
|
|
56
57
|
};
|
|
57
58
|
};
|
|
58
59
|
//#endregion
|
|
@@ -1,22 +1,29 @@
|
|
|
1
|
-
import { userTableResources } from "@protobase/server/config-exports";
|
|
1
|
+
import { ownedTableNames, userTableResources } from "@protobase/server/config-exports";
|
|
2
2
|
//#region src/doctor/user-table-models.ts
|
|
3
3
|
/**
|
|
4
|
-
* The models of the tables users add, which doctor checks like the config's,
|
|
5
|
-
*
|
|
4
|
+
* The models of the tables users add, which doctor checks like the config's, an error for each definition that does
|
|
5
|
+
* not fit the database, and a warning for each whose name a resource in code has, as the server leaves those out: one
|
|
6
|
+
* that code owns, as `protobase eject` writes it, waits for `protobase migration` to delete its definition.
|
|
6
7
|
*/
|
|
7
8
|
const userTableModels = async (db, codeModels) => {
|
|
8
9
|
const tables = await userTableResources(db);
|
|
9
10
|
const taken = new Set(codeModels.map((model) => model.name));
|
|
11
|
+
const owned = new Set(ownedTableNames(codeModels));
|
|
10
12
|
const built = tables.resources.map((source) => source.toModel());
|
|
13
|
+
const takenIssue = (name) => owned.has(name) ? {
|
|
14
|
+
severity: "warning",
|
|
15
|
+
resource: name,
|
|
16
|
+
message: "a resource in code owns this user table now; once that code has deployed, `protobase migration` writes the delete of its definition"
|
|
17
|
+
} : {
|
|
18
|
+
severity: "warning",
|
|
19
|
+
resource: name,
|
|
20
|
+
message: "a resource in code has the name of this user table, which is not served"
|
|
21
|
+
};
|
|
11
22
|
const issues = [...tables.problems.map((message) => ({
|
|
12
23
|
severity: "error",
|
|
13
24
|
resource: "user tables",
|
|
14
25
|
message
|
|
15
|
-
})), ...built.filter((model) => taken.has(model.name)).map((model) => (
|
|
16
|
-
severity: "warning",
|
|
17
|
-
resource: model.name,
|
|
18
|
-
message: "a resource in code has the name of this user table, which is not served"
|
|
19
|
-
}))];
|
|
26
|
+
})), ...built.filter((model) => taken.has(model.name)).map((model) => takenIssue(model.name))];
|
|
20
27
|
return {
|
|
21
28
|
models: built.filter((model) => !taken.has(model.name)),
|
|
22
29
|
issues
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { camelCase } from "../infer/names.js";
|
|
2
|
+
import { proposeField } from "../infer/proposal.js";
|
|
3
|
+
import { renderDataFile } from "../scaffold/data-file.js";
|
|
4
|
+
import { planResource } from "../scaffold/resource-plan.js";
|
|
5
|
+
import { uiFields } from "../scaffold/ui-fields.js";
|
|
6
|
+
import { renderUiFile } from "../scaffold/ui-file.js";
|
|
7
|
+
import { maxTextLength } from "@protobase/server/config-exports";
|
|
8
|
+
//#region src/eject/eject-files.ts
|
|
9
|
+
const decisionOf = ({ table, definition, model }, column) => {
|
|
10
|
+
const proposal = {
|
|
11
|
+
...proposeField(table, column, {
|
|
12
|
+
table,
|
|
13
|
+
resourceNameOf: (_, name) => name
|
|
14
|
+
}),
|
|
15
|
+
name: column.name
|
|
16
|
+
};
|
|
17
|
+
const served = model.fields[column.name];
|
|
18
|
+
if (!served) return {
|
|
19
|
+
proposal,
|
|
20
|
+
ignored: true
|
|
21
|
+
};
|
|
22
|
+
const capped = definition.fields.some((field) => field.name === column.name && field.type === "text");
|
|
23
|
+
return {
|
|
24
|
+
proposal: {
|
|
25
|
+
...proposal,
|
|
26
|
+
filterable: served.filterable,
|
|
27
|
+
sortable: served.sortable,
|
|
28
|
+
...capped && { max: maxTextLength }
|
|
29
|
+
},
|
|
30
|
+
ignored: false
|
|
31
|
+
};
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* The config/<name>/data.ts and ui.ts that serve a table users added as the server serves it from its definition, written
|
|
35
|
+
* with scaffold's renderers: its resource of the same name over the same table, its fields, key, search and admins-only
|
|
36
|
+
* access, then its view with the definition's names, title and field labels, and scaffold's list.
|
|
37
|
+
*/
|
|
38
|
+
const ejectFiles = (ejected) => {
|
|
39
|
+
const { table, definition, model } = ejected;
|
|
40
|
+
const decisions = table.columns.map((column) => decisionOf(ejected, column));
|
|
41
|
+
const plan = planResource(definition.name, table, decisions);
|
|
42
|
+
if (!plan) throw new Error(`${definition.name}: its table has no primary key`);
|
|
43
|
+
const data = renderDataFile({
|
|
44
|
+
...plan,
|
|
45
|
+
softDelete: void 0,
|
|
46
|
+
tenant: void 0,
|
|
47
|
+
search: model.search?.[0],
|
|
48
|
+
adminsOnly: true
|
|
49
|
+
});
|
|
50
|
+
const labels = {
|
|
51
|
+
singular: definition.singular_label,
|
|
52
|
+
plural: definition.plural_label,
|
|
53
|
+
...definition.title_field !== null && { title: definition.title_field },
|
|
54
|
+
fields: definition.fields.map((field) => ({
|
|
55
|
+
name: field.name,
|
|
56
|
+
label: field.label,
|
|
57
|
+
multiline: field.type === "long_text"
|
|
58
|
+
}))
|
|
59
|
+
};
|
|
60
|
+
const target = {
|
|
61
|
+
resource: definition.name,
|
|
62
|
+
identifier: camelCase(definition.name),
|
|
63
|
+
tableName: table.name,
|
|
64
|
+
fields: uiFields(table, void 0, decisions),
|
|
65
|
+
labels
|
|
66
|
+
};
|
|
67
|
+
return {
|
|
68
|
+
data,
|
|
69
|
+
ui: renderUiFile(target, ["names", "list"])
|
|
70
|
+
};
|
|
71
|
+
};
|
|
72
|
+
//#endregion
|
|
73
|
+
export { ejectFiles };
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { connect } from "../db/connect.js";
|
|
2
|
+
import { introspect } from "../db/introspect.js";
|
|
3
|
+
import { readOnlyDb } from "../db/read-only-db.js";
|
|
4
|
+
import { readTableDdl } from "./table-ddl.js";
|
|
5
|
+
import { userTableResources, userTablesSchema } from "@protobase/server/config-exports";
|
|
6
|
+
//#region src/eject/read-ejected.ts
|
|
7
|
+
const notServed = (name, problems) => problems.find((problem) => problem.startsWith(`The user table "${name}" `)) ?? `No table users added is named "${name}"`;
|
|
8
|
+
/**
|
|
9
|
+
* Each named table users added as the server serves it, with the constraints and indexes of its table, read from the
|
|
10
|
+
* database at `url` in read-only sessions. A name the server serves no table of stops it, with why.
|
|
11
|
+
*/
|
|
12
|
+
const readEjected = async (url, names) => {
|
|
13
|
+
const sql = connect(url);
|
|
14
|
+
const db = readOnlyDb(url);
|
|
15
|
+
try {
|
|
16
|
+
const [relations, served] = await Promise.all([introspect(sql), userTableResources(db)]);
|
|
17
|
+
const tables = names.map((name) => {
|
|
18
|
+
const definition = served.definitions.find((each) => each.name === name);
|
|
19
|
+
if (!definition) throw new Error(notServed(name, served.problems));
|
|
20
|
+
return {
|
|
21
|
+
table: relations.find((each) => each.schema === userTablesSchema && each.name === name),
|
|
22
|
+
definition,
|
|
23
|
+
model: served.resources.map((resource) => resource.toModel()).find((each) => each.name === name)
|
|
24
|
+
};
|
|
25
|
+
});
|
|
26
|
+
const read = [];
|
|
27
|
+
for (const ejected of tables) read.push({
|
|
28
|
+
ejected,
|
|
29
|
+
ddl: await readTableDdl(sql, ejected.table)
|
|
30
|
+
});
|
|
31
|
+
return read;
|
|
32
|
+
} finally {
|
|
33
|
+
await Promise.all([sql.end(), db.destroy()]);
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
//#endregion
|
|
37
|
+
export { readEjected };
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { isConnectionString, resolveConnectionUrl } from "../db/connection-url.js";
|
|
2
|
+
import { loadNearestEnvFile } from "../dev/env.js";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
//#region src/eject/register.ts
|
|
5
|
+
const out = (text) => process.stdout.write(text);
|
|
6
|
+
const registerEject = (program) => {
|
|
7
|
+
program.command("eject").description("Take tables users added over as code: write config/<table>/data.ts and ui.ts, and a migration that makes each table as it is").argument("<args...>", "connection string (default: DATABASE_URL from the environment or .env) followed by the names of the tables").option("--env <variable>", "read the connection string from this environment variable").option("--config <dir>", "config folder", "config").option("--dir <dir>", "migrations folder", "db/migrations").action(async (args, opts) => {
|
|
8
|
+
loadNearestEnvFile(process.cwd());
|
|
9
|
+
const [first, ...rest] = args;
|
|
10
|
+
const fromArgument = !opts.env && first !== void 0 && isConnectionString(first);
|
|
11
|
+
const url = resolveConnectionUrl(fromArgument ? first : void 0, opts.env);
|
|
12
|
+
const { runEject } = await import("./run.js");
|
|
13
|
+
await runEject({
|
|
14
|
+
url,
|
|
15
|
+
names: fromArgument ? rest : args,
|
|
16
|
+
configDir: path.resolve(opts.config),
|
|
17
|
+
migrationsDir: path.resolve(opts.dir),
|
|
18
|
+
projectDir: process.cwd()
|
|
19
|
+
}, out);
|
|
20
|
+
});
|
|
21
|
+
};
|
|
22
|
+
//#endregion
|
|
23
|
+
export { registerEject };
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { camelCase } from "../infer/names.js";
|
|
2
|
+
import { readExistingResources } from "../scaffold/existing-config.js";
|
|
3
|
+
import { ensureExports } from "../scaffold/index-file.js";
|
|
4
|
+
import { viewName } from "../scaffold/ui-file.js";
|
|
5
|
+
import { nextMigrationName } from "../auth/migration-file.js";
|
|
6
|
+
import { runDoctor } from "../doctor/run.js";
|
|
7
|
+
import { ejectFiles } from "./eject-files.js";
|
|
8
|
+
import { readEjected } from "./read-ejected.js";
|
|
9
|
+
import { tableMigration } from "./table-migration.js";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
|
12
|
+
import { userTablesSchema } from "@protobase/server/config-exports";
|
|
13
|
+
//#region src/eject/run.ts
|
|
14
|
+
const readText = (file) => readFile(file, "utf8").catch((error) => {
|
|
15
|
+
if (error.code === "ENOENT") return void 0;
|
|
16
|
+
throw error;
|
|
17
|
+
});
|
|
18
|
+
const readNames = (dir) => readdir(dir).catch((error) => {
|
|
19
|
+
if (error.code === "ENOENT") return [];
|
|
20
|
+
throw error;
|
|
21
|
+
});
|
|
22
|
+
const migrationText = (tables, statements) => `-- The tables users added that \`protobase eject\` took over as code: ${tables.join(", ")}.\n-- Where they were added this changes nothing and moves no row; on an empty database it makes them, without their rows.\n\n${statements.join("\n\n")}\n`;
|
|
23
|
+
const checkNames = async ({ names, configDir }) => {
|
|
24
|
+
if (names.length === 0) throw new Error("Name the tables to eject");
|
|
25
|
+
const twice = names.find((name, i) => names.indexOf(name) !== i);
|
|
26
|
+
if (twice) throw new Error(`"${twice}" is named twice`);
|
|
27
|
+
const existing = await readExistingResources(configDir);
|
|
28
|
+
for (const name of names) {
|
|
29
|
+
const owner = existing.find((resource) => resource.name === name || resource.table === `${userTablesSchema}.${name}`);
|
|
30
|
+
if (owner) throw new Error(`"${name}" is a resource in code already, in ${path.join(owner.dir, "data.ts")}`);
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
const configChanges = async (options, files) => {
|
|
34
|
+
const changes = [];
|
|
35
|
+
for (const { name, data, ui } of files) for (const [file, after] of [[path.join(options.configDir, name, "data.ts"), data], [path.join(options.configDir, name, "ui.ts"), ui]]) {
|
|
36
|
+
if (await readText(file) !== void 0) throw new Error(`${file} exists already`);
|
|
37
|
+
changes.push({
|
|
38
|
+
path: file,
|
|
39
|
+
after
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
const index = path.join(options.configDir, "index.ts");
|
|
43
|
+
const before = await readText(index);
|
|
44
|
+
const registrations = files.flatMap(({ name }) => [{
|
|
45
|
+
from: `./${name}/data`,
|
|
46
|
+
names: [camelCase(name)]
|
|
47
|
+
}, {
|
|
48
|
+
from: `./${name}/ui`,
|
|
49
|
+
names: [viewName(camelCase(name))]
|
|
50
|
+
}]);
|
|
51
|
+
changes.push({
|
|
52
|
+
path: index,
|
|
53
|
+
...before !== void 0 && { before },
|
|
54
|
+
after: ensureExports(before ?? "", registrations)
|
|
55
|
+
});
|
|
56
|
+
return changes;
|
|
57
|
+
};
|
|
58
|
+
const write = async ({ path: file, before, after }) => {
|
|
59
|
+
const made = await mkdir(path.dirname(file), { recursive: true });
|
|
60
|
+
await writeFile(file, after, { flag: before === void 0 ? "wx" : "w" });
|
|
61
|
+
return made;
|
|
62
|
+
};
|
|
63
|
+
const rollBack = async (changes, folders) => {
|
|
64
|
+
for (const { path: file, before } of changes) if (before === void 0) await rm(file, { force: true });
|
|
65
|
+
else await writeFile(file, before);
|
|
66
|
+
for (const folder of folders.reverse()) await rm(folder, {
|
|
67
|
+
recursive: true,
|
|
68
|
+
force: true
|
|
69
|
+
});
|
|
70
|
+
};
|
|
71
|
+
/**
|
|
72
|
+
* Takes tables users added over as code: writes each one's config/<name>/data.ts and ui.ts, exports them from
|
|
73
|
+
* config/index.ts, and writes one migration that makes the tables as they are, then runs doctor on the config. When
|
|
74
|
+
* doctor finds an error in an ejected resource, or cannot run, every file is put back as it was and it stops. It only
|
|
75
|
+
* reads the database.
|
|
76
|
+
*/
|
|
77
|
+
const runEject = async (options, out) => {
|
|
78
|
+
await checkNames(options);
|
|
79
|
+
const tables = await readEjected(options.url, options.names);
|
|
80
|
+
const config = await configChanges(options, tables.map(({ ejected }) => ({
|
|
81
|
+
name: ejected.definition.name,
|
|
82
|
+
...ejectFiles(ejected)
|
|
83
|
+
})));
|
|
84
|
+
const migration = path.join(options.migrationsDir, nextMigrationName(await readNames(options.migrationsDir), `eject_${options.names.join("_")}`));
|
|
85
|
+
const statements = tables.flatMap(({ ejected, ddl }) => tableMigration(ejected.table, ddl));
|
|
86
|
+
const changes = [...config, {
|
|
87
|
+
path: migration,
|
|
88
|
+
after: migrationText(options.names.map((name) => `${userTablesSchema}.${name}`), statements)
|
|
89
|
+
}];
|
|
90
|
+
const folders = [];
|
|
91
|
+
for (const change of changes) {
|
|
92
|
+
const made = await write(change);
|
|
93
|
+
if (made) folders.push(made);
|
|
94
|
+
}
|
|
95
|
+
const shown = (file) => path.relative(options.projectDir, file);
|
|
96
|
+
for (const change of changes) out(`${change.before === void 0 ? "created" : "updated"} ${shown(change.path)}\n`);
|
|
97
|
+
const errors = (await runDoctor({
|
|
98
|
+
url: options.url,
|
|
99
|
+
configDir: options.configDir,
|
|
100
|
+
projectDir: options.projectDir
|
|
101
|
+
}, out).catch(async (error) => {
|
|
102
|
+
await rollBack(changes, folders);
|
|
103
|
+
throw error;
|
|
104
|
+
})).issues.filter((issue) => issue.severity === "error" && options.names.includes(issue.resource));
|
|
105
|
+
if (errors.length > 0) {
|
|
106
|
+
await rollBack(changes, folders);
|
|
107
|
+
throw new Error(`doctor found ${errors.length} error${errors.length === 1 ? "" : "s"} in the ejected ${errors.length === 1 ? "resource" : "resources"}, so every file is as it was`);
|
|
108
|
+
}
|
|
109
|
+
out(`\nDeploy this soon: until it serves, the admin app still adds fields to ${options.names.join(", ")}. Once it has deployed, \`protobase migration\` writes the delete of ${options.names.length === 1 ? "its definition" : "their definitions"}.\n`);
|
|
110
|
+
};
|
|
111
|
+
//#endregion
|
|
112
|
+
export { runEject };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
//#region src/eject/table-ddl.ts
|
|
2
|
+
/** The constraints and indexes of `table`, read from the catalogue; the primary key first, the rest by name. */
|
|
3
|
+
const readTableDdl = async (sql, table) => {
|
|
4
|
+
const constraints = await sql`
|
|
5
|
+
select c.conname as name, pg_get_constraintdef(c.oid) as definition from pg_constraint c
|
|
6
|
+
where c.conrelid = format('%I.%I', ${table.schema}::text, ${table.name}::text)::regclass and c.contype <> 'n' order by c.contype <> 'p', c.conname`;
|
|
7
|
+
const indexes = await sql`
|
|
8
|
+
select pg_get_indexdef(i.indexrelid) as definition from pg_index i join pg_class ic on ic.oid = i.indexrelid
|
|
9
|
+
where i.indrelid = format('%I.%I', ${table.schema}::text, ${table.name}::text)::regclass and not exists (select from pg_constraint c where c.conindid = i.indexrelid and c.conrelid = i.indrelid)
|
|
10
|
+
order by ic.relname`;
|
|
11
|
+
return {
|
|
12
|
+
constraints: [...constraints],
|
|
13
|
+
indexes: indexes.map((index) => index.definition)
|
|
14
|
+
};
|
|
15
|
+
};
|
|
16
|
+
//#endregion
|
|
17
|
+
export { readTableDdl };
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { tableKey } from "../db/model.js";
|
|
2
|
+
//#region src/eject/table-migration.ts
|
|
3
|
+
const ident = (name) => `"${name.replaceAll("\"", "\"\"")}"`;
|
|
4
|
+
const qualified = (table) => `${ident(table.schema)}.${ident(table.name)}`;
|
|
5
|
+
const identity = (column) => column.identityAlways ? " generated always as identity" : " generated by default as identity";
|
|
6
|
+
const columnSql = (table, column) => {
|
|
7
|
+
if (column.generated) throw new Error(`${tableKey(table)}.${column.name} is a generated column, which eject does not write: Not implemented`);
|
|
8
|
+
const kept = column.identity ? identity(column) : `${column.defaultExpr === void 0 ? "" : ` default ${column.defaultExpr}`}${column.notNull ? " not null" : ""}`;
|
|
9
|
+
return ` ${ident(column.name)} ${column.formatted}${kept}`;
|
|
10
|
+
};
|
|
11
|
+
const ifNotExists = (definition) => {
|
|
12
|
+
const statement = /^CREATE (UNIQUE )?INDEX ("(?:[^"]|"")*"|\S+) ON /;
|
|
13
|
+
if (!statement.test(definition)) throw new Error(`Unexpected index definition: ${definition}`);
|
|
14
|
+
return definition.replace(statement, (_, unique, name) => `create ${unique ? "unique " : ""}index if not exists ${name} on `);
|
|
15
|
+
};
|
|
16
|
+
const trigramIndex = /\b(gin|gist)_trgm_ops\b/;
|
|
17
|
+
/**
|
|
18
|
+
* The statements that make `table` as the catalogue has it, each a no-op where it is already there: its schema, the
|
|
19
|
+
* pg_trgm extension when an index needs it, the table with its columns and constraints, then its indexes. On the
|
|
20
|
+
* database it was read from they change nothing; on an empty one they make the table again, without its rows.
|
|
21
|
+
*/
|
|
22
|
+
const tableMigration = (table, ddl) => {
|
|
23
|
+
const body = [...table.columns.map((column) => columnSql(table, column)), ...ddl.constraints.map(({ name, definition }) => ` constraint ${ident(name)} ${definition}`)];
|
|
24
|
+
return [
|
|
25
|
+
`create schema if not exists ${ident(table.schema)}`,
|
|
26
|
+
...ddl.indexes.some((index) => trigramIndex.test(index)) ? ["create extension if not exists pg_trgm"] : [],
|
|
27
|
+
`create table if not exists ${qualified(table)} (\n${body.join(",\n")}\n)`,
|
|
28
|
+
...ddl.indexes.map(ifNotExists)
|
|
29
|
+
].map((statement) => `${statement};`);
|
|
30
|
+
};
|
|
31
|
+
//#endregion
|
|
32
|
+
export { tableMigration };
|
package/dist/infer/proposal.d.ts
CHANGED
package/dist/main.js
CHANGED
|
@@ -5,6 +5,7 @@ import { loadNearestEnvFile } from "./dev/env.js";
|
|
|
5
5
|
import { placeholderSecretWarning } from "./project/placeholder-secret.js";
|
|
6
6
|
import { registerOrganizations } from "./organizations/register.js";
|
|
7
7
|
import { registerFiles } from "./files/register.js";
|
|
8
|
+
import { registerEject } from "./eject/register.js";
|
|
8
9
|
import { registerEncryption } from "./encryption/register.js";
|
|
9
10
|
import { registerMigration } from "./migration/register.js";
|
|
10
11
|
import { registerMobile } from "./mobile/register.js";
|
|
@@ -177,6 +178,7 @@ registerOrganizations(program);
|
|
|
177
178
|
registerToken(program);
|
|
178
179
|
registerAuth(program);
|
|
179
180
|
registerMigration(program);
|
|
181
|
+
registerEject(program);
|
|
180
182
|
registerFiles(program);
|
|
181
183
|
registerEncryption(program);
|
|
182
184
|
registerSkill(program);
|
package/dist/migration/parts.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { authMigrationTitle } from "../auth/migration-file.js";
|
|
2
|
-
import { authSchemaMigration, callsMigration, changesMigration, channelSendsMigration, commentsMigration, computedMigration, isCaptured, jobsSchemaMigration, notificationsMigration, queryRunnerMigration, realtimeRelayMigration, relayed, sqlSends, statusMigration, userTablesMigration, webhooksMigration } from "@protobase/server";
|
|
2
|
+
import { authSchemaMigration, callsMigration, changesMigration, channelSendsMigration, commentsMigration, computedMigration, isCaptured, jobsSchemaMigration, notificationsMigration, ownedDefinitionsMigration, queryRunnerMigration, realtimeRelayMigration, relayed, sqlSends, statusMigration, userTablesMigration, webhooksMigration } from "@protobase/server";
|
|
3
3
|
//#region src/migration/parts.ts
|
|
4
4
|
/** Every feature the project's config enables, in the order their files are written. */
|
|
5
5
|
const migrationParts = (project) => {
|
|
@@ -66,6 +66,11 @@ const migrationParts = (project) => {
|
|
|
66
66
|
title: "The schemas of the tables users add and of their definitions, and pg_trgm",
|
|
67
67
|
sql: () => userTablesMigration(project.db)
|
|
68
68
|
}] : [],
|
|
69
|
+
...project.userTables ? [{
|
|
70
|
+
name: "owned_definitions",
|
|
71
|
+
title: "The delete of the definitions of user tables that resources in code own now",
|
|
72
|
+
sql: () => ownedDefinitionsMigration(project.db, project.models)
|
|
73
|
+
}] : [],
|
|
69
74
|
...sqlSends(project.realtime) ? [{
|
|
70
75
|
name: "channel_sends",
|
|
71
76
|
title: "SQL's broadcasts to the record channels, channels.send()",
|
|
@@ -10,7 +10,7 @@ import { pngIcons } from "@protobase/schema/png-icons";
|
|
|
10
10
|
* Links the project's favicon at `/protobase.favicon.svg` in the app's page, and its Apple touch icon at
|
|
11
11
|
* `/protobase.apple-touch-icon.png`, which Safari takes for the home screen as it takes no SVG. A bundle always has
|
|
12
12
|
* the favicon (see `buildFavicon`) and the touch icon when the project has one (see `buildIcons`); without it Safari
|
|
13
|
-
* shows a picture of the page instead. The dev server answers them from the project's files, the favicon
|
|
13
|
+
* shows a picture of the page instead. The dev server answers them from the project's files, the favicon the neutral mark
|
|
14
14
|
* without one, the project's other PNG icons the same way, and `/favicon.ico` as a bundle's server does (see `faviconIcoFile`).
|
|
15
15
|
*/
|
|
16
16
|
const faviconLink = (projectDir) => {
|
|
@@ -10,6 +10,11 @@ const docComment = (plan) => {
|
|
|
10
10
|
const unique = plan.unique.map((columns) => `(${columns.join(", ")})`).join(", ");
|
|
11
11
|
return `/** ${tableKey(plan.table)}${unique ? `. Unique: ${unique}` : ""} */\n`;
|
|
12
12
|
};
|
|
13
|
+
const adminsRule = [
|
|
14
|
+
`import { f, resource, type AccessContext } from '@protobase/schema'`,
|
|
15
|
+
"",
|
|
16
|
+
`const admins = ({ user }: AccessContext) => user?.roles?.includes('admin') ?? false`
|
|
17
|
+
];
|
|
13
18
|
const renderDataFile = (plan) => {
|
|
14
19
|
const properties = plan.decisions.map((d) => ` ${propertyText(d)},`).join("\n");
|
|
15
20
|
const tail = [
|
|
@@ -17,10 +22,12 @@ const renderDataFile = (plan) => {
|
|
|
17
22
|
plan.softDelete && ` .softDelete((r) => r.${plan.softDelete})`,
|
|
18
23
|
plan.tenant && ` .tenant((r) => r.${plan.tenant})`,
|
|
19
24
|
plan.search && ` .search((r) => [r.${plan.search}])`,
|
|
20
|
-
plan.live && " .live()"
|
|
25
|
+
plan.live && " .live()",
|
|
26
|
+
plan.adminsOnly && " .explicitGrants()",
|
|
27
|
+
plan.adminsOnly && " .access({ list: admins, read: admins, create: admins, update: admins, delete: admins })"
|
|
21
28
|
].filter(Boolean);
|
|
22
29
|
return [
|
|
23
|
-
`import { f, resource } from '@protobase/schema'
|
|
30
|
+
...plan.adminsOnly ? adminsRule : [`import { f, resource } from '@protobase/schema'`],
|
|
24
31
|
"",
|
|
25
32
|
`${docComment(plan)}export const ${camelCase(plan.name)} = resource('${plan.name}')`,
|
|
26
33
|
` .table('${tableKey(plan.table)}')`,
|
package/dist/scaffold/ui-file.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { humanize, humanizeSingular } from "../infer/names.js";
|
|
2
2
|
import { parseSource } from "./project.js";
|
|
3
3
|
import { methodCalls } from "./existing-config.js";
|
|
4
|
+
import { quote } from "../infer/render-field.js";
|
|
4
5
|
import { Node, SyntaxKind } from "ts-morph";
|
|
5
6
|
//#region src/scaffold/ui-file.ts
|
|
6
7
|
const auditField = /^(created|updated)(At|On|_at|_on)$/;
|
|
@@ -48,19 +49,27 @@ const listSort = (fields) => {
|
|
|
48
49
|
return (dates.find((f) => createdField.test(f.name)) ?? dates[0])?.name;
|
|
49
50
|
};
|
|
50
51
|
const viewName = (identifier) => `${identifier}View`;
|
|
52
|
+
const namesText = (singular, plural) => `.names({ singular: ${quote(singular)}, plural: ${quote(plural)} })`;
|
|
51
53
|
const sectionText = {
|
|
52
|
-
names: (t) =>
|
|
54
|
+
names: (t) => t.labels ? namesText(t.labels.singular, t.labels.plural) : namesText(humanizeSingular(t.tableName), humanize(t.tableName)),
|
|
53
55
|
list: (t) => {
|
|
54
56
|
const sort = listSort(t.fields);
|
|
55
57
|
return `.list((r) => ({ columns: [${listColumns(t.fields).map((n) => `r.${n}`).join(", ")}]${sort ? `, sort: [[r.${sort}, 'desc']]` : ""} }))`;
|
|
56
58
|
}
|
|
57
59
|
};
|
|
60
|
+
const fieldLabel = ({ name, label, multiline }) => ` ${name}: r.${name}.label(${quote(label)})${multiline ? ".multiline()" : ""},`;
|
|
61
|
+
const labelLines = ({ title, fields }) => [...title ? [` .title((r) => r.${title})`] : [], ...fields.length > 0 ? [
|
|
62
|
+
" .fields((r) => ({",
|
|
63
|
+
...fields.map(fieldLabel),
|
|
64
|
+
" }))"
|
|
65
|
+
] : []];
|
|
58
66
|
const lines = (sections, target) => sections.map((s) => ` ${sectionText[s](target)}`);
|
|
59
67
|
const renderUiFile = (target, sections) => [
|
|
60
68
|
`import { view } from '@protobase/schema'`,
|
|
61
69
|
`import type { ${target.identifier} } from './data'`,
|
|
62
70
|
"",
|
|
63
71
|
`export const ${viewName(target.identifier)} = view<typeof ${target.identifier}>('${target.resource}')`,
|
|
72
|
+
...target.labels ? labelLines(target.labels) : [],
|
|
64
73
|
...lines(sections, target),
|
|
65
74
|
""
|
|
66
75
|
].join("\n");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@protobase/cli",
|
|
3
|
-
"version": "0.67.
|
|
3
|
+
"version": "0.67.4",
|
|
4
4
|
"description": "The protobase command: scaffold, doctor, dev, build and serve a Protobase project.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -43,12 +43,12 @@
|
|
|
43
43
|
"dependencies": {
|
|
44
44
|
"@better-auth/core": "^1.7.12",
|
|
45
45
|
"@hono/node-server": "^2.1.3",
|
|
46
|
-
"@protobase/config": "0.67.
|
|
47
|
-
"@protobase/layout": "0.67.
|
|
48
|
-
"@protobase/query": "0.67.
|
|
49
|
-
"@protobase/schema": "0.67.
|
|
50
|
-
"@protobase/server": "0.67.
|
|
51
|
-
"@protobase/ui": "0.67.
|
|
46
|
+
"@protobase/config": "0.67.4",
|
|
47
|
+
"@protobase/layout": "0.67.4",
|
|
48
|
+
"@protobase/query": "0.67.4",
|
|
49
|
+
"@protobase/schema": "0.67.4",
|
|
50
|
+
"@protobase/server": "0.67.4",
|
|
51
|
+
"@protobase/ui": "0.67.4",
|
|
52
52
|
"@react-email/render": "^2.1.0",
|
|
53
53
|
"@scalar/hono-api-reference": "^0.12.10",
|
|
54
54
|
"@sentry/vite-plugin": "^11.10.0",
|
|
@@ -6,7 +6,8 @@ The app's colours, name, icon, language and installation.
|
|
|
6
6
|
|
|
7
7
|
- [Theme](../reference/theme.md): One CSS file sets the app's colours, corners and fonts for light and dark, and keeps working on every release.
|
|
8
8
|
- [Dark mode](../reference/theme.md#light-dark-or-the-system): Each user picks light, dark or the system setting.
|
|
9
|
-
- [App name, icon and colours](../reference/app-identity.md): The app's own name, icon and colours in the sidebar, browser tabs, `/favicon.ico`, home-screen icon and mail.
|
|
9
|
+
- [App name, icon and colours](../reference/app-identity.md): The app's own name, icon and colours in the sidebar, sign-in page, browser tabs, API docs, `/favicon.ico`, home-screen icon and mail; an app without an icon of its own shows a neutral mark rather than Protobase's.
|
|
10
|
+
- [Runs on Protobase note](../reference/app-identity.md#runs-on-protobase-note): A host can end the sign-in page on a small "Runs on Protobase" line by setting `PROTOBASE_RUNS_ON_NOTE=true`; without it there is no note.
|
|
10
11
|
- [Logo and favicon](../reference/theme.md#logo-and-favicon): Your own favicon or logo replaces Protobase's.
|
|
11
12
|
- [Named icons](../reference/layouts.md#named-actions): Actions, key facts and card meta show an icon picked by name from a set, such as `play`, `bed` or `cpu`.
|
|
12
13
|
- [English and Dutch](../reference/languages.md): The built-in screens come in English and Dutch, with numbers and dates written to match.
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
Start a new app, or an admin over an existing Postgres database, and keep the config in step with it.
|
|
6
6
|
|
|
7
|
-
- [Project starter](../getting-started.md#start-an-app): `npm create protobase` (also `bun create` and `pnpm create`) starts a new app from the scratch, ERP or real estate preset, with its own Postgres in Docker for development, and prints the next steps. Each preset has an `AGENTS.md` with rules for coding agents.
|
|
7
|
+
- [Project starter](../getting-started.md#start-an-app): `npm create protobase` (also `bun create` and `pnpm create`) starts a new app from the scratch, ERP, property portal or real estate preset, with its own Postgres in Docker for development, and prints the next steps. Each preset has an `AGENTS.md` with rules for coding agents.
|
|
8
8
|
- [Start on an existing database](../getting-started.md#on-an-existing-database): `npm create protobase <database-url>` makes a project on a Postgres database you already have and asks which of its tables become resources, only reading the database.
|
|
9
9
|
- [Example apps](../getting-started.md#run-the-erp-example): Seeded example apps on Postgres: an ERP, real estate property management and a public property portal.
|
|
10
10
|
- [One config](../reference/config.md): The whole app is one object of data in `protobase.config.ts`, typed with `defineConfig`, with a sensible default for every key.
|
|
@@ -6,3 +6,4 @@ Tables an app's users add from the admin UI, by hand or from a file, and how a d
|
|
|
6
6
|
|
|
7
7
|
- [Tables defined in the database](../reference/user-tables.md#tables-defined-in-the-database) (beta): An app can keep tables its users add in its own database, beside the resources in its code. Each one is served like any other resource, at its own address with its list, record page, search and API. Every server process serves a new table within a second of it being added.
|
|
8
8
|
- [Adding tables and fields](../reference/user-tables.md#adding-tables-and-fields) (beta): Someone with the right capability creates a table with its fields, or adds a field to it, through the app's API. It is created in the database at once, with an index for every field but JSON so its list can be filtered by it, and sorted by it unless it is long text, and only admins can see it until access is widened.
|
|
9
|
+
- [Taking a table over as code](../reference/cli.md#eject) (beta): `protobase eject` turns a table someone added in the admin app into ordinary config and a migration in the project, so a developer can customise it like any other resource. Its records stay where they are.
|
|
@@ -10,7 +10,7 @@ An app's own React code, its components, field types and actions, is a [plugin](
|
|
|
10
10
|
|
|
11
11
|
## Start an app
|
|
12
12
|
|
|
13
|
-
`npm create protobase my-app` asks which preset to start from, scratch (the default), the ERP or real estate, writes the project into `my-app`, with the `@protobase` packages in `devDependencies` at the caret range of the latest release, so it is [bundled](reference/versioning.md#bundled-and-locked-projects): dev, the build and its scripts run the CLI's own copies, and a newer CLI in that range updates it with no change to its files. It prints the next steps. Without a folder it asks for one too. `--preset <name>` picks the preset without asking; without a terminal it takes scratch. `bun create protobase` and `pnpm create protobase` do the same, and take `--preset` without the `--` that npm needs. It needs Node 22.12 or later. Each preset has an `AGENTS.md` with the rules for a coding agent that changes the app, such as a new migration file for every schema change.
|
|
13
|
+
`npm create protobase my-app` asks which preset to start from, scratch (the default), the ERP, the property portal or real estate, writes the project into `my-app`, with the `@protobase` packages in `devDependencies` at the caret range of the latest release, so it is [bundled](reference/versioning.md#bundled-and-locked-projects): dev, the build and its scripts run the CLI's own copies, and a newer CLI in that range updates it with no change to its files. It prints the next steps. Without a folder it asks for one too. `--preset <name>` picks the preset without asking; without a terminal it takes scratch. `bun create protobase` and `pnpm create protobase` do the same, and take `--preset` without the `--` that npm needs. It needs Node 22.12 or later. Each preset has an `AGENTS.md` with the rules for a coding agent that changes the app, such as a new migration file for every schema change.
|
|
14
14
|
|
|
15
15
|
```sh
|
|
16
16
|
npm create protobase my-app
|
|
@@ -2,14 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
# App identity
|
|
4
4
|
|
|
5
|
-
The app's name, icon and colours, which its sidebar logo, browser tabs, home-screen icon, web manifest and mail all follow.
|
|
5
|
+
The app's name, icon and colours, which its sidebar logo, sign-in page, browser tabs, API docs, home-screen icon, web manifest and mail all follow.
|
|
6
6
|
|
|
7
|
-
An app looks like its own everywhere from three things in its project: its name, its icon and its colours. The sidebar's logo, the browser tab, the icon on a phone's home screen, the [installed app](installable-app.md) and the auth mail all follow them, with no component to write.
|
|
7
|
+
An app looks like its own everywhere from three things in its project: its name, its icon and its colours. The sidebar's logo, the sign-in page, the browser tab, the API docs, the icon on a phone's home screen, the [installed app](installable-app.md) and the auth mail all follow them, with no component to write.
|
|
8
8
|
|
|
9
9
|
| What | Where | Used by |
|
|
10
10
|
| --- | --- | --- |
|
|
11
|
-
| Name | `name` in `protobase.config.ts` ([config](config.md)), overridden at run time by `PROTOBASE_APP_NAME` | the sidebar's logo, the end of every tab title, the installed app's name, the [mobile app](../guides/mobile-app.md)'s name on the phone, passkey and authenticator prompts, auth mail and its sender's name |
|
|
12
|
-
| Icon | `protobase.favicon.svg` at the project's root | the browser tab, `/favicon.ico`, the sidebar's logo, the installed app's icon, the [MCP endpoint](mcp.md#the-apps-icon)'s icon in AI clients; the [mobile app](../guides/mobile-app.md) shows Protobase's icon instead |
|
|
11
|
+
| Name | `name` in `protobase.config.ts` ([config](config.md)), overridden at run time by `PROTOBASE_APP_NAME` | the sidebar's logo, the sign-in page's title ("Sign in to Acme Admin"), the end of every tab title, the title of the [API docs](api.md), the installed app's name, the [mobile app](../guides/mobile-app.md)'s name on the phone, passkey and authenticator prompts, auth mail and its sender's name |
|
|
12
|
+
| Icon | `protobase.favicon.svg` at the project's root | the browser tab, `/favicon.ico`, the sidebar's logo, the sign-in page, the installed app's icon, the [MCP endpoint](mcp.md#the-apps-icon)'s icon in AI clients; the [mobile app](../guides/mobile-app.md) shows Protobase's icon instead |
|
|
13
13
|
| Raster icons | optional PNGs beside the SVG, below | the iPhone home screen and Android launchers, which take no SVG |
|
|
14
14
|
| Colours | `themeColor` and `backgroundColor` in `protobase.config.ts` | the installed app's title bar and its start screen |
|
|
15
15
|
|
|
@@ -28,6 +28,8 @@ export default defineConfig({
|
|
|
28
28
|
|
|
29
29
|
`name` names the app; `PROTOBASE_APP_NAME`, when a deployment sets it, overrides it, so a deployment can rename an app without a new build. Without either the app is called `Protobase`.
|
|
30
30
|
|
|
31
|
+
The app's name titles the sign-in page, such as "Sign in to Acme Admin", the browser tab and the API docs, and the page shown before the app has users speaks of the app rather than of Protobase. An app with a name of its own but no icon shows its initial where the icon would be, in the sidebar and on the sign-in page, and the neutral mark below in the tab and as the installed app's icon.
|
|
32
|
+
|
|
31
33
|
## The icons
|
|
32
34
|
|
|
33
35
|
`protobase.favicon.svg` is the one source of the app's icon: the tab shows it, the [web manifest](installable-app.md#the-manifest) names it, and with it the sidebar's logo draws it in place of Protobase's mark, beside the app's name. A project's own [`shell.logo`](custom-components.md#shell-slots) still wins.
|
|
@@ -41,7 +43,7 @@ Some platforms take no SVG, so a project may put PNGs of the same icon beside it
|
|
|
41
43
|
| `protobase.icon-512.png` | 512 × 512 | the web manifest, for Android's install screens |
|
|
42
44
|
| `protobase.icon-maskable.png` | 512 × 512 | the web manifest's maskable icon, which Android crops to its own shape: keep the mark within the middle 80% |
|
|
43
45
|
|
|
44
|
-
The app answers `/favicon.ico`, which browsers and favicon services ask for when they have not read the page, with its icon: the 192 pixel PNG, else the touch icon, else the 512 pixel PNG, else the SVG, each with its own content type. Without a favicon of its own the app answers
|
|
46
|
+
The app answers `/favicon.ico`, which browsers and favicon services ask for when they have not read the page, with its icon: the 192 pixel PNG, else the touch icon, else the 512 pixel PNG, else the SVG, each with its own content type. Without a favicon of its own the app answers a neutral mark, a plain rounded square in no brand's colours, which the tab and the installed app show too.
|
|
45
47
|
|
|
46
48
|
Each is optional, and none is made from the SVG: without the touch icon an iPhone's home screen shows a picture of the page. `protobase build` copies the ones the project has to the bundle's `public/`, and fails on a file that is no PNG or not its size; `protobase dev` serves them from the project's root.
|
|
47
49
|
|
|
@@ -53,3 +55,9 @@ Each takes any CSS colour, and the web manifest carries the ones set:
|
|
|
53
55
|
- `backgroundColor`: the screen the installed app shows while it starts (`background_color`).
|
|
54
56
|
|
|
55
57
|
The app's own colours, its buttons and surfaces, are the [theme](theme.md)'s.
|
|
58
|
+
|
|
59
|
+
## Runs on Protobase note
|
|
60
|
+
|
|
61
|
+
A host can have the sign-in page end on one small line, "Runs on Protobase", below the sign-in card, which links to protobase.net. It is plain, muted text in the app's own font, and the note shows on the sign-in page only: not in dialogs, and not inside the app after signing in.
|
|
62
|
+
|
|
63
|
+
The host turns it on with `PROTOBASE_RUNS_ON_NOTE=true` where the app runs; without it, as self-hosted apps have it by default, there is no note. It is no option of `protobase.config.ts`, so it is the host's to set. The variable takes only `true` or `false`; any other value stops the app at startup, with an error naming the variable.
|
|
@@ -102,7 +102,7 @@ The store's migrations then make nothing of the user table, which a migration of
|
|
|
102
102
|
- Cookies are `SameSite=Lax`, and `Secure` when the app's `url` is https. When it is http with a port, their names carry it (`better-auth-5173.session_token`), because a browser shares cookies across the ports of a host: two apps on `localhost` then keep their own sign-ins.
|
|
103
103
|
- Sign-in is rate limited (5 password attempts per minute and client by default, `signInPerMinute`, which also limits [sign-up](#open-sign-up) requests; 3 per minute for emailed codes); [Client addresses](#client-addresses) says how a client is known.
|
|
104
104
|
- New passwords need at least 12 characters.
|
|
105
|
-
- `name`, at the config's top level, is the app's name: authenticator apps and passkey prompts show it, the admin app's sidebar shows it at the top beside the app's [icon](app-identity.md#the-icons), else
|
|
105
|
+
- `name`, at the config's top level, is the app's name: authenticator apps and passkey prompts show it, the admin app's sidebar shows it at the top beside the app's [icon](app-identity.md#the-icons), else its initial (Protobase's mark and wordmark when it is `Protobase`), the sign-in page's title names it, every browser tab of the admin app ends its title with it, the [installed app](installable-app.md) is called by it, the [auth emails](email.md#protobases-own-mail) name the app with it, and the platform's mailer sends them as it (the sender's display name). `PROTOBASE_APP_NAME`, when set, overrides it; without either it is `Protobase`, then without a display name. See [App identity](app-identity.md).
|
|
106
106
|
|
|
107
107
|
### Hooks
|
|
108
108
|
|
|
@@ -117,11 +117,12 @@ await call.publish({ camera: media.getVideoTracks()[0], microphone: media.getAud
|
|
|
117
117
|
|
|
118
118
|
| | |
|
|
119
119
|
| --- | --- |
|
|
120
|
-
| `publish(tracks, { simulcast })` | Sends tracks under their sources; rejects when the member may not. With `simulcast` and LiveKit, video goes in three sizes. |
|
|
120
|
+
| `publish(tracks, { simulcast })` | Sends tracks under their sources; rejects with `PublishRefused`, exported beside `joinCall`, when the member may not. With `simulcast` and LiveKit, video goes in three sizes. |
|
|
121
121
|
| `unpublish(source)` | Stops sending the source; the track itself goes on until the page stops it. |
|
|
122
122
|
| `mute(source, muted)` | Mutes or unmutes a sent track; the others see `muted` on it. |
|
|
123
123
|
| `view(sids, { width, height })` | With LiveKit: how large the page shows remote video, so it receives the size that fits; `'hidden'` receives none. |
|
|
124
124
|
| `restartIce()` | New network paths, such as after a network change; a failed connection restarts by itself. |
|
|
125
|
+
| `connections()` | How each of the member's connections fares now, to each other member peer-to-peer or to the media server with LiveKit: `{ member?, state, roundTripMs?, lostRatio? }`, the round trip and the share of received media lost once connected. |
|
|
125
126
|
| `leave()` | Leaves the call. |
|
|
126
127
|
|
|
127
128
|
`call.id` is the call, as the record's calls list it and `calls.remove` takes it. `onSpeakers` gets who speaks how loudly, and `onUnpublished` a source the media server stopped, such as after the member lost the right to send.
|
|
@@ -130,6 +130,30 @@ Imports `config/index.ts` and the views of `config/*/ui.ts`, as the config modul
|
|
|
130
130
|
|
|
131
131
|
Exit code is 1 when there are errors. Run it after the project's migrations and before a new version serves, as a platform's deploy does: a read it fails is one the API would refuse once the version runs. `protobase build` needs no database, so the indexes are doctor's to check.
|
|
132
132
|
|
|
133
|
+
## `eject`
|
|
134
|
+
|
|
135
|
+
:::caution[Beta]
|
|
136
|
+
A table is taken over from the command line alone, keeps its name and stays in `user_tables`.
|
|
137
|
+
:::
|
|
138
|
+
|
|
139
|
+
```sh
|
|
140
|
+
protobase eject [connection string | --env VAR] <table>... [--config <dir>] [--dir db/migrations]
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Takes [tables users added](user-tables.md) over as code. The URL comes as for [`scaffold`](#scaffold). For each table named, as its definition names it, it writes `config/<table>/data.ts` and `config/<table>/ui.ts` with scaffold's renderers, exports them from `config/index.ts`, and writes one migration in `--dir`, `NNN_eject_<tables>.sql`. It only reads the database.
|
|
144
|
+
|
|
145
|
+
- **`data.ts`** is a resource of the same name over `user_tables.<table>`, where the table stays: the fields of the definition as the server serves them, filterable, sortable and searched where the table's indexes serve them, a text field at most 500 characters, and every operation the admins' alone, with `.explicitGrants()`. A column the definition has no field for is written as `field: null`.
|
|
146
|
+
- **`ui.ts`** has the definition's singular and plural label, its title field, each field's label, a long text field multiline, and the list `scaffold --ui` writes.
|
|
147
|
+
- **The migration** makes each table as the catalogue has it: `create schema if not exists`, `create extension if not exists pg_trgm` when an index needs it, `create table if not exists` with the columns and constraints, and `create index if not exists` for each index, by its name. On the database the table is in it changes nothing and moves no row; on an empty one it makes the same table.
|
|
148
|
+
|
|
149
|
+
Then it runs [`doctor`](#doctor) on the config. When doctor reports an error in an ejected resource, or cannot load the config, every file is put back as it was and the command fails. It refuses a name no definition the server serves has, saying why the server leaves it out, and one a resource in `config/` has, or whose table one reads, already.
|
|
150
|
+
|
|
151
|
+
```sh
|
|
152
|
+
protobase eject notes # config/notes/data.ts, config/notes/ui.ts, db/migrations/NNN_eject_notes.sql
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Deploy the change soon: until it serves, the admin app still adds fields to the table, which the new config does not have. Once it has deployed, [`migration`](#migration) writes the delete of the definition (see [Taking a table over as code](user-tables.md#taking-a-table-over-as-code)).
|
|
156
|
+
|
|
133
157
|
## `dev`
|
|
134
158
|
|
|
135
159
|
```sh
|
|
@@ -151,7 +175,7 @@ One port, two processes: Vite serves the admin app from `@protobase/ui`'s `src/a
|
|
|
151
175
|
- **Tunnels and other hosts:** Vite only answers `localhost` by default. `--allowed-hosts .trycloudflare.com,app.example.com` adds Host headers (a leading dot matches all subdomains). The API's WebSockets, such as an [API function](../guides/api-functions.md#websockets)'s, pass the same check: an upgrade with another Host header is refused with `403`. Hot reload needs no extra setting: the Vite client takes protocol, host and port from the page, so behind an HTTPS tunnel it connects over `wss` on 443 as long as the tunnel forwards WebSockets.
|
|
152
176
|
- **Project discovery**, by convention: `config/index.ts` exports the resources (and views), and every `config/*/ui.ts` export that is not already exported by the index is added as a view. Each `functions/<name>.ts` (or `functions/<name>/index.ts`) is an [API function](../guides/api-functions.md) at `/api/functions/<name>`. `protobase.config.ts` (or `protobase.config.json`) in the project root describes the rest as data, and the jobs, hooks and other code it names are found by their paths; see [protobase.config.ts](config.md). Without it, the app runs on the defaults.
|
|
153
177
|
- **Project UI:** the app imports `protobase.ui.tsx` from the project root, with hot reload, when it exists (see [Custom components](custom-components.md)): the same contract as the plugin `protobase build` makes of it, built into the dev page instead. Layout files are checked as they load, as `protobase build` checks them.
|
|
154
|
-
- **Theme:** the page links `/protobase.theme.css`, which the dev server answers from the project's [theme](theme.md), checked as the build checks it, on every request, so an edit shows on the next reload. It links `/protobase.favicon.svg` too, the project's [favicon](theme.md#logo-and-favicon) or
|
|
178
|
+
- **Theme:** the page links `/protobase.theme.css`, which the dev server answers from the project's [theme](theme.md), checked as the build checks it, on every request, so an edit shows on the next reload. It links `/protobase.favicon.svg` too, the project's [favicon](theme.md#logo-and-favicon) or a neutral mark, and `/protobase.apple-touch-icon.png`, which it answers with the PNG [icons](app-identity.md#the-icons) the project has.
|
|
155
179
|
- **Extensions:** `--extend <dir>` merges an extension in, its UI built into the dev page beside the project's, to try one out; its files reload like the project's.
|
|
156
180
|
- **Hot reload:** server code, data configs, `ui.ts` views, layouts and the config are loaded through Vite's SSR module graph, and the config is read again with each rebuild. A change inside the project (or, in this repository, in `packages/server`, `packages/query`, `packages/schema`) rebuilds the API on the next request without a restart; `/meta` and `X-Meta-Version` change, so open tabs refetch. The database pool survives reloads. Newly added `ui.ts` files appear after the next change inside the project.
|
|
157
181
|
|
|
@@ -189,7 +213,7 @@ dist/
|
|
|
189
213
|
plugins/app/ the project's protobase.ui.tsx, built as a plugin
|
|
190
214
|
plugins/<extension>/ each --extend folder's protobase.ui.tsx, built as a plugin
|
|
191
215
|
protobase.theme.css the project's theme, checked and minified; empty without one
|
|
192
|
-
protobase.favicon.svg the project's favicon;
|
|
216
|
+
protobase.favicon.svg the project's favicon; a neutral mark without one
|
|
193
217
|
protobase.*.png the project's PNG icons, the ones it has (see App identity)
|
|
194
218
|
protobase.bundle.json the manifest
|
|
195
219
|
```
|
|
@@ -419,6 +443,7 @@ Serves the API of a bundle from `protobase build` and owns the process around it
|
|
|
419
443
|
| `PROTOBASE_ASSISTANT_URL`, `PROTOBASE_ASSISTANT_API_KEY`, `PROTOBASE_ASSISTANT_MODEL`, `PROTOBASE_ASSISTANT_BASE_URL` | the [assistant](assistant.md#which-backend): a backend elsewhere, or the built-in one on OpenRouter or another OpenAI-compatible endpoint; without them there is none |
|
|
420
444
|
| `PROTOBASE_ASSISTANT_CHATS` | the folder the built-in assistant keeps its conversations in; default `.protobase/chats` |
|
|
421
445
|
| `PROTOBASE_FIRST_ADMIN_URL` | the hosting platform's page where the app's first user is added, such as its console's; while the app has no users, the sign-in page links there in place of the `protobase users create` command ([the first admin](auth.md#first-run)) |
|
|
446
|
+
| `PROTOBASE_RUNS_ON_NOTE` | `true` ends the sign-in page on the [Runs on Protobase note](app-identity.md#runs-on-protobase-note), for a hosting platform; default `false`, and any value but `true` or `false` stops startup |
|
|
422
447
|
| `PROTOBASE_RUNTIME_URL` | the endpoint behind the top bar's [runtime update button](versioning.md#runtime-updates), such as a hosting platform's; without it there is no button |
|
|
423
448
|
| `PROTOBASE_SENTRY_DSN` | the DSN of a Sentry-compatible error tracker the app's server and admin app send their errors to, after the config's `errorReporting` ([Error reporting](api.md#error-reporting)); without it nothing is sent |
|
|
424
449
|
| `PROTOBASE_MAP_STYLE_URL` | the URL of the MapLibre style the record page's [maps](ui-config.md#maps) are drawn with, after the config's `maps`; without it there are no maps |
|
|
@@ -469,6 +494,7 @@ An Android or iOS app built with `mobile.publicKey` updates itself from the serv
|
|
|
469
494
|
A host that serves bundles without running their config, such as a router in front of many apps or a static host, serves the same release from the files it already serves with `@protobase/server/mobile-release`, which loads none of the rest of the server: `protobase serve` composes its own release with the same exports.
|
|
470
495
|
|
|
471
496
|
- `nativeFingerprint(native, plugins)`: the release's fingerprint, from the manifest's `native` (or that of the shell the host serves) and `mobile.plugins`. It is a contract across versions: a binary one Protobase release built is matched by a host that runs another.
|
|
497
|
+
- `releaseOrder({ builtAt, shell })`: the order a release signs, from the manifest's build time and that of the server's own shell the host serves in its place, `[builtAt, 0]` with the bundle's copy; `undefined` when either names no build time.
|
|
472
498
|
- `withApiOrigin(html, origin)`: the `spa` page naming the API origin, the app's `url`.
|
|
473
499
|
- `composeRelease(files, signing?)`: the release of `{ href, content }` files, every file the host serves the web with the page above in place of `spa`, with its id and, with `signing`, `{ key, binding: { order, appId, fingerprint, shellVersion } }`, `appId` the manifest's `mobile.appId`, the signed checksums and statement.
|
|
474
500
|
- `readSigningKey(env)`: the signing key in `PROTOBASE_MOBILE_SIGNING_KEY` of the env object given, the app's own, never the process's.
|
|
@@ -569,7 +595,7 @@ Applies the project's `db/migrations/*.sql` that the database has not applied ye
|
|
|
569
595
|
protobase migration [--dir db/migrations] [--extend <dir>]
|
|
570
596
|
```
|
|
571
597
|
|
|
572
|
-
Writes what the database still needs for every feature the config enables, as the project's next migrations in `--dir`, one file per feature: the auth schema when sign-in is on (`NNN_auth.sql`, as [`auth migration`](#auth-migration) writes it), the [jobs schema](jobs.md#the-jobs-schema) when the config has jobs, `webhooks` or `notifications`, or a resource has events, history, comments or an [AI field](ai.md) (`NNN_jobs.sql`), the [capture of events](events.md#migrations-and-startup) and the [history table](history.md#the-table) (`NNN_changes.sql`), the columns of [rollup and formula fields](data-config.md#rollups-and-formulas) and the triggers that keep the rollups (`NNN_computed.sql`), the role the [assistant's read-only queries](assistant.md#the-built-in-backend) run as, when it finds the built-in assistant's key (`NNN_query.sql`), the [comments table](comments.md#the-table) (`NNN_comments.sql`), the [webhook subscriptions](webhooks.md#subscriptions-in-the-admin-app) of `webhooks` (`NNN_webhooks.sql`), the [devices and history tables](notifications.md#table-names) of `notifications` (`NNN_notifications.sql`), the `realtime_relay` schema of [`realtime: { processes: 'many' }`](supabase-realtime.md#several-processes) (`NNN_realtime_relay.sql`),
|
|
598
|
+
Writes what the database still needs for every feature the config enables, as the project's next migrations in `--dir`, one file per feature: the auth schema when sign-in is on (`NNN_auth.sql`, as [`auth migration`](#auth-migration) writes it), the [jobs schema](jobs.md#the-jobs-schema) when the config has jobs, `webhooks` or `notifications`, or a resource has events, history, comments or an [AI field](ai.md) (`NNN_jobs.sql`), the [capture of events](events.md#migrations-and-startup) and the [history table](history.md#the-table) (`NNN_changes.sql`), the columns of [rollup and formula fields](data-config.md#rollups-and-formulas) and the triggers that keep the rollups (`NNN_computed.sql`), the role the [assistant's read-only queries](assistant.md#the-built-in-backend) run as, when it finds the built-in assistant's key (`NNN_query.sql`), the [comments table](comments.md#the-table) (`NNN_comments.sql`), the [webhook subscriptions](webhooks.md#subscriptions-in-the-admin-app) of `webhooks` (`NNN_webhooks.sql`), the [devices and history tables](notifications.md#table-names) of `notifications` (`NNN_notifications.sql`), the `realtime_relay` schema of [`realtime: { processes: 'many' }`](supabase-realtime.md#several-processes) (`NNN_realtime_relay.sql`), the `channels` schema of [`realtime: { sqlSends: true }`](channels.md#from-sql) (`NNN_channel_sends.sql`), and, with [`userTables`](user-tables.md#the-switch), the schemas of user tables (`NNN_user_tables.sql`) and the delete of the definitions of tables that resources in code [own now](user-tables.md#taking-a-table-over-as-code) (`NNN_owned_definitions.sql`). It compares the database, with the project's migrations applied, with the config (for the auth schema, it reads the store's record of the migrations it applied), merged with those of `--extend` as `dev` merges them, and writes nothing when the database is current. Apply the files with the project's other migrations. It reads the `.env` nearest the current directory and needs `DATABASE_URL`.
|
|
573
599
|
|
|
574
600
|
```sh
|
|
575
601
|
bun run db:migrate # the database at the project's current schema
|
|
@@ -238,7 +238,7 @@ A component that asks for input looks like the app's own forms when it is built
|
|
|
238
238
|
| `Choices` | One of a few options, each a card in a row of its own with a `description`: `name`, `label`, `options`, `value`, `onChange`. A `disabled` option shows and cannot be chosen. |
|
|
239
239
|
| `RecordPicker` | Records of another resource, picked as the create form picks a relation: type to narrow them by their search fields or title, scroll for more, or use the keyboard. `resource` names the resource, whose model it reads from the app; `value` is the chosen record's key as text and `onChange` gets the new one. With `multiple`, `value` and `onChange` hold several keys, such as the objectives a task serves through a join resource: each shows as a token that can be taken off. |
|
|
240
240
|
| `Segmented`, `MenuSelect` | A switch between a few short options in a row (`size`, `iconOnly`), and the same options in a menu. |
|
|
241
|
-
| `Button`, `buttonClass` | A button (`variant` `primary`, `secondary`, `ghost` or `danger`, `size`, `loading`), and its classes for a link that looks like one. |
|
|
241
|
+
| `Button`, `buttonClass` | A button (`variant` `primary`, `secondary`, `ghost` or `danger`, `size` `sm`, `md` or `icon`, a square for an icon alone, and `loading`), and its classes for a link that looks like one. |
|
|
242
242
|
| `Dialog`, `ResponsiveDialog` | A modal with a `title`, `description`, `actions` and a body, which Escape and a click outside close with `onClose`; the responsive one becomes a sheet from the bottom of a phone, which a swipe down on its header closes too. |
|
|
243
243
|
|
|
244
244
|
```tsx
|
|
@@ -206,7 +206,7 @@ export const PushButton = () => {
|
|
|
206
206
|
- Call `turnOn` straight from a click. The browser asks for permission then, and only then: without a click nothing happens, and push is never turned on when a page loads. Once the user said no, `turnOn` does not ask again and answers `blocked`. `state` is read again on a return to the page and when the browser says the permission changed, so it turns from `blocked` to `off` once the user allows notifications.
|
|
207
207
|
- Only someone signed in as themselves can turn push on: a visitor's click and staff signed in as the user do nothing.
|
|
208
208
|
- On iPhone and iPad, push works in the app added to the Home Screen; a browser tab answers `unsupported`.
|
|
209
|
-
- Signing out turns push off in this browser, and so does someone else signing in on it: what the browser showed is closed, the app's badge cleared, and the device removed. Signing in again as the same user keeps it on.
|
|
209
|
+
- Signing out turns push off in this browser, and so does someone else signing in on it: what the browser showed is closed, the app's badge cleared, and the device removed. Signing in again as the same user keeps it on. Each push names the user its device is registered for, and the browser shows nothing of a push for another user than the one it holds push for.
|
|
210
210
|
- A tap on a notification whose url is on your app's origin opens that page in the app, in an open window of it or a new one; any other url opens in a new tab.
|
|
211
211
|
|
|
212
212
|
`protobase serve` and `protobase dev` both serve the service worker that shows notifications, `protobase dev` only when the app has `notifications`.
|
|
@@ -123,9 +123,9 @@ The list is `packages/ui/src/tokens.css`, and a test fails when one of its token
|
|
|
123
123
|
|
|
124
124
|
## Logo and favicon
|
|
125
125
|
|
|
126
|
-
The sidebar's logo is the project's own favicon, or Protobase's mark
|
|
126
|
+
The sidebar's logo is the project's own favicon, or without one the app's initial when it has a name of its own and Protobase's mark when it keeps Protobase's, beside the app's [name](app-identity.md#the-name), with the organization the user works in under it, and the icon alone when the sidebar shows only icons. An app that keeps Protobase's name and has no favicon of its own shows Protobase's wordmark beside the mark. A project draws something else with `shell.logo` in `protobase.ui.tsx`, a component drawn in its place ([Shell slots](custom-components.md#shell-slots)); it draws with these tokens like the rest of the app.
|
|
127
127
|
|
|
128
|
-
The browser tab shows
|
|
128
|
+
The browser tab shows a neutral mark. A project shows its own with a `protobase.favicon.svg` at its root, beside `protobase.theme.css`: the app's `index.html` links `/protobase.favicon.svg`, which `protobase build` writes to the bundle's `public/`, the project's file or the neutral mark without one, and which `protobase dev` serves the same way. An SVG can follow the system's light or dark with a `prefers-color-scheme` media query inside it, as the neutral mark does. PNGs beside it serve the platforms that take no SVG, such as an iPhone's home screen ([App identity](app-identity.md#the-icons)).
|
|
129
129
|
|
|
130
130
|
## In a plugin
|
|
131
131
|
|
|
@@ -667,7 +667,7 @@ export const repairsView = filtered
|
|
|
667
667
|
|
|
668
668
|
A custom component draws only its part: the page around it stays the record page, at `/<resource>/<key>`, with its breadcrumbs, Save, the action rail with Edit and Delete, and the related records, and a layout may hold only custom components. The repairs above list the customer's earlier repairs under the bike:
|
|
669
669
|
|
|
670
|
-
Without a layout the record page shows one section with every field the user may change; its [read view](#read-view-and-edit), which is what a user who may change none gets, shows every field. A section or sidebar card with no field the user may see is left out. The record's title is the page's heading, above the sections, with an enum `status` as a badge beside it unless the sidebar shows it already. `.saveFeedback('button')` shows saving on the Save button instead of a toast (`'toast'`, the default). A [sensitive field](data-config.md#sensitive-fields) in a section is masked, with an eye button that reveals it. A [write-only field](access.md#write-only-fields) is on the form only where a section names it, never in the section without a layout, and shows as an empty input to set a new value in.
|
|
670
|
+
Without a layout the record page shows one section with every field the user may change; its [read view](#read-view-and-edit), which is what a user who may change none gets, shows every field. A section or sidebar card with no field the user may see is left out. On a wide screen the sidebar cards stay in view beside the main column as the page scrolls, below the top bar. The record's title is the page's heading, above the sections, with an enum `status` as a badge beside it unless the sidebar shows it already. `.saveFeedback('button')` shows saving on the Save button instead of a toast (`'toast'`, the default). A [sensitive field](data-config.md#sensitive-fields) in a section is masked, with an eye button that reveals it. A [write-only field](access.md#write-only-fields) is on the form only where a section names it, never in the section without a layout, and shows as an empty input to set a new value in.
|
|
671
671
|
|
|
672
672
|
A record opened from a list, by its row or its card, shows the fields the list loaded at once, with placeholders for the others, and takes input once the whole record has loaded; actions, custom components, maps and galleries wait for it. What the page shows is read again whenever the browser tab or window regains focus, as are the lists, unless the record has unsaved changes, which keep the version they were made to.
|
|
673
673
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
# User tables
|
|
4
4
|
|
|
5
|
-
The switch that keeps tables users add in the app's own database, the two schemas they live in, the migration that adds them,
|
|
5
|
+
The switch that keeps tables users add in the app's own database, the two schemas they live in, the migration that adds them, how each one is served, and how a developer takes one over as code.
|
|
6
6
|
|
|
7
7
|
An app's resources usually come from its code: a resource in `config/` over a table a migration made. With user tables on, the app also keeps tables its users add in its own database, and serves each one beside the resources in code.
|
|
8
8
|
|
|
@@ -61,7 +61,7 @@ When the server starts, and whenever the definitions change, it builds a resourc
|
|
|
61
61
|
- **A definition that does not fit is left out.** A definition with a name or type outside the rules above, or whose table or columns do not match it, is not served, and the server prints why at start. The rest of the app serves.
|
|
62
62
|
- **Code wins.** A resource in code with the name of a user table, or a resource Protobase adds, is served instead of the definition, which is then ignored with a warning. This is how a table users added is taken over as code.
|
|
63
63
|
|
|
64
|
-
[`protobase doctor`](cli.md#doctor) checks each definition against the database as it checks the resources in code,
|
|
64
|
+
[`protobase doctor`](cli.md#doctor) checks each definition against the database as it checks the resources in code, reports each one it leaves out as an error, and checks a table a resource in code has taken over as that resource. [`protobase scaffold`](cli.md#scaffold) leaves out the definitions and the tables in `user_tables` that no resource in code claims, and writes and updates the config of one that a resource claims.
|
|
65
65
|
|
|
66
66
|
### How a change reaches every process
|
|
67
67
|
|
|
@@ -120,3 +120,22 @@ The answer has the table's record, so a client links to the new table at `/notes
|
|
|
120
120
|
- **The rest of the app.** The process that made the change serves it before it answers, and every other process [within a second](#how-a-change-reaches-every-process). While a deploy freezes writes, a create answers as any write does then.
|
|
121
121
|
|
|
122
122
|
Building an index holds the table's write lock, and every statement runs within the request's statement timeout (1 s by default). <gap>Adding a field to a table with very many records cannot finish when building its index takes longer than the statement timeout, because building it outside the request is not implemented; the field is then not added.</gap>
|
|
123
|
+
|
|
124
|
+
## Taking a table over as code
|
|
125
|
+
|
|
126
|
+
:::caution[Beta]
|
|
127
|
+
A table is taken over from the command line alone, keeps its name and stays in `user_tables`.
|
|
128
|
+
:::
|
|
129
|
+
|
|
130
|
+
`protobase eject` turns a table someone added in the admin app into ordinary config and a migration in the project, so a developer can customise it like any other resource. Its records stay where they are.
|
|
131
|
+
|
|
132
|
+
When a table needs more than the admin app gives it, such as a validation rule, a view per role or an action, [`protobase eject <table>`](cli.md#eject) writes its `config/<table>/data.ts` and `ui.ts`, as the server serves it from its definition, and a migration that makes the table as it is. The resource reads `user_tables.<table>`, where the table already is, so the deploy that brings the code moves no row: the migration changes nothing on that database and makes the same table on an empty one, such as a developer's. From then on it is a resource like any other, and the config is yours to change.
|
|
133
|
+
|
|
134
|
+
1. **Get the project and its database.** For an app you host yourself, work in its repository with its database URL in `DATABASE_URL`, or pass the URL as the first argument. For an app on Protobase Cloud, clone the repository the app deploys from and use the app's database URL.
|
|
135
|
+
2. **Eject.** Run `protobase eject <table>`, or name several tables. It runs [`doctor`](cli.md#doctor) on what it wrote and puts every file back when doctor fails.
|
|
136
|
+
3. **Deploy soon.** Commit the files and deploy as usual: for an app on Protobase Cloud, push to the branch it follows (see [Protobase Cloud](../guides/deploy.md#protobase-cloud)). Until the new version serves, the admin app still adds fields to the table, which the new config does not have: add such a field with [`protobase scaffold user_tables.<table>`](cli.md#scaffold) and a migration of its own that adds the column.
|
|
137
|
+
4. **Delete the definition in a later deploy.** Once the code has deployed, run [`protobase migration`](cli.md#migration): it writes the delete of each definition whose table a resource in code reads under the same name (`NNN_owned_definitions.sql`), which goes out with the next deploy. Its table and rows stay. Until then the server ignores the definition, as [code wins](#how-each-one-is-served), and doctor warns about it. <gap>`protobase migration` cannot hold the delete back when it runs before the eject has deployed because telling which version serves is not implemented; the old version then stops serving the table for the moment before the new one serves.</gap>
|
|
138
|
+
|
|
139
|
+
Once the code serves, the table takes no new field from the admin app, and a new table cannot take its name. The resource starts as the admins' alone, as it was served; widen its access in `data.ts` as for any resource.
|
|
140
|
+
|
|
141
|
+
<gap>A table cannot be taken over from the admin app or the assistant because an eject that writes to the project's repository is not implemented.</gap>
|
|
@@ -45,7 +45,7 @@ Every commit message follows [Conventional Commits](https://www.conventionalcomm
|
|
|
45
45
|
`.github/workflows/release.yml` runs on every push to `main`:
|
|
46
46
|
|
|
47
47
|
1. The checks of `ci.yml` (`bun run check`, the smoke test on Node, the docs build, the Storybook play tests and the docs check), of the tests and steps the changes since the last `v<version>` tag affect (see What a push checks in `CONTRIBUTING.md`), and commitlint over the pushed commits. The Postgres suite (`bun run test:postgres`, on Postgres and PgBouncer) runs in full as well, whatever changed, beside the other checks. They start at once, and the release waits for them all.
|
|
48
|
-
2. `bun run release` (`scripts/release.mjs`, with [
|
|
48
|
+
2. `bun run release` (`scripts/release.mjs`, with [conventional-changelog](https://github.com/conventional-changelog/conventional-changelog)) computes the next version and its notes from the commits since the last `v<version>` tag, as above, and tags the pushed commit `v<version>`. A squash-merged pull request counts with each commit its body lists as a `* ` item, so one titled `chore` that carries a fix or feature is released; an item an older commit since the tag lists already counts once. It commits nothing and changes no file. Without a fix, feature or breaking change it tags nothing.
|
|
49
49
|
3. The tag is pushed, and the GitHub release of the tag gets the notes.
|
|
50
50
|
4. Beside the checks, a pack job takes the tag `bun run release --dry-run` predicts, and `scripts/stamp-version.mjs` writes its version into every `package.json` and `bun.lock` in the runner, never committed. `scripts/publish.mjs --pack --out` then builds every package to `dist` and packs it with Bun (the private `@protobase/presets` is not published). Once the tag is pushed, npm, under Node, publishes those tarballs with [trusted publishing](https://docs.npmjs.com/trusted-publishers) and provenance (`scripts/publish.mjs --from`); there is no npm token, and only the publish job holds the identity token it needs. A tag other than the version the tarballs were stamped with fails the publish before anything is uploaded, and a run that cut no release packs and publishes nothing.
|
|
51
51
|
5. Once npm serves the packages, the tag's version is written into the `package.json` files the same way, and `bun run images:context <image>` writes each image's build context at the tag from them, and the images are built natively for `linux/amd64` and `linux/arm64` and pushed to GitHub's registry as `ghcr.io/prototeamhq/protobase:<version>` and `ghcr.io/prototeamhq/protobase-dev:<version>` (see [Deploy](../guides/deploy.md#images)). A release whose publish failed has no images.
|