@protobase/cli 0.67.0 → 0.67.2

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.
@@ -1,4 +1,4 @@
1
1
  //#region package.json
2
- var version = "0.67.0";
2
+ var version = "0.67.2";
3
3
  //#endregion
4
4
  export { version };
@@ -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
@@ -1,13 +1,13 @@
1
1
  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
- import { projectAdmin } from "../project/project-admin.js";
4
+ import { servedAdmin } from "../project/served-admin.js";
5
5
  import { devJobs } from "./dev-jobs.js";
6
6
  import { devRealtimePrune } from "./dev-prune.js";
7
7
  import { devDatabase, loadDevConfig } from "./project.js";
8
8
  import { withDevServiceWorker } from "./service-worker-route.js";
9
9
  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";
10
+ 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
11
  import { upgradeWebSocket } from "@hono/node-server";
12
12
  import { Hono } from "hono";
13
13
  //#region src/dev/entry.ts
@@ -42,6 +42,7 @@ if (relayed(project.realtime)) await checkRealtimeRelay(db);
42
42
  if (sqlSends(project.realtime)) await checkChannelSends(db);
43
43
  if (project.notifications) await checkNotificationsSchema(db, project.notifications, project.auth);
44
44
  if (project.status) await checkStatusSchema(db, project.status, project.auth);
45
+ if (project.userTables) await checkUserTablesSchema(db);
45
46
  setMissing(process.env, supabaseEnv({
46
47
  url: process.env.PROTOBASE_DEV_URL,
47
48
  databaseUrl: process.env.PROTOBASE_DATABASE_URL,
@@ -54,7 +55,7 @@ const ui = {
54
55
  fieldTypes: {},
55
56
  icons: projectIcons(projectDir)
56
57
  };
57
- const { admin, jobs } = projectAdmin(project, db, ui, reporter, upgradeWebSocket);
58
+ const { admin, jobs } = await servedAdmin(project, db, ui, reporter, upgradeWebSocket);
58
59
  if (process.env.PROTOBASE_DEV_JOBS !== "false") await devJobs(db, jobs, admin.decryptField, project.mailer);
59
60
  devRealtimePrune(admin);
60
61
  var entry_default = withDevServiceWorker(new Hono(), Boolean(project.notifications)).route("/", admin);
@@ -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 builtIn = builtInModels(await loadSourceConfig(options.projectDir, { command: "tool" }), config.models);
22
- const models = [...builtIn.models, ...config.models];
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
- out(`Checked ${builtIn.models.length > 0 ? `${config.models.length} resources and ${builtIn.models.length} built-in ones` : `${models.length} resources`}: ${errors} errors, ${issues.length - errors} warnings.\n`);
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: [],
@@ -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,20 @@ 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 `readUserTables`). `report` is what the admin does with an unhandled error.
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));
15
+ const report = (error, request) => {
16
+ reporter?.report(error, request);
17
+ unhandled(error, request);
18
+ };
14
19
  const admin = createAdmin({
15
20
  resources,
16
21
  views,
22
+ ...userTables && { userTables },
17
23
  pages,
18
24
  ...userMenu && { userMenu },
19
25
  bottomBars,
@@ -42,15 +48,13 @@ const projectAdmin = (project, db, ui, reporter, upgradeWebSocket) => {
42
48
  ...upgradeWebSocket && { upgradeWebSocket },
43
49
  options: {
44
50
  ...project.options,
45
- onUnhandledError: (error, request) => {
46
- reporter?.report(error, request);
47
- unhandled(error, request);
48
- }
51
+ onUnhandledError: report
49
52
  }
50
53
  });
51
54
  return {
52
55
  admin,
53
- jobs: [...project.jobs, ...admin.jobs]
56
+ jobs: [...project.jobs, ...admin.jobs],
57
+ report
54
58
  };
55
59
  };
56
60
  //#endregion
