@protobase/cli 0.66.16 → 0.67.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/package.js +1 -1
- package/dist/config/build-project.js +2 -0
- package/dist/config/source-config.js +3 -2
- package/dist/config/user-tables-config.js +15 -0
- package/dist/dev/entry.js +4 -2
- package/dist/doctor/run.js +17 -5
- package/dist/doctor/user-table-models.js +26 -0
- package/dist/migration/load-project.js +2 -0
- package/dist/migration/parts.js +6 -1
- package/dist/project/project-admin.js +4 -2
- package/dist/project/project-user-tables.js +15 -0
- package/dist/scaffold/plan.js +1 -1
- package/dist/scaffold/protobase-tables.d.ts +1 -1
- package/dist/scaffold/protobase-tables.js +17 -1
- package/dist/serve/lifecycle.js +4 -2
- package/dist/serve/load-bundle.js +2 -1
- package/package.json +7 -7
- package/skills/protobase/SKILL.md +1 -0
- package/skills/protobase/references/features/user-tables.md +7 -0
- package/skills/protobase/references/features.md +1 -0
- package/skills/protobase/references/reference/auth.md +1 -1
- package/skills/protobase/references/reference/cli.md +1 -1
- package/skills/protobase/references/reference/config/api.md +11 -0
- package/skills/protobase/references/reference/config.md +1 -1
- package/skills/protobase/references/reference/email.md +4 -2
- package/skills/protobase/references/reference/user-tables.md +66 -0
package/dist/cli/package.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { projectFiles, projectFunctions, projectJobs, projectNotifications, projectOptions, projectResources, projectWebhooks } from "./project-parts.js";
|
|
2
|
+
import { userTablesOn } from "./user-tables-config.js";
|
|
2
3
|
import { buildAppAuth, buildAuthenticator, checkCallsOptions, checkStatusOptions } from "@protobase/server/internal";
|
|
3
4
|
//#region src/config/build-project.ts
|
|
4
5
|
/**
|
|
@@ -76,6 +77,7 @@ const buildProject = async (config, database) => {
|
|
|
76
77
|
...config.mobile && { mobile: config.mobile },
|
|
77
78
|
...config.secret && { secret: config.secret },
|
|
78
79
|
...identity && { app: identity },
|
|
80
|
+
...userTablesOn(config) && { userTables: true },
|
|
79
81
|
options
|
|
80
82
|
};
|
|
81
83
|
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { mergeConfig } from "./merge-config.js";
|
|
2
|
+
import { checkUserTables } from "./user-tables-config.js";
|
|
2
3
|
import { importProjectFiles } from "../project/import-project-file.js";
|
|
3
4
|
import { withDefaults } from "./config-defaults.js";
|
|
4
5
|
import { sourceFiles } from "./project-files.js";
|
|
@@ -27,11 +28,11 @@ const loadSourceConfig = async (dir, { command, extensions = [], importModules =
|
|
|
27
28
|
}
|
|
28
29
|
const file = configFileOf(dir);
|
|
29
30
|
const own = file ? await readConfigFile(file, configEnv, importModules) : {};
|
|
30
|
-
return withDefaults(mergeConfig(...extended, {
|
|
31
|
+
return checkUserTables(withDefaults(mergeConfig(...extended, {
|
|
31
32
|
config: own,
|
|
32
33
|
files: sourceFiles(dir, importModules),
|
|
33
34
|
label: file ? path.basename(file) : "config/index.ts"
|
|
34
|
-
}), command, process.env);
|
|
35
|
+
}), command, process.env));
|
|
35
36
|
};
|
|
36
37
|
//#endregion
|
|
37
38
|
export { loadSourceConfig };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
//#region src/config/user-tables-config.ts
|
|
2
|
+
/** Whether the app keeps tables its users add: the one reading of `userTables`, after `checkUserTables`. */
|
|
3
|
+
const userTablesOn = (config) => config.userTables === true;
|
|
4
|
+
/**
|
|
5
|
+
* Refuses a `userTables` that is no boolean, and user tables together with organizations, whose definitions and records
|
|
6
|
+
* are not kept per organization. Organizations are on for any truthy `auth.organizations`, as sign-in reads it.
|
|
7
|
+
*/
|
|
8
|
+
const checkUserTables = (config) => {
|
|
9
|
+
const value = config.userTables;
|
|
10
|
+
if (value !== void 0 && typeof value !== "boolean") throw new Error(`userTables is ${JSON.stringify(value)}; it takes true or false`);
|
|
11
|
+
if (userTablesOn(config) && config.auth && config.auth.organizations) throw new Error("userTables and auth.organizations are both on; tables users add are not kept per organization yet, so turn one of them off");
|
|
12
|
+
return config;
|
|
13
|
+
};
|
|
14
|
+
//#endregion
|
|
15
|
+
export { checkUserTables, userTablesOn };
|
package/dist/dev/entry.js
CHANGED
|
@@ -2,12 +2,13 @@ import { buildProject } from "../config/build-project.js";
|
|
|
2
2
|
import { projectIcons } from "../build/app-icons.js";
|
|
3
3
|
import { projectErrorReporter } from "../project/error-reporter.js";
|
|
4
4
|
import { projectAdmin } from "../project/project-admin.js";
|
|
5
|
+
import { projectUserTables } from "../project/project-user-tables.js";
|
|
5
6
|
import { devJobs } from "./dev-jobs.js";
|
|
6
7
|
import { devRealtimePrune } from "./dev-prune.js";
|
|
7
8
|
import { devDatabase, loadDevConfig } from "./project.js";
|
|
8
9
|
import { withDevServiceWorker } from "./service-worker-route.js";
|
|
9
10
|
import { setMissing, supabaseEnv } from "./supabase-env.js";
|
|
10
|
-
import { apiKeySettings, assistantSettings, authSchemaDrift, authStoreTables, checkAi, checkAuthSchema, checkCalls, checkChanges, checkChannelSends, checkComments, checkComputed, checkNotificationsSchema, checkQueryRunner, checkRealtimeRelay, checkResourcesOutsideAuthStore, checkStatusSchema, checkWebhooksSchema, configExports, hasUsers, relayed, sqlSends } from "@protobase/server";
|
|
11
|
+
import { apiKeySettings, assistantSettings, authSchemaDrift, authStoreTables, checkAi, checkAuthSchema, checkCalls, checkChanges, checkChannelSends, checkComments, checkComputed, checkNotificationsSchema, checkQueryRunner, checkRealtimeRelay, checkResourcesOutsideAuthStore, checkStatusSchema, checkUserTablesSchema, checkWebhooksSchema, configExports, hasUsers, relayed, sqlSends } from "@protobase/server";
|
|
11
12
|
import { upgradeWebSocket } from "@hono/node-server";
|
|
12
13
|
import { Hono } from "hono";
|
|
13
14
|
//#region src/dev/entry.ts
|
|
@@ -42,6 +43,7 @@ if (relayed(project.realtime)) await checkRealtimeRelay(db);
|
|
|
42
43
|
if (sqlSends(project.realtime)) await checkChannelSends(db);
|
|
43
44
|
if (project.notifications) await checkNotificationsSchema(db, project.notifications, project.auth);
|
|
44
45
|
if (project.status) await checkStatusSchema(db, project.status, project.auth);
|
|
46
|
+
if (project.userTables) await checkUserTablesSchema(db);
|
|
45
47
|
setMissing(process.env, supabaseEnv({
|
|
46
48
|
url: process.env.PROTOBASE_DEV_URL,
|
|
47
49
|
databaseUrl: process.env.PROTOBASE_DATABASE_URL,
|
|
@@ -54,7 +56,7 @@ const ui = {
|
|
|
54
56
|
fieldTypes: {},
|
|
55
57
|
icons: projectIcons(projectDir)
|
|
56
58
|
};
|
|
57
|
-
const { admin, jobs } = projectAdmin(project, db, ui, reporter, upgradeWebSocket);
|
|
59
|
+
const { admin, jobs } = projectAdmin(project, db, ui, reporter, upgradeWebSocket, await projectUserTables(project, db));
|
|
58
60
|
if (process.env.PROTOBASE_DEV_JOBS !== "false") await devJobs(db, jobs, admin.decryptField, project.mailer);
|
|
59
61
|
devRealtimePrune(admin);
|
|
60
62
|
var entry_default = withDevServiceWorker(new Hono(), Boolean(project.notifications)).route("/", admin);
|
package/dist/doctor/run.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { userTablesOn } from "../config/user-tables-config.js";
|
|
1
2
|
import { loadSourceConfig } from "../config/source-config.js";
|
|
2
3
|
import { connect } from "../db/connect.js";
|
|
3
4
|
import { introspect } from "../db/introspect.js";
|
|
@@ -9,6 +10,7 @@ import { indexIssues } from "./index-issues.js";
|
|
|
9
10
|
import { readShapes } from "./read-shapes.js";
|
|
10
11
|
import { relationIssues } from "./relation-issues.js";
|
|
11
12
|
import { sortIssues } from "./sort-issues.js";
|
|
13
|
+
import { userTableModels } from "./user-table-models.js";
|
|
12
14
|
import { existsSync } from "node:fs";
|
|
13
15
|
import path from "node:path";
|
|
14
16
|
import { tableIndexes } from "@protobase/query";
|
|
@@ -18,26 +20,36 @@ const runDoctor = async (options, out) => {
|
|
|
18
20
|
if (!existsSync(path.join(options.configDir, "index.ts"))) throw new Error(`No config in ${options.configDir}: write it with \`protobase scaffold\`, or pass --config <dir>`);
|
|
19
21
|
const config = await loadDataConfig(options.configDir, options.projectDir);
|
|
20
22
|
if (config.models.length === 0) throw new Error(`No resources exported from ${options.configDir}/index.ts`);
|
|
21
|
-
const
|
|
22
|
-
const
|
|
23
|
+
const source = await loadSourceConfig(options.projectDir, { command: "tool" });
|
|
24
|
+
const builtIn = builtInModels(source, config.models);
|
|
23
25
|
const sql = connect(options.url);
|
|
24
26
|
const tables = await introspect(sql, { views: true }).finally(() => sql.end());
|
|
25
27
|
const db = readOnlyDb(options.url);
|
|
28
|
+
const userTables = userTablesOn(source) ? await userTableModels(db, [...builtIn.models, ...config.models]) : {
|
|
29
|
+
models: [],
|
|
30
|
+
issues: []
|
|
31
|
+
};
|
|
32
|
+
const models = [
|
|
33
|
+
...builtIn.models,
|
|
34
|
+
...config.models,
|
|
35
|
+
...userTables.models
|
|
36
|
+
];
|
|
26
37
|
const shapes = readShapes({
|
|
27
38
|
models,
|
|
28
39
|
views: [...builtIn.views, ...config.views],
|
|
29
40
|
pages: config.pages
|
|
30
41
|
});
|
|
31
42
|
const indexes = await Promise.all(models.map(async (model) => indexIssues(model, await tableIndexes(db, model), shapes))).then((found) => found.map((issues, i) => i < builtIn.models.length ? builtInIndexIssues(issues) : issues)).finally(() => db.destroy());
|
|
32
|
-
const issues = models.flatMap((model, i) => [
|
|
43
|
+
const issues = [...userTables.issues, ...models.flatMap((model, i) => [
|
|
33
44
|
...checkResource(model, tables),
|
|
34
45
|
...relationIssues(model, models),
|
|
35
46
|
...sortIssues(model, shapes),
|
|
36
47
|
...indexes[i]
|
|
37
|
-
]);
|
|
48
|
+
])];
|
|
38
49
|
for (const issue of issues) out(format(issue));
|
|
39
50
|
const errors = issues.filter((i) => i.severity === "error").length;
|
|
40
|
-
|
|
51
|
+
const counted = userTables.models.length > 0 ? `${config.models.length} resources, ${userTables.models.length} user tables` : `${config.models.length} resources`;
|
|
52
|
+
out(`Checked ${builtIn.models.length > 0 ? `${counted} and ${builtIn.models.length} built-in ones` : counted}: ${errors} errors, ${issues.length - errors} warnings.\n`);
|
|
41
53
|
return {
|
|
42
54
|
errors,
|
|
43
55
|
warnings: issues.length - errors
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { userTableResources } from "@protobase/server/config-exports";
|
|
2
|
+
//#region src/doctor/user-table-models.ts
|
|
3
|
+
/**
|
|
4
|
+
* The models of the tables users add, which doctor checks like the config's, and an error for each definition that
|
|
5
|
+
* does not fit the database or whose name a resource in code has, as the server leaves those out.
|
|
6
|
+
*/
|
|
7
|
+
const userTableModels = async (db, codeModels) => {
|
|
8
|
+
const tables = await userTableResources(db);
|
|
9
|
+
const taken = new Set(codeModels.map((model) => model.name));
|
|
10
|
+
const built = tables.resources.map((source) => source.toModel());
|
|
11
|
+
const issues = [...tables.problems.map((message) => ({
|
|
12
|
+
severity: "error",
|
|
13
|
+
resource: "user tables",
|
|
14
|
+
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
|
+
}))];
|
|
20
|
+
return {
|
|
21
|
+
models: built.filter((model) => !taken.has(model.name)),
|
|
22
|
+
issues
|
|
23
|
+
};
|
|
24
|
+
};
|
|
25
|
+
//#endregion
|
|
26
|
+
export { userTableModels };
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { loadNearestEnvFile } from "../dev/env.js";
|
|
2
2
|
import { projectJobs, projectNotifications, projectResources, projectWebhooks } from "../config/project-parts.js";
|
|
3
|
+
import { userTablesOn } from "../config/user-tables-config.js";
|
|
3
4
|
import { projectAuth } from "../config/build-project.js";
|
|
4
5
|
import { loadSourceConfig } from "../config/source-config.js";
|
|
5
6
|
import { openAppDatabase } from "../project/pg-db.js";
|
|
@@ -44,6 +45,7 @@ const migrationProject = async (config, url) => {
|
|
|
44
45
|
...notifications && { notifications },
|
|
45
46
|
...config.status && { status: checkStatusOptions(config.status) },
|
|
46
47
|
jobs,
|
|
48
|
+
userTables: userTablesOn(config),
|
|
47
49
|
readOnlyQueries: assistantSettings(config.assistant, process.env)?.kind === "built-in",
|
|
48
50
|
authTables: auth ? authStoreTables(auth) : {
|
|
49
51
|
tables: [],
|
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, webhooksMigration } from "@protobase/server";
|
|
2
|
+
import { authSchemaMigration, callsMigration, changesMigration, channelSendsMigration, commentsMigration, computedMigration, isCaptured, jobsSchemaMigration, notificationsMigration, 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) => {
|
|
@@ -61,6 +61,11 @@ const migrationParts = (project) => {
|
|
|
61
61
|
title: "The relay of Realtime broadcasts and presence between the processes serving the app",
|
|
62
62
|
sql: () => realtimeRelayMigration(project.db)
|
|
63
63
|
}] : [],
|
|
64
|
+
...project.userTables ? [{
|
|
65
|
+
name: "user_tables",
|
|
66
|
+
title: "The schemas of the tables users add and of their definitions, and pg_trgm",
|
|
67
|
+
sql: () => userTablesMigration(project.db)
|
|
68
|
+
}] : [],
|
|
64
69
|
...sqlSends(project.realtime) ? [{
|
|
65
70
|
name: "channel_sends",
|
|
66
71
|
title: "SQL's broadcasts to the record channels, channels.send()",
|
|
@@ -6,14 +6,16 @@ import { configExports, createAdmin } from "@protobase/server";
|
|
|
6
6
|
* types and which icons are the project's own (`protobase dev` passes the project's icons alone). `jobs` are the
|
|
7
7
|
* project's own and the delivery of its events and history. With a `reporter`, every error the API turns into a 500,
|
|
8
8
|
* and every stopped query, goes to the error tracker too, besides the project's `onUnhandledError` or the console. With
|
|
9
|
-
* the host's `upgradeWebSocket`, it serves `/api/sb/realtime/v1` too.
|
|
9
|
+
* the host's `upgradeWebSocket`, it serves `/api/sb/realtime/v1` too. With `userTables`, the tables users add serve
|
|
10
|
+
* beside the config's resources (see `projectUserTables`).
|
|
10
11
|
*/
|
|
11
|
-
const projectAdmin = (project, db, ui, reporter, upgradeWebSocket) => {
|
|
12
|
+
const projectAdmin = (project, db, ui, reporter, upgradeWebSocket, userTables) => {
|
|
12
13
|
const { resources, views, pages, userMenu, bottomBars, subscriptions } = configExports(project.config);
|
|
13
14
|
const unhandled = project.options?.onUnhandledError ?? ((error) => console.error(error));
|
|
14
15
|
const admin = createAdmin({
|
|
15
16
|
resources,
|
|
16
17
|
views,
|
|
18
|
+
...userTables && { userTables },
|
|
17
19
|
pages,
|
|
18
20
|
...userMenu && { userMenu },
|
|
19
21
|
bottomBars,
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { userTableResources } from "@protobase/server";
|
|
2
|
+
//#region src/project/project-user-tables.ts
|
|
3
|
+
/**
|
|
4
|
+
* The tables users add, read from their definitions when the project turns `userTables` on, nothing otherwise, even
|
|
5
|
+
* when the definitions are in the database. Each definition that does not fit the database is printed as a warning and
|
|
6
|
+
* left out, so the rest of the app serves.
|
|
7
|
+
*/
|
|
8
|
+
const projectUserTables = async (project, db) => {
|
|
9
|
+
if (!project.userTables) return;
|
|
10
|
+
const tables = await userTableResources(db);
|
|
11
|
+
for (const problem of tables.problems) console.warn(problem);
|
|
12
|
+
return tables;
|
|
13
|
+
};
|
|
14
|
+
//#endregion
|
|
15
|
+
export { projectUserTables };
|
package/dist/scaffold/plan.js
CHANGED
|
@@ -45,7 +45,7 @@ const planScaffold = async (input) => {
|
|
|
45
45
|
const byTable = new Map(existing.map((resource) => [resource.table, resource]));
|
|
46
46
|
const wanted = (table) => matchesTarget(input.target, table) && !isExcluded(input.excludes, table);
|
|
47
47
|
const tables = input.tables.filter((table) => table.kind === "table");
|
|
48
|
-
const protobase = protobaseSchemas(tables, input.target);
|
|
48
|
+
const protobase = protobaseSchemas(tables, input.target, new Set(byTable.keys()));
|
|
49
49
|
const skipped = protobase.map((found) => ({
|
|
50
50
|
...found,
|
|
51
51
|
tables: found.tables.filter(wanted)
|
|
@@ -2,7 +2,7 @@ import { DbTable } from "../db/model.js";
|
|
|
2
2
|
//#region src/scaffold/protobase-tables.d.ts
|
|
3
3
|
export type ProtobaseSchema = {
|
|
4
4
|
schema: string;
|
|
5
|
-
what: 'auth store' | 'job queue' | 'record of migrations';
|
|
5
|
+
what: 'auth store' | 'job queue' | 'record of migrations' | 'table definitions' | 'user tables';
|
|
6
6
|
tables: DbTable[];
|
|
7
7
|
};
|
|
8
8
|
//#endregion
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { tableKey } from "../db/model.js";
|
|
1
2
|
import { migrationsTableName, storeTableNames } from "@protobase/server/auth-store";
|
|
3
|
+
import { definitionsSchema, userTablesSchema } from "@protobase/server/config-exports";
|
|
2
4
|
//#region src/scaffold/protobase-tables.ts
|
|
3
5
|
const storeCore = [
|
|
4
6
|
"session",
|
|
@@ -21,14 +23,28 @@ const authStore = (schema, tables) => {
|
|
|
21
23
|
};
|
|
22
24
|
const jobQueue = (schema, tables) => schema === "jobs" && ["job", "version"].every((name) => tables.some((table) => table.name === name)) ? tables : [];
|
|
23
25
|
const migrationRecord = (schema, tables) => schema === "migrations" && tables.some((table) => table.name === "applied") ? tables : [];
|
|
24
|
-
const
|
|
26
|
+
const tableDefinitions = (schema, tables) => schema === definitionsSchema && ["tables", "fields"].every((name) => tables.some((table) => table.name === name)) ? tables : [];
|
|
27
|
+
const userTables = (schema, tables, claimed) => schema === userTablesSchema ? tables.filter((table) => !claimed.has(tableKey(table))) : [];
|
|
28
|
+
const protobaseSchemas = (tables, target, claimed = /* @__PURE__ */ new Set()) => [...bySchema(tables)].flatMap(([schema, own]) => {
|
|
25
29
|
const store = authStore(schema, own);
|
|
26
30
|
if (store.length > 0) return [{
|
|
27
31
|
schema,
|
|
28
32
|
what: "auth store",
|
|
29
33
|
tables: store
|
|
30
34
|
}];
|
|
35
|
+
const added = userTables(schema, own, claimed);
|
|
36
|
+
if (added.length > 0) return [{
|
|
37
|
+
schema,
|
|
38
|
+
what: "user tables",
|
|
39
|
+
tables: added
|
|
40
|
+
}];
|
|
31
41
|
if (target.schema === schema) return [];
|
|
42
|
+
const definitions = tableDefinitions(schema, own);
|
|
43
|
+
if (definitions.length > 0) return [{
|
|
44
|
+
schema,
|
|
45
|
+
what: "table definitions",
|
|
46
|
+
tables: definitions
|
|
47
|
+
}];
|
|
32
48
|
const queue = jobQueue(schema, own);
|
|
33
49
|
if (queue.length > 0) return [{
|
|
34
50
|
schema,
|
package/dist/serve/lifecycle.js
CHANGED
|
@@ -2,6 +2,7 @@ import { protobaseVersion } from "../version/version.js";
|
|
|
2
2
|
import { buildProject } from "../config/build-project.js";
|
|
3
3
|
import { openAppDatabase } from "../project/pg-db.js";
|
|
4
4
|
import { projectAdmin } from "../project/project-admin.js";
|
|
5
|
+
import { projectUserTables } from "../project/project-user-tables.js";
|
|
5
6
|
import { apiWebSocketServer } from "../project/api-websocket-server.js";
|
|
6
7
|
import { withClientAddress } from "./client-address.js";
|
|
7
8
|
import { createServeApp } from "./app.js";
|
|
@@ -9,7 +10,7 @@ import { startCleanupTimer } from "./cleanup-timer.js";
|
|
|
9
10
|
import { closeSockets } from "./close-sockets.js";
|
|
10
11
|
import { servedRelease } from "./mobile-release.js";
|
|
11
12
|
import pg from "pg";
|
|
12
|
-
import { assistantSettings, authStoreTables, backgroundSettled, checkAi, checkAuthSchema, checkCalls, checkChanges, checkChannelSends, checkComments, checkComputed, checkJobsSchema, checkNotificationsSchema, checkQueryRunner, checkRealtimeRelay, checkResourcesOutsideAuthStore, checkStatusSchema, checkWebhooksSchema, configExports, createFileCleanup, relayed, sqlSends, startJobs } from "@protobase/server";
|
|
13
|
+
import { assistantSettings, authStoreTables, backgroundSettled, checkAi, checkAuthSchema, checkCalls, checkChanges, checkChannelSends, checkComments, checkComputed, checkJobsSchema, checkNotificationsSchema, checkQueryRunner, checkRealtimeRelay, checkResourcesOutsideAuthStore, checkStatusSchema, checkUserTablesSchema, checkWebhooksSchema, configExports, createFileCleanup, relayed, sqlSends, startJobs } from "@protobase/server";
|
|
13
14
|
import { createAdaptorServer, upgradeWebSocket } from "@hono/node-server";
|
|
14
15
|
//#region src/serve/lifecycle.ts
|
|
15
16
|
const listen = (server, port) => new Promise((resolve, reject) => {
|
|
@@ -48,7 +49,8 @@ const startServe = async ({ config, env, openDb = (url) => openAppDatabase(pg, u
|
|
|
48
49
|
if (env.startupChecks && sqlSends(project.realtime)) await checkChannelSends(db);
|
|
49
50
|
if (env.startupChecks && project.notifications) await checkNotificationsSchema(db, project.notifications, project.auth);
|
|
50
51
|
if (env.startupChecks && project.status) await checkStatusSchema(db, project.status, project.auth);
|
|
51
|
-
|
|
52
|
+
if (env.startupChecks && project.userTables) await checkUserTablesSchema(db);
|
|
53
|
+
const { admin, jobs: definitions } = projectAdmin(project, db, ui, reporter, upgradeWebSocket, await projectUserTables(project, db));
|
|
52
54
|
if (env.startupChecks && definitions.length > 0) await checkJobsSchema(db);
|
|
53
55
|
const jobs = definitions.length > 0 ? await startJobs({
|
|
54
56
|
db,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { mergeConfig } from "../config/merge-config.js";
|
|
2
|
+
import { checkUserTables } from "../config/user-tables-config.js";
|
|
2
3
|
import { withDefaults } from "../config/config-defaults.js";
|
|
3
4
|
import { calledConfig } from "../config/called-config.js";
|
|
4
5
|
import { bundleBasePath } from "../bundle/manifest.js";
|
|
@@ -36,7 +37,7 @@ const bundledConfig = async (bundle, command, root) => {
|
|
|
36
37
|
files: projectFiles(files, bundle.packages),
|
|
37
38
|
label
|
|
38
39
|
});
|
|
39
|
-
const config = withDefaults(mergeConfig(...sources), command, process.env);
|
|
40
|
+
const config = checkUserTables(withDefaults(mergeConfig(...sources), command, process.env));
|
|
40
41
|
const basePath = config.apiPath;
|
|
41
42
|
if (basePath !== void 0 && basePath !== "/api/v1") throw new Error(`apiPath is ${JSON.stringify(basePath)}; a bundle's UI and manifest expect the API at ${bundleBasePath}, so remove it (see https://docs.protobase.net/reference/cli/#the-manifest)`);
|
|
42
43
|
return config;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@protobase/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.67.1",
|
|
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.
|
|
47
|
-
"@protobase/layout": "0.
|
|
48
|
-
"@protobase/query": "0.
|
|
49
|
-
"@protobase/schema": "0.
|
|
50
|
-
"@protobase/server": "0.
|
|
51
|
-
"@protobase/ui": "0.
|
|
46
|
+
"@protobase/config": "0.67.1",
|
|
47
|
+
"@protobase/layout": "0.67.1",
|
|
48
|
+
"@protobase/query": "0.67.1",
|
|
49
|
+
"@protobase/schema": "0.67.1",
|
|
50
|
+
"@protobase/server": "0.67.1",
|
|
51
|
+
"@protobase/ui": "0.67.1",
|
|
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",
|
|
@@ -101,6 +101,7 @@ Reference:
|
|
|
101
101
|
- When using components and action handlers of your own in layouts → read [Custom components](references/reference/custom-components.md)
|
|
102
102
|
- When building or loading plugins → read [Plugins](references/reference/plugins.md)
|
|
103
103
|
- When using resources another Protobase API serves → read [Remote resources](references/reference/remote-resources.md)
|
|
104
|
+
- When an app keeps tables its users add in its own database (`userTables`) → read [User tables](references/reference/user-tables.md)
|
|
104
105
|
- When setting the app's tokens, colors and fonts → read [Theme](references/reference/theme.md)
|
|
105
106
|
- When setting the app's name, icon, home-screen icons or install colours → read [App identity](references/reference/app-identity.md)
|
|
106
107
|
- When showing the app's screens in Dutch → read [Languages](references/reference/languages.md)
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
<!-- Generated by scripts/protobase-skill.ts from docs/website/src/content/docs/features/user-tables.md: run `bun run docs:skill`, do not edit. -->
|
|
2
|
+
|
|
3
|
+
# Tables users add
|
|
4
|
+
|
|
5
|
+
Tables an app's users add from the admin UI, by hand or from a file, and how a developer takes them over as code.
|
|
6
|
+
|
|
7
|
+
- [Tables defined in the database](../reference/user-tables.md#tables-defined-in-the-database) (in development): 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.
|
|
@@ -16,6 +16,7 @@ A feature without a mark is stable. The others say so after their name:
|
|
|
16
16
|
|
|
17
17
|
- [Start a project](features/start-a-project.md): Start a new app, or an admin over an existing Postgres database, and keep the config in step with it.
|
|
18
18
|
- [Data model](features/data-model.md): Describe the tables of an existing Postgres database as resources, with their fields, keys and rules.
|
|
19
|
+
- [Tables users add](features/user-tables.md): Tables an app's users add from the admin UI, by hand or from a file, and how a developer takes them over as code.
|
|
19
20
|
- [Access](features/access.md): Decide who may read and change which records and fields.
|
|
20
21
|
- [Lists](features/lists.md): The list page of each resource, with its columns, filters, charts, search, exports and imports.
|
|
21
22
|
- [List displays](features/list-displays.md): Show a list as cards, a map, a board, a calendar or a timeline, and reorder records by hand.
|
|
@@ -232,7 +232,7 @@ The page is checked like any other, and the account blocks are allowed on it alo
|
|
|
232
232
|
With mail configured, the sign-in page offers "Forgot password?": the person enters their email, gets a link, and chooses a new password on the page the link opens. It is Better Auth's own reset: `POST /api/auth/request-password-reset` with `{ email, redirectTo }`, the emailed link `GET /api/auth/reset-password/<token>`, which sends the browser back to `redirectTo` with `?token=...` (or `?error=INVALID_TOKEN`), and `POST /api/auth/reset-password` with `{ token, newPassword }`.
|
|
233
233
|
|
|
234
234
|
```sh
|
|
235
|
-
PROTOBASE_SMTP_URL=smtps://user:password@smtp.example.com:465 # or smtp://...:587, STARTTLS
|
|
235
|
+
PROTOBASE_SMTP_URL=smtps://user:password@smtp.example.com:465 # or smtp://...:587, which requires STARTTLS unless the URL ends in ?requireTLS=false
|
|
236
236
|
PROTOBASE_MAIL_FROM=noreply@admin.example.com
|
|
237
237
|
```
|
|
238
238
|
|
|
@@ -403,7 +403,7 @@ Serves the API of a bundle from `protobase build` and owns the process around it
|
|
|
403
403
|
| `PROTOBASE_DB_CONNECTIONS` | the connections that pool opens, such as the database's limit for the app; default 10. Sign-in's store shares them. [API functions](../guides/api-functions.md) and [jobs](jobs.md) hold at most one less, so a long import leaves a connection to page and API requests; those with [`longQueries`](../guides/api-functions.md#long-queries) take theirs from one more connection, kept for them. A query waits at most 2 seconds for a free connection, then fails with `503 connections-busy` ([Safety](api.md#safety)); a new connection may take up to 30 seconds to open, such as while a database that scaled to zero wakes |
|
|
404
404
|
| `PORT` | default 8787 |
|
|
405
405
|
| `REQUEST_LOG` | `true` logs every request; default `false` |
|
|
406
|
-
| `PROTOBASE_SMTP_URL` | the SMTP server for password-reset mail, emailed sign-in and two-factor codes and the app's own [email](email.md), credential included: `smtp://user:password@host:587` (STARTTLS
|
|
406
|
+
| `PROTOBASE_SMTP_URL` | the SMTP server for password-reset mail, emailed sign-in and two-factor codes and the app's own [email](email.md), credential included: `smtp://user:password@host:587` (STARTTLS required, `?requireTLS=false` sends in plaintext, see [Email](email.md#configuration)) or `smtps://...:465`; without it they are off ([Password reset](auth.md#password-reset), [Emailed sign-in codes](auth.md#emailed-sign-in-codes)) |
|
|
407
407
|
| `PROTOBASE_MAIL_FROM` | the sender of that mail, a plain address such as `noreply@admin.example.com`; set with `PROTOBASE_SMTP_URL` or not at all |
|
|
408
408
|
| `PROTOBASE_APP_NAME` | the app's name, such as `Property Finder`: what its auth emails, its sidebar, its browser tabs and the [installed app](installable-app.md) call it, and the sender's display name of its mail, over the config's [`name`](config/app.md#name) |
|
|
409
409
|
| `PROTOBASE_SIGN_IN_ISSUER`, `PROTOBASE_SIGN_IN_CLIENT_ID`, `PROTOBASE_SIGN_IN_CLIENT_SECRET` | an OpenID Connect provider people who have an account can sign in with; all three or none. `PROTOBASE_SIGN_IN_PROVIDER` and `PROTOBASE_SIGN_IN_NAME` are optional ([Platform sign-in provider](auth.md#platform-sign-in-provider)) |
|
|
@@ -173,6 +173,17 @@ export default defineConfig(({ env }) => ({
|
|
|
173
173
|
}))
|
|
174
174
|
```
|
|
175
175
|
|
|
176
|
+
## userTables
|
|
177
|
+
|
|
178
|
+
- **Type:** `boolean`
|
|
179
|
+
- **Default:** `false`
|
|
180
|
+
|
|
181
|
+
`true` keeps the tables the app's users add in its own database, in the `table_definitions` and `user_tables` schemas that [`protobase migration`](../cli.md#migration) writes, and serves each one like a resource in code. It cannot be on together with organizations. See [User tables](../user-tables.md).
|
|
182
|
+
|
|
183
|
+
```ts
|
|
184
|
+
export default defineConfig({ userTables: true })
|
|
185
|
+
```
|
|
186
|
+
|
|
176
187
|
## Code of the API
|
|
177
188
|
|
|
178
189
|
Two files of the project's own change what the API does, each its default export, with no key in the config:
|
|
@@ -28,7 +28,7 @@ Each key is described with its type and default in the option pages:
|
|
|
28
28
|
| --- | --- |
|
|
29
29
|
| [App options](config/app.md) | `name`, `url`, `secret`, `previousSecrets`, `locale`, `timezone`, `themeColor`, `backgroundColor`, `mobile` |
|
|
30
30
|
| [Sign-in options](config/sign-in.md) | `auth`, `authenticate`, `signingKeyDays`, `signingKeyGraceDays` |
|
|
31
|
-
| [API options](config/api.md) | `apiPath`, `apiKeys`, `cors`, `defaultAccess`, `rowPermissions`, `statementTimeoutMs`, `scanGuard`, `errorReporting`, `writeHooks`, `mcpEndpoint`, `remoteResources`, `runtimeUpdates`, `realtime`, `calls` |
|
|
31
|
+
| [API options](config/api.md) | `apiPath`, `apiKeys`, `cors`, `defaultAccess`, `rowPermissions`, `statementTimeoutMs`, `scanGuard`, `errorReporting`, `writeHooks`, `mcpEndpoint`, `remoteResources`, `runtimeUpdates`, `realtime`, `calls`, `userTables` |
|
|
32
32
|
| [Admin app options](config/admin-app.md) | `assistant`, `maps`, `plugins` |
|
|
33
33
|
| [Jobs and events options](config/jobs-and-events.md) | `jobs`, `events`, `history`, `webhooks`, `notifications`, `status` |
|
|
34
34
|
| [Files options](config/files.md) | `files` |
|
|
@@ -10,7 +10,7 @@ An app sends mail through one SMTP server: Protobase's own mail ([password reset
|
|
|
10
10
|
|
|
11
11
|
| Variable | |
|
|
12
12
|
| --- | --- |
|
|
13
|
-
| `PROTOBASE_SMTP_URL` | The SMTP server, credential included: `smtp://user:password@host:587` (STARTTLS
|
|
13
|
+
| `PROTOBASE_SMTP_URL` | The SMTP server, credential included: `smtp://user:password@host:587` (STARTTLS required) or `smtps://user:password@host:465` (TLS from the start). |
|
|
14
14
|
| `PROTOBASE_MAIL_FROM` | The sender, a plain address such as `noreply@shop.example.com`. The client greets the server with its domain; a `?name=` on the URL overrides that. |
|
|
15
15
|
| `PROTOBASE_APP_NAME` | Optional: the app's name, such as `Shop`, which mail clients show as the sender's name (`Shop <noreply@shop.example.com>`). It overrides the config's [`name`](config/app.md#name). Without either the sender has no name. |
|
|
16
16
|
|
|
@@ -22,10 +22,12 @@ Set both or neither. One without the other, a URL that is not `smtp://` or `smtp
|
|
|
22
22
|
docker run -d -p 8025:8025 -p 1025:1025 axllent/mailpit # its inbox is http://localhost:8025
|
|
23
23
|
|
|
24
24
|
# .env
|
|
25
|
-
PROTOBASE_SMTP_URL=smtp://localhost:1025
|
|
25
|
+
PROTOBASE_SMTP_URL=smtp://localhost:1025?requireTLS=false
|
|
26
26
|
PROTOBASE_MAIL_FROM=noreply@shop.example.com
|
|
27
27
|
```
|
|
28
28
|
|
|
29
|
+
Over `smtp://` the client always upgrades the connection with STARTTLS before it logs in or sends anything, so nobody between the app and the server reads the server's password or the mail, such as a sign-in code or a reset link. A server that does not offer STARTTLS, or an answer from which someone on the way removed it, fails the send, with the code `ETLS` when the server refuses the upgrade. `?requireTLS=false` on the URL is the only way to send in plaintext, for a server that has no TLS, such as the mail catcher above or a server on the same host. `smtps://` uses TLS from the start.
|
|
30
|
+
|
|
29
31
|
A project that delivers its mail another way exports a mailer, `{ send({ to, subject, text, html, headers }) }`, as the default of `auth/mailer.ts`: sign-in and the jobs (`mail` in their context) both send with it. `auth: { mailer: false }` turns sign-in's mail off whatever the environment says. A host that runs jobs itself passes a mailer to `startJobs`. A template's `send` takes one too, see [Templates](#templates).
|
|
30
32
|
|
|
31
33
|
### When the server does not answer
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
<!-- Generated by scripts/protobase-skill.ts from docs/website/src/content/docs/reference/user-tables.md: run `bun run docs:skill`, do not edit. -->
|
|
2
|
+
|
|
3
|
+
# User tables
|
|
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, and how each one is served.
|
|
6
|
+
|
|
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
|
+
|
|
9
|
+
## Tables defined in the database
|
|
10
|
+
|
|
11
|
+
:::caution[In development]
|
|
12
|
+
The server serves the tables defined in the database, but no screen or API adds one yet, and a definition added while the server runs is served after its next start.
|
|
13
|
+
:::
|
|
14
|
+
|
|
15
|
+
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.
|
|
16
|
+
|
|
17
|
+
<gap>Tables users add cannot be used in an app with organizations because keeping their definitions and records per organization is not implemented.</gap>
|
|
18
|
+
|
|
19
|
+
<gap>Users cannot add a table yet because the screens and the API that create one are not implemented.</gap>
|
|
20
|
+
|
|
21
|
+
<gap>A definition added while the server runs cannot be served before the server starts again because picking up new definitions while serving is not implemented.</gap>
|
|
22
|
+
|
|
23
|
+
### The switch
|
|
24
|
+
|
|
25
|
+
User tables are off by default. Turn them on in `protobase.config.ts`:
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
export default defineConfig({ userTables: true })
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
`userTables` takes only `true` or `false`. Together with organizations (`auth.organizations`), `dev`, `build` and `serve` stop with an error that names both settings.
|
|
32
|
+
|
|
33
|
+
### The schemas and the migration
|
|
34
|
+
|
|
35
|
+
The tables users add and their definitions live in two schemas of the app's database, so a table's name can never reach a definition:
|
|
36
|
+
|
|
37
|
+
| Schema | What it holds |
|
|
38
|
+
| --- | --- |
|
|
39
|
+
| `table_definitions` | `tables`, one row per table users add, with its singular and plural label, its title field and a version, and `fields`, each table's fields in order with their label, type and whether they are required |
|
|
40
|
+
| `user_tables` | the tables themselves, each with an `id bigint generated always as identity` primary key and a column per field |
|
|
41
|
+
|
|
42
|
+
With `userTables: true`, [`protobase migration`](cli.md#migration) writes a migration that adds both schemas, the two definition tables and the `pg_trgm` extension when it is missing, and grants the app's database role CREATE on `user_tables` alone, and writes nothing once the database has them. Apply it with the project's other migrations; the server never creates them itself. At start, `dev` and `serve` stop while one is missing; `PROTOBASE_STARTUP_CHECKS=false` skips that check in `serve`, as it skips the others.
|
|
43
|
+
|
|
44
|
+
A table's name and each field's name are lowercase letters, digits and `_`, starting with a letter, at most 63 bytes. A field is one of these types:
|
|
45
|
+
|
|
46
|
+
| Type | Column |
|
|
47
|
+
| --- | --- |
|
|
48
|
+
| `text` | `text` |
|
|
49
|
+
| `long_text` | `text`, edited in a box that grows with it |
|
|
50
|
+
| `integer` | `integer` |
|
|
51
|
+
| `decimal` | `numeric`, with the definition's precision and scale |
|
|
52
|
+
| `boolean` | `boolean` |
|
|
53
|
+
| `date` | `date` |
|
|
54
|
+
| `timestamp` | `timestamptz` |
|
|
55
|
+
| `json` | `jsonb` |
|
|
56
|
+
|
|
57
|
+
### How each one is served
|
|
58
|
+
|
|
59
|
+
When the server starts, it builds a resource from each definition and serves it at `/<table>` and `/<table>/<key>`, with its list, record page, sidebar entry and REST API, and in `/meta`, as the resources in code are. The labels of the definition are the names the admin app shows.
|
|
60
|
+
|
|
61
|
+
- **Admins only.** Each table starts readable and writable by admins alone, and is left out of role wildcards such as `*.read`, like [resources that need named grants](access.md#resources-that-need-named-grants). Anyone else does not see it.
|
|
62
|
+
- **Filters, sorts and search follow the indexes.** A field can be filtered or sorted by only where an index of its table serves it, and the table is searched on its title field only where a `pg_trgm` index serves that field, as the [scan guard](config/api.md#scanguard) requires.
|
|
63
|
+
- **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.
|
|
64
|
+
- **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.
|
|
65
|
+
|
|
66
|
+
[`protobase doctor`](cli.md#doctor) checks each definition against the database as it checks the resources in code, and reports each one it leaves out as an error. [`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.
|