@@ -0,0 +1,10 @@
1
+ import { userTableResources } from "@protobase/server";
2
+ //#region src/project/project-user-tables.ts
3
+ /** The tables users add, read from their definitions; each one that does not fit the database is printed as a warning and left out, so the rest of the app serves. */
4
+ const readUserTables = async (db) => {
5
+ const tables = await userTableResources(db);
6
+ for (const problem of tables.problems) console.warn(problem);
7
+ return tables;
8
+ };
9
+ //#endregion
10
+ export { readUserTables };
@@ -0,0 +1,29 @@
1
+ import { projectAdmin } from "./project-admin.js";
2
+ import { readUserTables } from "./project-user-tables.js";
3
+ import { readDefinitionsVersion, reloadingAdmin } from "@protobase/server";
4
+ //#region src/project/served-admin.ts
5
+ /**
6
+ * The admin a serving process answers with (`projectAdmin`). With `userTables`, it is rebuilt when the tables users add
7
+ * change (`reloadingAdmin`); the version is read before the tables, so a change between the two reads rebuilds again.
8
+ */
9
+ const servedAdmin = async (project, db, ui, reporter, upgradeWebSocket) => {
10
+ if (!project.userTables) return projectAdmin(project, db, ui, reporter, upgradeWebSocket);
11
+ const version = await readDefinitionsVersion(db);
12
+ const tables = await readUserTables(db);
13
+ const built = projectAdmin(project, db, ui, reporter, upgradeWebSocket, tables);
14
+ const admin = reloadingAdmin({
15
+ first: built.admin,
16
+ version,
17
+ readVersion: () => readDefinitionsVersion(db),
18
+ readTables: () => readUserTables(db),
19
+ build: (changed) => projectAdmin(project, db, ui, reporter, upgradeWebSocket, changed).admin,
20
+ report: built.report,
21
+ ...project.options?.statementTimeoutMs !== void 0 && { statementTimeoutMs: project.options.statementTimeoutMs }
22
+ });
23
+ return {
24
+ ...built,
25
+ admin
26
+ };
27
+ };
28
+ //#endregion
29
+ export { servedAdmin };
@@ -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 protobaseSchemas = (tables, target) => [...bySchema(tables)].flatMap(([schema, own]) => {
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,
@@ -1,7 +1,7 @@
1
1
  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
- import { projectAdmin } from "../project/project-admin.js";
4
+ import { servedAdmin } from "../project/served-admin.js";
5
5
  import { apiWebSocketServer } from "../project/api-websocket-server.js";
6
6
  import { withClientAddress } from "./client-address.js";
7
7
  import { createServeApp } from "./app.js";
@@ -9,7 +9,7 @@ import { startCleanupTimer } from "./cleanup-timer.js";
9
9
  import { closeSockets } from "./close-sockets.js";
10
10
  import { servedRelease } from "./mobile-release.js";
11
11
  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";
12
+ 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
13
  import { createAdaptorServer, upgradeWebSocket } from "@hono/node-server";
14
14
  //#region src/serve/lifecycle.ts
15
15
  const listen = (server, port) => new Promise((resolve, reject) => {
@@ -48,7 +48,8 @@ const startServe = async ({ config, env, openDb = (url) => openAppDatabase(pg, u
48
48
  if (env.startupChecks && sqlSends(project.realtime)) await checkChannelSends(db);
49
49
  if (env.startupChecks && project.notifications) await checkNotificationsSchema(db, project.notifications, project.auth);
50
50
  if (env.startupChecks && project.status) await checkStatusSchema(db, project.status, project.auth);
51
- const { admin, jobs: definitions } = projectAdmin(project, db, ui, reporter, upgradeWebSocket);
51
+ if (env.startupChecks && project.userTables) await checkUserTablesSchema(db);
52
+ const { admin, jobs: definitions } = await servedAdmin(project, db, ui, reporter, upgradeWebSocket);
52
53
  if (env.startupChecks && definitions.length > 0) await checkJobsSchema(db);
53
54
  const jobs = definitions.length > 0 ? await startJobs({
54
55
  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.67.0",
3
+ "version": "0.67.2",
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.0",
47
- "@protobase/layout": "0.67.0",
48
- "@protobase/query": "0.67.0",
49
- "@protobase/schema": "0.67.0",
50
- "@protobase/server": "0.67.0",
51
- "@protobase/ui": "0.67.0",
46
+ "@protobase/config": "0.67.2",
47
+ "@protobase/layout": "0.67.2",
48
+ "@protobase/query": "0.67.2",
49
+ "@protobase/schema": "0.67.2",
50
+ "@protobase/server": "0.67.2",
51
+ "@protobase/ui": "0.67.2",
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. Every server process serves a new table within a second of it being added.
@@ -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.
@@ -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` |
@@ -0,0 +1,83 @@
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.
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. Every server process serves a new table within a second of it being added.
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
+ ### The switch
22
+
23
+ User tables are off by default. Turn them on in `protobase.config.ts`:
24
+
25
+ ```ts
26
+ export default defineConfig({ userTables: true })
27
+ ```
28
+
29
+ `userTables` takes only `true` or `false`. Together with organizations (`auth.organizations`), `dev`, `build` and `serve` stop with an error that names both settings.
30
+
31
+ ### The schemas and the migration
32
+
33
+ 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:
34
+
35
+ | Schema | What it holds |
36
+ | --- | --- |
37
+ | `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 |
38
+ | `user_tables` | the tables themselves, each with an `id bigint generated always as identity` primary key and a column per field |
39
+
40
+ 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.
41
+
42
+ 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:
43
+
44
+ | Type | Column |
45
+ | --- | --- |
46
+ | `text` | `text` |
47
+ | `long_text` | `text`, edited in a box that grows with it |
48
+ | `integer` | `integer` |
49
+ | `decimal` | `numeric`, with the definition's precision and scale |
50
+ | `boolean` | `boolean` |
51
+ | `date` | `date` |
52
+ | `timestamp` | `timestamptz` |
53
+ | `json` | `jsonb` |
54
+
55
+ ### How each one is served
56
+
57
+ When the server starts, and whenever the definitions change, 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.
58
+
59
+ - **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.
60
+ - **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.
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
+ - **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
+
64
+ [`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.
65
+
66
+ ### How a change reaches every process
67
+
68
+ A definition added, changed or removed is served by every process of `serve` and `dev` within a second, with no restart or deploy. Each process rebuilds its API from the resources in code and the definitions as they are now, and the admin app reads `/meta` again once `X-Meta-Version` changes, as after any change of the app.
69
+
70
+ - **When a process looks.** `/meta` reads the version of the definitions before it answers. Any other request reads it at most once a second, and once more when its answer would be not found or invalid, as for a resource or field the process does not know yet; it is then answered by the API built anew. The process that makes a change rebuilds at once. A process that receives no request reads nothing, so a database that scales to zero stays asleep.
71
+ - **What a look costs.** The version is one read of `table_definitions.tables`, under a millisecond. Each change of a definition or its fields must raise the definition's `version`; adding and removing a definition change the version on their own.
72
+ - **What a rebuild costs.** A rebuild reads the definitions and the indexes of each table, then builds the API, once per process: requests that need it meanwhile wait for that one rebuild. Requests already running finish on the API they started with, and an open WebSocket keeps the API it opened with. <gap>A channel socket opened before a rebuild cannot hear a broadcast sent through a socket opened after it, in the same process and without a realtime relay, until it reconnects, because moving open sockets to the rebuilt API is not implemented.</gap> <gap>A request whose body is not JSON, such as a file upload, that names a table added under a second ago cannot be answered by the rebuilt API because replaying a streamed body is not implemented; it is answered not found, and works on the next try.</gap>
73
+
74
+ Measured with six fields per table on one machine running Postgres and the processes:
75
+
76
+ | Tables users add | Rebuild, one process | Rebuild, each of four processes at once |
77
+ | --- | --- | --- |
78
+ | 10 | under 0.1 s | under 0.1 s |
79
+ | 50 | about 0.1 s | about 0.4 s |
80
+ | 200 | about 0.7 s | about 1.4 s |
81
+ | 500 | about 2.2 s | about 4.5 s |
82
+
83
+ - **A rebuild that fails.** When the API does not build with the new definitions, the process reports why through `onUnhandledError`, naming what it refused, and keeps serving the API it had, with the resources in code. A definition that does not fit the database is left out with a warning, as at start.