@protobase/cli 0.66.5 → 0.66.7
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/config-bundle.js +11 -1
- package/dist/build/first-party-code.js +22 -0
- package/dist/build/plugin-build.js +3 -1
- package/dist/build/shell.js +3 -1
- package/dist/cli/package.js +1 -1
- package/dist/dev/server-package.js +8 -6
- package/dist/doctor/built-in-models.js +26 -0
- package/dist/doctor/run.js +11 -4
- package/dist/mobile/android-signing.js +8 -1
- package/dist/mobile/load-project.js +4 -1
- package/dist/project/cli-modules.js +30 -0
- package/dist/project/import-project-file.js +9 -9
- package/dist/project/pool-wait.js +15 -11
- package/package.json +8 -7
- package/skills/protobase/references/features/history-comments-and-live-updates.md +1 -0
- package/skills/protobase/references/features/sign-in-and-accounts.md +1 -0
- package/skills/protobase/references/features/start-a-project.md +2 -1
- package/skills/protobase/references/getting-started.md +2 -0
- package/skills/protobase/references/guides/mobile-app.md +2 -2
- package/skills/protobase/references/reference/api.md +2 -2
- package/skills/protobase/references/reference/auth.md +9 -5
- package/skills/protobase/references/reference/cli.md +3 -3
- package/skills/protobase/references/reference/comments.md +1 -1
- package/skills/protobase/references/reference/history.md +1 -1
- package/skills/protobase/references/reference/layouts.md +1 -1
- package/skills/protobase/references/reference/live.md +4 -0
- package/skills/protobase/references/reference/mcp.md +1 -1
- package/skills/protobase/references/reference/versioning.md +3 -2
- package/skills/protobase/references/reference/webhooks.md +2 -0
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { findPackageDir, packageName } from "./packages/package-dir.js";
|
|
2
2
|
import { hostModuleIds } from "../serve/host-module-ids.js";
|
|
3
|
+
import { cliPackages } from "../dev/server-package.js";
|
|
4
|
+
import { importProjectFiles } from "../project/import-project-file.js";
|
|
3
5
|
import { loadSourceConfig } from "../config/source-config.js";
|
|
4
6
|
import { layoutCheck } from "./layout-check.js";
|
|
5
7
|
import { extensionImports } from "../project/extensions.js";
|
|
6
8
|
import { viteBundle } from "./vite-bundle.js";
|
|
9
|
+
import { cliProtobaseIds, provideCliModules } from "../project/cli-modules.js";
|
|
7
10
|
import { collectComponents } from "./component-names.js";
|
|
8
11
|
import { bundleEntryCode } from "./entry-code.js";
|
|
9
12
|
import { hostPackages } from "./packages/host-packages.js";
|
|
@@ -67,9 +70,16 @@ const servedImports = (native) => {
|
|
|
67
70
|
};
|
|
68
71
|
};
|
|
69
72
|
const buildConfigBundle = async ({ projectDir, outFile, bun, extensions = [], components = /* @__PURE__ */ new Set() }) => {
|
|
73
|
+
if (process.versions.bun !== void 0) provideCliModules();
|
|
74
|
+
const cli = cliPackages();
|
|
75
|
+
const packages = {
|
|
76
|
+
...cli,
|
|
77
|
+
dedupe: [.../* @__PURE__ */ new Set([...cli.dedupe, ...cliProtobaseIds().map((id) => packageName(id))])]
|
|
78
|
+
};
|
|
70
79
|
const config = await loadSourceConfig(projectDir, {
|
|
71
80
|
command: "build",
|
|
72
|
-
extensions
|
|
81
|
+
extensions,
|
|
82
|
+
importModules: (files) => importProjectFiles(files, projectDir, packages)
|
|
73
83
|
});
|
|
74
84
|
const file = path.join(projectDir, "protobase-bundle-entry.js");
|
|
75
85
|
const native = /* @__PURE__ */ new Map();
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { firstPartyCodeKey } from "@protobase/schema/internal";
|
|
2
|
+
import { sentryVitePlugin } from "@sentry/vite-plugin";
|
|
3
|
+
//#region src/build/first-party-code.ts
|
|
4
|
+
/**
|
|
5
|
+
* Marks every script of a browser build as the app's own (see `firstPartyCodeKey`), with the error SDK's bundler
|
|
6
|
+
* plugin and nothing else of it: no release, no source map upload and no telemetry. Silent, since it still warns that
|
|
7
|
+
* it has no token to create the release it is told not to create.
|
|
8
|
+
*/
|
|
9
|
+
const firstPartyCode = () => sentryVitePlugin({
|
|
10
|
+
applicationKey: firstPartyCodeKey,
|
|
11
|
+
telemetry: false,
|
|
12
|
+
silent: true,
|
|
13
|
+
sourcemaps: { disable: true },
|
|
14
|
+
release: {
|
|
15
|
+
inject: false,
|
|
16
|
+
create: false,
|
|
17
|
+
finalize: false,
|
|
18
|
+
setCommits: false
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
//#endregion
|
|
22
|
+
export { firstPartyCode };
|
|
@@ -6,6 +6,7 @@ import { bundledImports } from "../project/bundled-imports.js";
|
|
|
6
6
|
import { sharedModules } from "../project/shared-modules.js";
|
|
7
7
|
import { pluginFieldTypes } from "./plugin-fields.js";
|
|
8
8
|
import { layerPluginUtilities } from "./plugin-styles.js";
|
|
9
|
+
import { firstPartyCode } from "./first-party-code.js";
|
|
9
10
|
import { createRequire } from "node:module";
|
|
10
11
|
import { existsSync } from "node:fs";
|
|
11
12
|
import path from "node:path";
|
|
@@ -120,7 +121,8 @@ const buildPlugin = async ({ pluginDir, outDir, name, projectDir, ignore = [], s
|
|
|
120
121
|
pluginStyles(styles, styleCode(pluginDir, target.theme, [outDir, ...ignore])),
|
|
121
122
|
react(),
|
|
122
123
|
tailwindcss(),
|
|
123
|
-
layeredStyles()
|
|
124
|
+
layeredStyles(),
|
|
125
|
+
firstPartyCode()
|
|
124
126
|
],
|
|
125
127
|
worker: { rolldownOptions: { output: { sourcemapDebugIds: sourceMaps } } },
|
|
126
128
|
build: {
|
package/dist/build/shell.js
CHANGED
|
@@ -2,6 +2,7 @@ import { readPackageJson } from "./packages/package-dir.js";
|
|
|
2
2
|
import { adminAppDir, uiEntry, uiPackageDir } from "../project/admin-app.js";
|
|
3
3
|
import { projectUiPlugin } from "../project/project-ui.js";
|
|
4
4
|
import { sharedModulesPlugin } from "../project/shared-modules.js";
|
|
5
|
+
import { firstPartyCode } from "./first-party-code.js";
|
|
5
6
|
import { pinnedNative } from "../mobile/pinned-packages.js";
|
|
6
7
|
import { faviconLink } from "../project/favicon-link.js";
|
|
7
8
|
import { themeLink } from "../project/theme-link.js";
|
|
@@ -69,7 +70,8 @@ const buildShell = async (outDir, sourceMapsDir) => {
|
|
|
69
70
|
faviconLink(),
|
|
70
71
|
serviceWorker(),
|
|
71
72
|
react(),
|
|
72
|
-
tailwindcss()
|
|
73
|
+
tailwindcss(),
|
|
74
|
+
firstPartyCode()
|
|
73
75
|
],
|
|
74
76
|
worker: { rolldownOptions: { output: { sourcemapDebugIds: sourceMaps } } },
|
|
75
77
|
build: {
|
package/dist/cli/package.js
CHANGED
|
@@ -6,6 +6,12 @@ import { fileURLToPath } from "node:url";
|
|
|
6
6
|
const server = "@protobase/server";
|
|
7
7
|
const supplied = [...new Set(hostModuleIds.map((id) => packageName(id)))];
|
|
8
8
|
const cliDir = fileURLToPath(new URL("../..", import.meta.url));
|
|
9
|
+
/** The CLI's own copies of the packages the serve runtime supplies, as a bundled project gets them. */
|
|
10
|
+
const cliPackages = (ownDir = cliDir) => ({
|
|
11
|
+
from: "cli",
|
|
12
|
+
root: ownDir,
|
|
13
|
+
dedupe: supplied
|
|
14
|
+
});
|
|
9
15
|
/**
|
|
10
16
|
* Where the API of `protobase dev` gets @protobase/server: from the project's node_modules when the project is locked,
|
|
11
17
|
* so one that is not installed fails as any missing package does; otherwise, bundled, the CLI's own, with the other
|
|
@@ -17,11 +23,7 @@ const serverPackage = (projectDir, ownDir = cliDir) => {
|
|
|
17
23
|
root: projectDir,
|
|
18
24
|
dedupe: [server]
|
|
19
25
|
};
|
|
20
|
-
return
|
|
21
|
-
from: "cli",
|
|
22
|
-
root: ownDir,
|
|
23
|
-
dedupe: supplied
|
|
24
|
-
};
|
|
26
|
+
return cliPackages(ownDir);
|
|
25
27
|
};
|
|
26
28
|
//#endregion
|
|
27
|
-
export { serverPackage };
|
|
29
|
+
export { cliPackages, serverPackage };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { builtInResources, configExports, webhooksModule } from "@protobase/server/config-exports";
|
|
2
|
+
//#region src/doctor/built-in-models.ts
|
|
3
|
+
/**
|
|
4
|
+
* The resources and views Protobase adds to an app by what its config enables, as the API serves them: the webhook ones
|
|
5
|
+
* of `webhooks`, then Failed jobs, Activity and Comments by what the app's resources use. Doctor checks them as it checks
|
|
6
|
+
* the app's own.
|
|
7
|
+
*/
|
|
8
|
+
const builtInModels = (config, models) => {
|
|
9
|
+
const webhooks = config.webhooks ? configExports(webhooksModule(config.webhooks)) : {
|
|
10
|
+
resources: [],
|
|
11
|
+
views: []
|
|
12
|
+
};
|
|
13
|
+
const webhookModels = webhooks.resources.map((resource) => resource.toModel());
|
|
14
|
+
const added = builtInResources({
|
|
15
|
+
models: [...webhookModels, ...models],
|
|
16
|
+
jobs: Object.keys(config.jobs).length > 0,
|
|
17
|
+
notifications: config.notifications !== void 0,
|
|
18
|
+
...config.events && { events: config.events }
|
|
19
|
+
});
|
|
20
|
+
return {
|
|
21
|
+
models: [...webhookModels, ...added.resources.map((resource) => resource.toModel())],
|
|
22
|
+
views: [...webhooks.views, ...added.views].map((view) => view.toModel())
|
|
23
|
+
};
|
|
24
|
+
};
|
|
25
|
+
//#endregion
|
|
26
|
+
export { builtInModels };
|
package/dist/doctor/run.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { loadSourceConfig } from "../config/source-config.js";
|
|
1
2
|
import { connect } from "../db/connect.js";
|
|
2
3
|
import { introspect } from "../db/introspect.js";
|
|
3
4
|
import { loadDataConfig } from "../project/load-data-config.js";
|
|
4
5
|
import { readOnlyDb } from "../db/read-only-db.js";
|
|
6
|
+
import { builtInModels } from "./built-in-models.js";
|
|
5
7
|
import { checkResource } from "./check.js";
|
|
6
8
|
import { indexIssues } from "./index-issues.js";
|
|
7
9
|
import { readShapes } from "./read-shapes.js";
|
|
@@ -15,12 +17,17 @@ const format = (issue) => ` ${issue.severity.padEnd(7)} ${issue.resource}: ${is
|
|
|
15
17
|
const runDoctor = async (options, out) => {
|
|
16
18
|
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>`);
|
|
17
19
|
const config = await loadDataConfig(options.configDir, options.projectDir);
|
|
18
|
-
|
|
19
|
-
|
|
20
|
+
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];
|
|
20
23
|
const sql = connect(options.url);
|
|
21
24
|
const tables = await introspect(sql, { views: true }).finally(() => sql.end());
|
|
22
25
|
const db = readOnlyDb(options.url);
|
|
23
|
-
const shapes = readShapes(
|
|
26
|
+
const shapes = readShapes({
|
|
27
|
+
models,
|
|
28
|
+
views: [...builtIn.views, ...config.views],
|
|
29
|
+
pages: config.pages
|
|
30
|
+
});
|
|
24
31
|
const indexes = await Promise.all(models.map(async (model) => indexIssues(model, await tableIndexes(db, model), shapes))).finally(() => db.destroy());
|
|
25
32
|
const issues = models.flatMap((model, i) => [
|
|
26
33
|
...checkResource(model, tables),
|
|
@@ -30,7 +37,7 @@ const runDoctor = async (options, out) => {
|
|
|
30
37
|
]);
|
|
31
38
|
for (const issue of issues) out(format(issue));
|
|
32
39
|
const errors = issues.filter((i) => i.severity === "error").length;
|
|
33
|
-
out(`Checked ${models.length} resources: ${errors} errors, ${issues.length - errors} warnings.\n`);
|
|
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`);
|
|
34
41
|
return {
|
|
35
42
|
errors,
|
|
36
43
|
warnings: issues.length - errors
|
|
@@ -12,6 +12,13 @@ const signingNames = new Set(androidSigningVariables);
|
|
|
12
12
|
const isSigningVariable = (key) => signingNames.has(key.toUpperCase());
|
|
13
13
|
/** `env` without the signing variables, in any letter case. */
|
|
14
14
|
const withoutAndroidSigning = (env) => Object.fromEntries(Object.entries(env).filter(([key]) => !isSigningVariable(key)));
|
|
15
|
+
/**
|
|
16
|
+
* Deletes from `env`, in place, every variable named `PROTOBASE_ANDROID_*` in any letter case: wider than the four, so
|
|
17
|
+
* no such variable is left for the project's code to find, while Gradle, which reads the four by name, gets them apart.
|
|
18
|
+
*/
|
|
19
|
+
const dropAndroidSigning = (env) => {
|
|
20
|
+
for (const key of Object.keys(env)) if (key.toUpperCase().startsWith("PROTOBASE_ANDROID_")) delete env[key];
|
|
21
|
+
};
|
|
15
22
|
const isFile = (file) => {
|
|
16
23
|
try {
|
|
17
24
|
return statSync(file).isFile();
|
|
@@ -50,4 +57,4 @@ const withReleaseSigning = (gradle) => {
|
|
|
50
57
|
return gradle.replace(releaseAnchor, `${signingConfig}${releaseAnchor} signingConfig signingConfigs.release\n`);
|
|
51
58
|
};
|
|
52
59
|
//#endregion
|
|
53
|
-
export { androidSigning, androidSigningVariables, withReleaseSigning, withoutAndroidSigning };
|
|
60
|
+
export { androidSigning, androidSigningVariables, dropAndroidSigning, withReleaseSigning, withoutAndroidSigning };
|
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
import { loadNearestEnvFile } from "../dev/env.js";
|
|
2
2
|
import { loadSourceConfig } from "../config/source-config.js";
|
|
3
|
+
import { dropAndroidSigning } from "./android-signing.js";
|
|
3
4
|
import { appNameOver } from "@protobase/server";
|
|
4
5
|
//#region src/mobile/load-project.ts
|
|
5
6
|
/**
|
|
6
7
|
* What a mobile build needs of the project in `dir`, merged as `protobase dev` merges it after the configs of
|
|
7
8
|
* `extensions`: its mobile section, unchecked, and with sign-in on the app's URL, its tokens' issuer, and its name.
|
|
8
|
-
* The project's .env is loaded first, as other commands do
|
|
9
|
+
* The project's .env is loaded first, as other commands do, and no `PROTOBASE_ANDROID_*` variable is left in the
|
|
10
|
+
* environment; nothing but the configs runs.
|
|
9
11
|
*/
|
|
10
12
|
const loadMobileProject = async (dir, extensions) => {
|
|
11
13
|
loadNearestEnvFile(dir);
|
|
14
|
+
dropAndroidSigning(process.env);
|
|
12
15
|
const { mobile, auth, url, name } = await loadSourceConfig(dir, {
|
|
13
16
|
command: "tool",
|
|
14
17
|
extensions
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { cliTypePaths } from "./project-types.js";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
//#region src/project/cli-modules.ts
|
|
4
|
+
const cliDir = fileURLToPath(new URL("../..", import.meta.url));
|
|
5
|
+
/** Every entry point of the @protobase packages the CLI carries, such as `@protobase/server/pdf`. */
|
|
6
|
+
const cliProtobaseIds = () => Object.keys(cliTypePaths()).filter((id) => id.startsWith("@protobase/"));
|
|
7
|
+
/**
|
|
8
|
+
* Under Bun, makes every later import of an entry point of the @protobase packages load the CLI's own copy, whatever
|
|
9
|
+
* the importer installed (Bun's virtual modules). Each loads when it is first imported, so the CLI loads nothing it did
|
|
10
|
+
* not already load or the project does not import. Only the @protobase packages: a virtual module that loads lazily
|
|
11
|
+
* cannot be `require`d, as the CLI requires React. Bun only: under Node the project's files load through Vite's module
|
|
12
|
+
* runner, which resolves them from the CLI itself. Each build calls it again, which Bun takes: a plugin of the same name
|
|
13
|
+
* registered again replaces nothing it needs and throws nothing.
|
|
14
|
+
*/
|
|
15
|
+
const provideCliModules = () => {
|
|
16
|
+
const bun = globalThis.Bun;
|
|
17
|
+
if (!bun) throw new Error("provideCliModules runs under Bun only");
|
|
18
|
+
const ids = cliProtobaseIds();
|
|
19
|
+
bun.plugin({
|
|
20
|
+
name: "protobase-cli-modules",
|
|
21
|
+
setup: (build) => {
|
|
22
|
+
for (const id of ids) build.module(id, async () => ({
|
|
23
|
+
exports: await import(bun.resolveSync(id, cliDir)),
|
|
24
|
+
loader: "object"
|
|
25
|
+
}));
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
};
|
|
29
|
+
//#endregion
|
|
30
|
+
export { cliProtobaseIds, provideCliModules };
|
|
@@ -3,10 +3,9 @@ import { writeProjectTypes } from "./project-types.js";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { pathToFileURL } from "node:url";
|
|
5
5
|
//#region src/project/import-project-file.ts
|
|
6
|
-
const runnerImport = async (file, projectDir, plugins = []) => {
|
|
6
|
+
const runnerImport = async (file, projectDir, resolution, plugins = []) => {
|
|
7
7
|
await writeProjectTypes(projectDir);
|
|
8
8
|
const vite = await import("vite");
|
|
9
|
-
const resolution = serverPackage(projectDir);
|
|
10
9
|
const packages = resolution.from === "cli" ? {
|
|
11
10
|
root: resolution.root,
|
|
12
11
|
resolve: { dedupe: resolution.dedupe }
|
|
@@ -23,29 +22,30 @@ const inPackage = (file) => file.split(path.sep).includes("node_modules");
|
|
|
23
22
|
const bunLoads = (projectDir) => process.versions.bun !== void 0 && serverPackage(projectDir).from === "project";
|
|
24
23
|
/**
|
|
25
24
|
* Imports one of the files of the project in `projectDir`, TypeScript: Bun loads it itself, Node through Vite's module
|
|
26
|
-
* runner, or a package's file as it is. A bundled project's file always goes through the runner. `
|
|
27
|
-
*
|
|
25
|
+
* runner, or a package's file as it is. A bundled project's file always goes through the runner. `resolution` says whose
|
|
26
|
+
* packages the runner resolves, by default as `serverPackage` says. `protobase dev` loads this module through Vite,
|
|
27
|
+
* which leaves these imports of a path as they are.
|
|
28
28
|
*/
|
|
29
|
-
const importProjectFile = async (file, projectDir) => inPackage(file) ? import(
|
|
29
|
+
const importProjectFile = async (file, projectDir, resolution = serverPackage(projectDir)) => inPackage(file) ? import(
|
|
30
30
|
/* @vite-ignore */
|
|
31
31
|
pathToFileURL(file).href
|
|
32
32
|
) : bunLoads(projectDir) ? import(
|
|
33
33
|
/* @vite-ignore */
|
|
34
34
|
file
|
|
35
|
-
) : runnerImport(file, projectDir);
|
|
35
|
+
) : runnerImport(file, projectDir, resolution);
|
|
36
36
|
/**
|
|
37
37
|
* Imports several of the project's files in order, so that the modules they share are loaded once. Bun's module cache
|
|
38
38
|
* does that itself; Vite's runner gets them as the imports of one module, which need not exist, beside the first file.
|
|
39
39
|
*/
|
|
40
|
-
const importProjectFiles = async (files, projectDir) => {
|
|
40
|
+
const importProjectFiles = async (files, projectDir, resolution = serverPackage(projectDir)) => {
|
|
41
41
|
if (bunLoads(projectDir) || files.length === 1) {
|
|
42
42
|
const modules = [];
|
|
43
|
-
for (const file of files) modules.push(await importProjectFile(file, projectDir));
|
|
43
|
+
for (const file of files) modules.push(await importProjectFile(file, projectDir, resolution));
|
|
44
44
|
return modules;
|
|
45
45
|
}
|
|
46
46
|
const id = path.join(path.dirname(files[0]), "protobase-project-files.js");
|
|
47
47
|
const code = [...files.map((file, i) => `import * as m${i} from ${JSON.stringify(file)}`), `export const modules = [${files.map((_, i) => `m${i}`).join(", ")}]`].join("\n");
|
|
48
|
-
return (await runnerImport(id, projectDir, [{
|
|
48
|
+
return (await runnerImport(id, projectDir, resolution, [{
|
|
49
49
|
name: "protobase-project-files",
|
|
50
50
|
resolveId: (source) => source === id ? `\0${id}` : void 0,
|
|
51
51
|
load: (source) => source === `\0${id}` ? code : void 0
|
|
@@ -1,20 +1,24 @@
|
|
|
1
1
|
import { ConnectionsBusy, poolWaitMs } from "@protobase/server";
|
|
2
2
|
//#region src/project/pool-wait.ts
|
|
3
3
|
/**
|
|
4
|
-
* The pool's `connect`, where a query that finds every connection in use and waits `poolWaitMs` for one
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* The pool's `connect`, where a query that finds every connection in use and waits `poolWaitMs` for one fails with
|
|
5
|
+
* `ConnectionsBusy`, so a saturated app answers at once rather than queue requests behind slow ones. A query that finds
|
|
6
|
+
* a connection free, or room for a new one, waits as long as the pool lets a new connection take to open, such as while
|
|
7
|
+
* a database that scaled to zero wakes. The pool still hands a query that gave up the connection it waited for, which
|
|
8
|
+
* goes straight back.
|
|
8
9
|
*/
|
|
9
10
|
const connectOrBusy = (pool) => async () => {
|
|
10
11
|
const max = pool.options.max ?? 10;
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
12
|
+
if (!(pool.totalCount >= max && pool.idleCount === 0)) return pool.connect();
|
|
13
|
+
const connecting = pool.connect();
|
|
14
|
+
let timer;
|
|
15
|
+
const gaveUp = new Promise((resolve) => {
|
|
16
|
+
timer = setTimeout(() => resolve("gave up"), poolWaitMs);
|
|
17
|
+
});
|
|
18
|
+
const first = await Promise.race([connecting, gaveUp]).finally(() => clearTimeout(timer));
|
|
19
|
+
if (first !== "gave up") return first;
|
|
20
|
+
connecting.then((client) => client.release(), () => {});
|
|
21
|
+
throw new ConnectionsBusy(max, poolWaitMs);
|
|
18
22
|
};
|
|
19
23
|
//#endregion
|
|
20
24
|
export { connectOrBusy };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@protobase/cli",
|
|
3
|
-
"version": "0.66.
|
|
3
|
+
"version": "0.66.7",
|
|
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,14 +43,15 @@
|
|
|
43
43
|
"dependencies": {
|
|
44
44
|
"@better-auth/core": "^1.7.12",
|
|
45
45
|
"@hono/node-server": "^2.1.3",
|
|
46
|
-
"@protobase/config": "0.66.
|
|
47
|
-
"@protobase/layout": "0.66.
|
|
48
|
-
"@protobase/query": "0.66.
|
|
49
|
-
"@protobase/schema": "0.66.
|
|
50
|
-
"@protobase/server": "0.66.
|
|
51
|
-
"@protobase/ui": "0.66.
|
|
46
|
+
"@protobase/config": "0.66.7",
|
|
47
|
+
"@protobase/layout": "0.66.7",
|
|
48
|
+
"@protobase/query": "0.66.7",
|
|
49
|
+
"@protobase/schema": "0.66.7",
|
|
50
|
+
"@protobase/server": "0.66.7",
|
|
51
|
+
"@protobase/ui": "0.66.7",
|
|
52
52
|
"@react-email/render": "^2.1.0",
|
|
53
53
|
"@scalar/hono-api-reference": "^0.12.10",
|
|
54
|
+
"@sentry/vite-plugin": "^11.10.0",
|
|
54
55
|
"@tailwindcss/vite": "^4.3.3",
|
|
55
56
|
"@vitejs/plugin-react": "^6.1.2",
|
|
56
57
|
"aip-parsers": "^1.0.0",
|
|
@@ -12,5 +12,6 @@ The changes of each record, comments on it, and pages that refresh as records ch
|
|
|
12
12
|
- [Comment moderation](../reference/comments.md#moderation): Admins find and delete comments across the app.
|
|
13
13
|
- [Live updates](../reference/live.md#what-a-page-hears): Open lists and record pages refresh within seconds when a record changes, including changes made outside the API.
|
|
14
14
|
- [Change stream](../reference/live.md#the-stream): Apps follow record changes over a stream that catches up after a reconnect.
|
|
15
|
+
- [Polling, then a socket](../reference/live.md#polling-then-a-socket): Apps and Node scripts poll for changes first and switch to a WebSocket after 30 seconds, or at once, falling back to polling while the socket is down.
|
|
15
16
|
- [Presence](../reference/channels.md#presence) (beta): Apps see who else has a record open and what each of them tracks, such as online or away, for whoever may read the record.
|
|
16
17
|
- [Broadcasts](../reference/channels.md#broadcasts) (beta): Apps on a record send each other short-lived events, such as typing, which are never stored, as JSON or as bytes, such as a chunk of audio or a drawing stroke; the app's own code sends them too, from an API function, over HTTP or [from SQL](../reference/channels.md#from-sql) with `channels.send()`.
|
|
@@ -11,6 +11,7 @@ Sign-in methods, users, roles, organizations and tokens.
|
|
|
11
11
|
- [Existing users table](../reference/auth.md#an-existing-users-table): Sign-in can use the users table another system keeps in the same database.
|
|
12
12
|
- [Users page](../reference/auth.md#users-page): Admins find people, set roles, block them, add accounts and invite people by email.
|
|
13
13
|
- [Password reset](../reference/auth.md#password-reset): People reset a forgotten password through an emailed link.
|
|
14
|
+
- [Password changes](../reference/auth.md#name-password-and-devices): Changing the password signs out every other device and MCP client, and emails the account that its password changed. Guesses at the current password are limited for each account.
|
|
14
15
|
- [Leaked passwords](../reference/auth.md#leaked-passwords): New passwords that appear in a known data breach are refused, when the app turns the check on.
|
|
15
16
|
- [Emailed sign-in codes](../reference/auth.md#emailed-sign-in-codes): People sign in with a code sent to their email. An address with no account is emailed that it has none, so the person tries the one they signed up with. An app can have the sign-in page lead with the code and put the password behind "Use a password".
|
|
16
17
|
- [Changing the address](../reference/auth.md#changing-the-address): People move their account to a new email address from the account page, once they enter the code sent there.
|
|
@@ -19,7 +19,8 @@ Start a new app, or an admin over an existing Postgres database, and keep the co
|
|
|
19
19
|
- [Scaffold from a database](../reference/cli.md#scaffold): Reads your Postgres tables and writes the data config for them, with field types, keys, relations, enums, soft delete, tenants and search filled in, and with `--ui` a first view of each.
|
|
20
20
|
- [Safe re-scaffolding](../reference/cli.md#re-running): Running scaffold again adds only the columns that are not declared yet and leaves your own config code as it is.
|
|
21
21
|
- [Schema drift report](../reference/cli.md#drift-report): After each scaffold, lists declared fields whose column is gone, whose type changed or whose enum values differ, with a suggested fix.
|
|
22
|
-
- [Doctor](../reference/cli.md#doctor): Checks every resource and view against the database,
|
|
22
|
+
- [Doctor](../reference/cli.md#doctor): Checks every resource and view against the database, the ones Protobase adds such as webhook subscriptions included, with the reads that lack an index, and prints the fix for each problem.
|
|
23
23
|
- [Admin without a view](../guides/table-to-admin.md#1-the-table-as-it-is): A table with an empty view already gives a working admin with every column, sorting, paging, a record page and a New button.
|
|
24
24
|
- [Agent skills](../reference/cli.md#skill): `protobase skill install` writes agent skills that match the installed version into the project, for the coding agents that work on it.
|
|
25
25
|
- [Docs as Markdown](../getting-started.md#docs-as-markdown): Every docs page is also Markdown at its address plus `.md`, for coding agents and chat assistants.
|
|
26
|
+
- [llms.txt](../getting-started.md#docs-as-markdown): The docs site serves `/llms.txt`, which links every page's Markdown, and the protobase agent skill at `/skills/protobase/SKILL.md`, for chat assistants.
|
|
@@ -78,6 +78,8 @@ For a Postgres database you already have, `npm create protobase <database-url>`
|
|
|
78
78
|
|
|
79
79
|
Every page of these docs is also Markdown at its address plus `.md`: `https://docs.protobase.net/reference/cli/` is also `https://docs.protobase.net/reference/cli.md`. The code a page shows from example files is written out in it, and its live demos are left out, so a coding agent or a chat assistant reads the page whole. In a project, [`protobase skill install`](reference/cli.md#skill) gives coding agents the docs of the installed version.
|
|
80
80
|
|
|
81
|
+
A chat assistant starts from [`/llms.txt`](https://docs.protobase.net/llms.txt), which links every page's Markdown and the protobase agent skill of the latest release at [`/skills/protobase/SKILL.md`](https://docs.protobase.net/skills/protobase/SKILL.md), with its references beside it.
|
|
82
|
+
|
|
81
83
|
The rest of this page works in a clone of this repository, on its example apps in `tests/apps`.
|
|
82
84
|
|
|
83
85
|
## Setup
|
|
@@ -44,7 +44,7 @@ With a mobile section, the API answers the app's origins, `capacitor://localhost
|
|
|
44
44
|
Signing in works in the app as on the web, with nothing more to set up:
|
|
45
45
|
|
|
46
46
|
- Emailed codes, passwords and the second step of two-factor authentication work. <gap>Passkeys do not work in the app yet.</gap> <gap>Sign-in with another provider does not work in the app yet.</gap>
|
|
47
|
-
- The app calls `/api/auth` through the device's native HTTP, naming its Capacitor origin. With a mobile section, sign-in trusts the Capacitor origin a request names, for that request only, without CORS.
|
|
47
|
+
- The app calls `/api/auth` through the device's native HTTP, naming its Capacitor origin. With a mobile section, sign-in trusts the Capacitor origin a request names, for that request only, without CORS. A POST from a Capacitor origin must send a JSON body: any other is refused with 415, as only a page in the app sends one.
|
|
48
48
|
- The session lives in the device's cookie store, so it survives reloads, updates and relaunches. On iOS, signing in or out within about a second of killing the app may be lost.
|
|
49
49
|
- <gap>"Forgot password?" is not offered in the app, as the emailed link cannot lead back into it.</gap>
|
|
50
50
|
- Page script cannot reach the session cookie, on iOS and Android alike: Capacitor's `CapacitorHttp`, `CapacitorCookies` and `WebView` plugins are refused to it, and so are the app's local file routes. A plugin that reads files for its JavaScript, such as `@capacitor/filesystem`, could still hand the cookie over.
|
|
@@ -118,7 +118,7 @@ Use it as the upload key with Play App Signing. Set these variables in your shel
|
|
|
118
118
|
|
|
119
119
|
Only Gradle gets them, for the length of the build: npm, its install scripts and Capacitor never do. The build needs the Android SDK and JDK 21.
|
|
120
120
|
|
|
121
|
-
**iOS**, on a Mac with Xcode: `--team` is your Apple Developer team id, shown in your account's membership details. Xcode signs with the certificates and profiles of the account you added in its settings. For manual signing, pass `--certificate <name>` and `--profile <name>` too. If automatic signing finds no profile for a new app, run `protobase mobile open ios`, pick the team in Signing & Capabilities and archive once from Xcode (Product, Archive), which creates the profile; then build again.
|
|
121
|
+
**iOS**, on a Mac with Xcode: `--team` is your Apple Developer team id, shown in your account's membership details. Xcode signs with the certificates and profiles of the account you added in its settings. For manual signing, pass `--certificate <name>` and `--profile <name>` too. <gap>Manual signing cannot use a certificate or profile name that holds `<`, `>` or `&` because writing the escaped export options is not implemented.</gap> If automatic signing finds no profile for a new app, run `protobase mobile open ios`, pick the team in Signing & Capabilities and archive once from Xcode (Product, Archive), which creates the profile; then build again.
|
|
122
122
|
|
|
123
123
|
## 7. Live updates
|
|
124
124
|
|
|
@@ -201,7 +201,7 @@ The API sends no CORS headers until it has something to allow: other websites in
|
|
|
201
201
|
## Safety
|
|
202
202
|
|
|
203
203
|
- Every request runs in a transaction with `SET LOCAL statement_timeout` (`statementTimeoutMs`, default 1 s), so a slow query fails where it runs instead of holding a connection that other requests wait for. List, search, get, histogram, series, map and seek transactions are read-only. A query that runs longer is stopped: a `504` `statement-timeout` that names the request, its limit and the ways out, such as `GET /api/v1/orders ran a query longer than 1 s, so the database stopped it. Make the query faster, such as with an index or a narrower filter, or move the work into a function or job with longQueries: true, whose queries may run up to 30 s.` It goes to the app's error hook (`hooks/error.ts`, `createAdmin`'s `options.onUnhandledError`) and the [error tracker](#error-reporting) like a `500`. A read that needs longer has no way to opt out: give it an index, or move it into a [function](../guides/api-functions.md#long-queries) or [job](jobs.md) with `longQueries`.
|
|
204
|
-
- Under `protobase dev` and `serve`, a request waits at most 2 seconds for a free database connection. When every connection stays busy that long, it is a `503` `connections-busy` with `Retry-After: 1` that names the request, reported the same way, rather than a request that hangs behind slow ones.
|
|
204
|
+
- Under `protobase dev` and `serve`, a request waits at most 2 seconds for a free database connection. When every connection stays busy that long, it is a `503` `connections-busy` with `Retry-After: 1` that names the request, reported the same way, rather than a request that hangs behind slow ones. A request that finds room for a new connection waits up to 30 seconds for it to open, such as while a database that scaled to zero wakes.
|
|
205
205
|
- **Scan guard**: a list, `:search`, `:seek`, `:map` page, card photos, `:facets` and `:aggregate` read only columns an index serves, so they stay cheap however many rows match. The server reads each table's valid indexes from the catalog once, then checks every column the caller's filter, the role's row filter, the sort's first field, a facet's or an aggregate's grouped field and a [`read` parent](access.md#records-that-belong-to-another)'s relation read, and the columns of the parent's own row filter against the parent's table:
|
|
206
206
|
- Comparisons, `in`, null checks, sorts and facets need a btree index with the column as a key, every key before it pinned by the query: by `=` or `in` in the filter (outside `OR` and `NOT`), by the tenant or by soft delete. `(for_sale, status, published_at desc)` serves `for_sale = true AND status = "available"` sorted by `published_at desc`, but not `status` alone. `=` on text may use a pg_trgm index instead.
|
|
207
207
|
- Patterns (`name = "*port*"`), `regex()`, `similar()` and `search()` need a pg_trgm (GIN or GiST) index on each column they read, a `digitsEnd` search field one on its digits; JSON `field:value` needs a GIN index.
|
|
@@ -235,6 +235,6 @@ export default defineConfig({
|
|
|
235
235
|
```
|
|
236
236
|
|
|
237
237
|
- **Server:** every `500` with the request it failed, and every `504 statement-timeout` and `503 connections-busy` with the request it names, under `dev`, `serve` and the runtime, besides the default export of `hooks/error.ts` or the console. Under `serve`, the runtime and `worker`, an uncaught exception or unhandled rejection too, which then still ends the process with exit code 1.
|
|
238
|
-
- **Admin app:** uncaught errors and the errors of components that fail to draw. `/api/meta` names the DSN to every signed-in caller, as a browser app always shows its own.
|
|
238
|
+
- **Admin app:** uncaught errors and the errors of components that fail to draw. `/api/meta` names the DSN to every signed-in caller, as a browser app always shows its own. Only the app's own errors are sent: `build`, `build-shell` and `build-plugin` mark every script they write as the app's, and an error none of whose stack frames is in a marked script, such as one raised by a browser extension or a script the browser injects, is dropped. Under `dev`, whose scripts are not built, every error is sent. <gap>An error raised only inside a plugin built by an earlier `build-plugin` cannot be reported because marking a plugin's scripts when the app loads them is not implemented; build the plugin again.</gap>
|
|
239
239
|
- Errors only, with no performance tracing. Before an event leaves, the values of authorization headers, cookies, passwords, tokens and other credentials, in fields and in URLs, are replaced by `[Filtered]`.
|
|
240
240
|
- `environment` names the deployment in the tracker; without it the tracker's default, `production`. `errorReporting: false` turns reporting off whatever the environment says. A DSN that is no `http(s)` URL with a key and a project id stops startup.
|
|
@@ -262,15 +262,19 @@ The signed-in person changes their own name and password, and signs out their ot
|
|
|
262
262
|
| Client | Endpoint |
|
|
263
263
|
| --- | --- |
|
|
264
264
|
| `updateName(name)` | `POST /api/auth/update-user` with `{ name }` |
|
|
265
|
-
| `changePassword(current, next
|
|
265
|
+
| `changePassword(current, next)` | `POST /api/auth/change-password` with `{ currentPassword, newPassword }`, for an account that has a password (a wrong one is `INVALID_PASSWORD`) |
|
|
266
266
|
| `setPassword(next)` | `POST /api/auth/account/set-password` with `{ newPassword }`, for an account without a password, such as one that signs in with [emailed codes](#emailed-sign-in-codes) or [GitHub](#sign-in-with-github); one that has a password already is `PASSWORD_ALREADY_SET` |
|
|
267
267
|
| `sessions()` | `GET /api/auth/list-sessions`: the devices signed in now, each `{ token, current, userAgent?, ipAddress?, createdAt, updatedAt, expiresAt, staff? }`, with `current` for this one and `staff` for a [staff session](#staff-sign-in) |
|
|
268
268
|
| `revokeSession(token)` | `POST /api/auth/revoke-session` with `{ token }`: signs out that device |
|
|
269
269
|
| `revokeOtherSessions()` | `POST /api/auth/revoke-other-sessions`: signs out every device but this one |
|
|
270
270
|
|
|
271
|
-
Signing out a device
|
|
271
|
+
Signing out a device stops that device's API token at its next request (see [When a token stops working](#when-a-token-stops-working)); this device stays signed in. A password change always signs out every other device, whatever `revokeOtherSessions` says, and disconnects the account's [MCP clients](#oauth-for-mcp-clients); this device gets a new session and stays signed in. A first password with `setPassword` signs out no device and disconnects no [MCP client](#oauth-for-mcp-clients); it needs 12 characters like any other, is limited to 5 per minute and client, and is refused while the [sign-in policy](#sign-in-policy) turns passwords off (`403`). A [staff session](#staff-sign-in) cannot change or set the password or sign out the other devices (`STAFF_CANNOT_CHANGE_SIGN_IN`).
|
|
272
272
|
|
|
273
|
-
|
|
273
|
+
When the app has mail, the account's address is emailed that its password changed, with a link to choose a new one if it was not them: after a change, a first password, a [reset](#password-reset), an admin setting it, and a new password through the [endpoints for supabase-js](#supabase-js-and-supabasessr).
|
|
274
|
+
|
|
275
|
+
Checks of the signed-in account's current password are limited to 5 a minute for each account, on top of the limit for each client: changing the password, deleting the account, turning [two-factor authentication](#two-factor-authentication) on or off, its authenticator link and new backup codes, and `POST /api/auth/verify-password`. Every check counts, a right one too; one over the limit is refused with `429` (`TOO_MANY_PASSWORD_CHECKS`, with `X-Retry-After` in seconds), so a stolen session cannot guess the password from many addresses. <gap>The current-password limit cannot be shared between server instances, each of which counts on its own, because a shared store for it is not implemented.</gap>
|
|
276
|
+
|
|
277
|
+
On the [account page](#account-page), the `Password` block does both: with a password, it asks for the current one and the new one twice, and says the other devices are signed out; without one, it sets the first, and nobody is signed out. While the sign-in policy turns passwords off, or in a staff session, it only says so.
|
|
274
278
|
|
|
275
279
|
The [account page](#account-page)'s Devices tab lists the sessions with this one first and marked, a Sign out on each other one and Sign out of all other devices. It names each by its browser and system from the user agent, and a staff session by the operator alone, such as "Protobase staff" with when it signed in and until when, never the staff member.
|
|
276
280
|
|
|
@@ -704,7 +708,7 @@ An app moving from Supabase keeps its `supabase.auth` calls and its `@supabase/s
|
|
|
704
708
|
- The sign-in must start at `/api/sb/auth/v1/authorize` in the same browser: `/api/sb/auth/v1/callback` hands a code or a session only at the end of a provider sign-in started there.
|
|
705
709
|
- The browser keeps no Protobase session afterwards: the app holds its own.
|
|
706
710
|
- A wildcard pattern in `auth.trustedOrigins` still trusts every host it matches with the sign-in code, so list only hosts you control.
|
|
707
|
-
- Addresses are proved with the emailed 6-digit code, never a link. `signUp({ email, password, options: { data } })` needs [open sign-up](#open-sign-up) and mails a sign-up code; `verifyOtp({ email, token, type: 'signup' })` makes the account, with `data` in the [user metadata column](#an-existing-users-table), and signs in. Until then there is no account and `signUp` answers no user. `resetPasswordForEmail` mails a sign-in code; `verifyOtp` with `type: 'recovery'` signs in, and `updateUser({ password })` sets the new password.
|
|
711
|
+
- Addresses are proved with the emailed 6-digit code, never a link. `signUp({ email, password, options: { data } })` needs [open sign-up](#open-sign-up) and mails a sign-up code; `verifyOtp({ email, token, type: 'signup' })` makes the account, with `data` in the [user metadata column](#an-existing-users-table), and signs in. Until then there is no account and `signUp` answers no user. `resetPasswordForEmail` mails a sign-in code; `verifyOtp` with `type: 'recovery'` signs in, and `updateUser({ password })` sets the new password. Every new password through `updateUser` signs out the user's other sessions and [MCP clients](#oauth-for-mcp-clients), and emails the address when the app has mail.
|
|
708
712
|
- The access token is ES256, valid for an hour, with Supabase's claims: `sub`, `role: authenticated`, `aud: authenticated`, `email`, `app_metadata`, `user_metadata`, `aal`, `session_id`, issued by `<url>/api/sb/auth/v1`. Its keys are at `/api/sb/auth/v1/.well-known/jwks.json`. It is apart from the EdDSA token of [Tokens and roles](#tokens-and-roles): neither is accepted for the other.
|
|
709
713
|
- The refresh token is the Better Auth session's token. Each refresh gives the session a new one, so a refresh token works once, and signing out, a ban or a revoked session ends it.
|
|
710
714
|
- `email_confirmed_at` is the time the address was confirmed, in the user table's `email_confirmed_at` column. An account verified before that column existed has none.
|
|
@@ -749,7 +753,7 @@ auth: { mcp: { path: '/api/mcp', connectPage: '/-/connect' } }
|
|
|
749
753
|
- Someone who has not set up what the sign-in policy requires is sent to the setup page first. Staff signed in as a person cannot allow a client, nor disconnect one. An account whose address is not verified cannot connect a client until it signs in once with an emailed code: a provider sign-up whose provider did not vouch for the address, or a password account created before Protobase created accounts verified. With password-only sign-in, or without mail, it cannot.
|
|
750
754
|
- The connect page shows the client's name as the client's own claim, who published it for a client with an https id, where the code goes (a website by its host, an app on this computer by its port, or an app on the device by its whole address), and the signed-in address. It cannot be used inside another site's frame, and Allow takes a click only once the page has been in front for half a second. `protobase serve` and `protobase dev` send `X-Frame-Options: DENY` and `Content-Security-Policy: frame-ancestors 'none'` with every page of the UI; a host that serves `public/` itself should send them too.
|
|
751
755
|
- The account page lists the clients a person allowed, under Connected apps, and disconnects any one: its consent is forgotten and its tokens revoked, access tokens already issued included, so it has to ask again. A client published at a URL has one id for everyone, so two copies of it allowed on one account with the same role show as one entry, and disconnecting it disconnects both.
|
|
752
|
-
- Signing out everywhere, signing out the other devices, a password change
|
|
756
|
+
- Signing out everywhere, signing out the other devices, a password change, a password reset, an admin ending the person's sessions, banning them, setting their password or marking their address unverified, and `protobase users disable` disconnect every client and forget which were allowed, so each has to ask again; enabling a disabled user brings none back. Through the [endpoints for supabase-js](#supabase-js-and-supabasessr) the same holds for signing out with the `global` or `others` scope and for every new password set with `PUT /user`, since a password reset ends there too. Signing out of one browser, or a session expiring, disconnects nothing.
|
|
753
757
|
- A refresh token presented again after it was rotated, outside the provider's 30-second window for a retried request, is taken for stolen: that client is disconnected for the person, its access tokens stop at the next request, and it has to ask again. Their other clients keep working.
|
|
754
758
|
|
|
755
759
|
## Several origins
|
|
@@ -107,7 +107,7 @@ protobase doctor [connection string | --env VAR] [--config <dir>]
|
|
|
107
107
|
|
|
108
108
|
The URL comes as for [`scaffold`](#scaffold): the connection string, the variable `--env` names, or `DATABASE_URL`, each also read from the nearest `.env`.
|
|
109
109
|
|
|
110
|
-
Imports `config/index.ts` and the views of `config/*/ui.ts`, as the config module has them by convention, and checks every exported resource against the database:
|
|
110
|
+
Imports `config/index.ts` and the views of `config/*/ui.ts`, as the config module has them by convention, and checks every exported resource against the database, with the resources Protobase adds by what `protobase.config.ts` (read with `command: 'tool'`) and the resources enable: the [webhook](webhooks.md#subscriptions-in-the-admin-app) ones of `webhooks`, Failed jobs, [Activity](history.md) and [Comments](comments.md). The tables [`migration`](#migration) writes for those have every index their reads need, so after the project's migrations they pass:
|
|
111
111
|
|
|
112
112
|
- error: missing table or column, field type that does not fit the column (a file field needs a text column), table without a usable key, configured key not backed by a unique index, `.openExisting()` fields that are not the columns of a unique index, relation to a resource the config does not declare
|
|
113
113
|
- error: a read the [scan guard](api.md#safety) refuses, because no index serves a column it filters, sorts, searches or counts by. Doctor reads each table's indexes from the catalog, as the server does, and judges with the scan guard's own rule every read the API takes and the app makes: a filter on each `.filterable()` field, a sort by each `.sortable()` one, `search()` over the search fields, a role's own records (`.owner()`) and the names a relation shows; each view's list in its order and with the filter it opens with, its filter widgets and their counts, its chart, its map, board, calendar and timeline, its card photos, its order by hand, its related sections, galleries and charts, its named lists, its recent records and a saved search's new records; and each block of a [composed page](layouts.md) that lists records, and each `Chart` block's grouped and split fields. Each error is an index to add, with every request that needs it and where it comes from:
|
|
@@ -388,7 +388,7 @@ Serves the API of a bundle from `protobase build` and owns the process around it
|
|
|
388
388
|
| Variable | |
|
|
389
389
|
| --- | --- |
|
|
390
390
|
| `DATABASE_URL` | the API's pool (Kysely over `pg`, 10 connections) |
|
|
391
|
-
| `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)) |
|
|
391
|
+
| `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 |
|
|
392
392
|
| `PORT` | default 8787 |
|
|
393
393
|
| `REQUEST_LOG` | `true` logs every request; default `false` |
|
|
394
394
|
| `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 when offered) or `smtps://...:465`; without it they are off ([Password reset](auth.md#password-reset), [Emailed sign-in codes](auth.md#emailed-sign-in-codes)) |
|
|
@@ -610,7 +610,7 @@ Builds the project in the current directory as an iOS or Android app with Capaci
|
|
|
610
610
|
- `run` runs the app on `--target`, or asks for one. On iOS, `--team` writes the Apple team id into the generated project and builds it once with xcodebuild allowed to create the development profile and register the device, `--target` or the one connected device, before Capacitor runs it there. With `--live` it opens `protobase dev` on `localhost:--port` instead of its own files. `open` opens the native project in Xcode or Android Studio.
|
|
611
611
|
- `build` makes a signed release, an `.aab` (Android) or `.ipa` (iOS), and copies it to `--out`, which ignores itself in git when the command creates it, as `<appId>-<version>-<build number>`. Android signs through Gradle with the keystore, alias and passwords in `PROTOBASE_ANDROID_KEYSTORE`, `PROTOBASE_ANDROID_KEY_ALIAS`, `PROTOBASE_ANDROID_KEYSTORE_PASSWORD` and `PROTOBASE_ANDROID_KEY_PASSWORD`; iOS runs Capacitor's build with `--team`, and `--certificate` with `--profile` for manual signing.
|
|
612
612
|
- A release calls an https server that is not this machine, and `--app-version` (one to three numbers) and `--build-number` (1 to 2100000000) are required.
|
|
613
|
-
- npm and Capacitor get the CLI's environment without any variable named in the current directory's `.env*` files, nor the four `PROTOBASE_ANDROID_*` signing variables, which only Gradle gets, and with `CI=true`, which keeps Capacitor's telemetry off.
|
|
613
|
+
- npm and Capacitor get the CLI's environment without any variable named in the current directory's `.env*` files, nor the four `PROTOBASE_ANDROID_*` signing variables, which only Gradle gets, and with `CI=true`, which keeps Capacitor's telemetry off. The project's config, its extensions' configs and the web build run inside the CLI with no `PROTOBASE_ANDROID_*` variable left in its environment, even one the project's `.env` sets.
|
|
614
614
|
- It prints the workspace, the API origin and the native fingerprint, which the app also sends in its user agent as `ProtobaseNative/<fingerprint>`: a hash of the native packages `@protobase/mobile` pins, at their versions, its own native contract, and the section's `plugins`, at the versions they give. A [mobile release](#mobile-releases) runs only on a binary of its own fingerprint. A Protobase release that pins the same native packages and keeps the same native contract keeps it; raise a plugin's version when the app's files start using a newer native feature of it, which changes it.
|
|
615
615
|
- The app's files carry the live updater's manifest of them, so an update downloads only the files that changed.
|
|
616
616
|
- With `mobile.publicKey`, the Android and iOS app get the [live updater](#the-live-updater), unless `--live` is given, the app's files name no build time, or the API is neither https nor http to this machine; it prints a note why when it leaves it out.
|
|
@@ -64,7 +64,7 @@ Once a resource takes comments, admins get **Comments** (the built-in resource `
|
|
|
64
64
|
|
|
65
65
|
## The table
|
|
66
66
|
|
|
67
|
-
[`protobase migration`](cli.md#migration) adds the schema `comments` with the table `comments.comments`; apply it with the project's other migrations. `protobase dev` and `protobase serve` do not start while a resource has comments and the table is missing. A deleted record's comments stay in the table.
|
|
67
|
+
[`protobase migration`](cli.md#migration) adds the schema `comments` with the table `comments.comments`; apply it with the project's other migrations. It has an index for each column the Comments list filters on, and the next migration adds one an earlier version's table lacks. `protobase dev` and `protobase serve` do not start while a resource has comments and the table or one of its indexes is missing. A deleted record's comments stay in the table.
|
|
68
68
|
|
|
69
69
|
| Column | |
|
|
70
70
|
| --- | --- |
|
|
@@ -71,7 +71,7 @@ Once a resource has history, admins get **Activity** (the built-in resource `rec
|
|
|
71
71
|
|
|
72
72
|
## The table
|
|
73
73
|
|
|
74
|
-
[`protobase migration`](cli.md#migration) adds the table `changes.history` to the `changes` schema of the event capture (renamed with `events: { schema }`), and the capture triggers of each resource with history; apply it with the project's other migrations. `protobase dev` and `protobase serve` do not start while the table or a trigger is missing. A resource that drops `.history()` loses its triggers, unless it has events, at the next migration; its changes stay in the table.
|
|
74
|
+
[`protobase migration`](cli.md#migration) adds the table `changes.history` to the `changes` schema of the event capture (renamed with `events: { schema }`), and the capture triggers of each resource with history; apply it with the project's other migrations. It has an index for each column the Activity list filters on, and the next migration adds one an earlier version's table lacks. `protobase dev` and `protobase serve` do not start while the table, one of its indexes or a trigger is missing. A resource that drops `.history()` loses its triggers, unless it has events, at the next migration; its changes stay in the table.
|
|
75
75
|
|
|
76
76
|
| Column | |
|
|
77
77
|
| --- | --- |
|
|
@@ -69,7 +69,7 @@ A `RecordCard` (its first match) and each card of a `CardRow` hold a **record**.
|
|
|
69
69
|
| `status`, `plan.name` | A field of the record around it, through relations. |
|
|
70
70
|
| `invoices.count` | How many `invoices` the user can see (an exact count from the list API). |
|
|
71
71
|
|
|
72
|
-
Comparisons, `AND`, `OR`, `NOT` and
|
|
72
|
+
Comparisons, `AND`, `OR`, `NOT`, parentheses, `isNull(field)` and `in(field, "a", "b")` work. <gap>A condition cannot call other functions, such as `now()`, because their evaluation is not implemented.</gap> A field that is missing or that the user cannot read makes the comparison false, and a `Show` that names a resource or field the user cannot see is left out of their page.
|
|
73
73
|
|
|
74
74
|
Record pages take the same conditions in the `when` of `l.section`, `l.related` and `l.sidebar`; see [Form and read view](ui-config.md#form-and-read-view).
|
|
75
75
|
|
|
@@ -43,6 +43,10 @@ It is live data, not multiplayer editing: two people editing the same record sti
|
|
|
43
43
|
|
|
44
44
|
Connecting again with `?after=<cursor>` catches up on what was missed while disconnected. [`client.live({ onChanges, onResync })`](client.md) of `@protobase/client` follows the stream and connects again by itself, as the app does. However many callers of one client follow it, they share one stream, so a page with many live views holds one connection, which matters under HTTP/1.1, where a browser opens at most six to a host.
|
|
45
45
|
|
|
46
|
+
## Polling, then a socket
|
|
47
|
+
|
|
48
|
+
`pollThenSocket({ poll, onError, socket, onMessage, pollMs?, switchAfterMs?, socketFirst? })` of `@protobase/client`, in the browser and on Node, keeps up with anything that changes, until the function it returns is called. It calls `poll` at once and again `pollMs` (5 seconds) after each call ends, also after one that rejected, whose error goes to `onError`, and after `switchAfterMs` (30 seconds) opens the WebSocket `socket()` returns, such as `() => new WebSocket(url)`. Once the socket is open, polling stops: `poll` runs once more, for what changed before the socket opened, and `onMessage` gets each message's data. When the socket closes, or never opens, it polls again at once and opens a new socket `switchAfterMs` later. Polling goes on while a socket connects, so nothing waits on it. A short visit thus never opens a socket; `socketFirst: true` opens one at once instead, for a process that stays, and polls only while it is down.
|
|
49
|
+
|
|
46
50
|
Who is on a record's page and what they send each other, which no change of a record says, goes over the record's [channel](channels.md).
|
|
47
51
|
|
|
48
52
|
## The table
|
|
@@ -100,7 +100,7 @@ export default defineConfig({ auth: { mcp: true }, mcpEndpoint: { writes: true }
|
|
|
100
100
|
| --- | --- |
|
|
101
101
|
| `create_record` | Creates a record of a resource from `data`, field names to values, and returns its key and ETag with the fields it wrote, see [What a write returns](#what-a-write-returns). |
|
|
102
102
|
| `update_record` | Changes the fields in `data` of one record by its key, and returns its key and new ETag with the fields it set. With the `etag` that `get_record` gave, it fails when someone changed the record since. It does not set the field a soft delete sets: a delete goes through `delete_record`. |
|
|
103
|
-
| `delete_record` | Deletes one record by its key
|
|
103
|
+
| `delete_record` | Deletes one record by its key. With the `etag` that `get_record` gave, it fails when someone changed the record since; without one, or with `*`, it deletes the record whatever its version. A resource with a [soft delete](data-config.md) keeps the record, deleted; any other loses it for good. |
|
|
104
104
|
| `batch_write` | Creates, updates and deletes records across resources in one transaction, see [Batch writes](#batch-writes). |
|
|
105
105
|
|
|
106
106
|
Each write goes through the REST API as the person: their role's access rules, `validate`, write hooks, events and webhooks apply, so the client can do only what the person may do in the app.
|
|
@@ -49,6 +49,7 @@ Every commit message follows [Conventional Commits](https://www.conventionalcomm
|
|
|
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.
|
|
52
|
+
6. Once npm serves the packages, `scripts/bump-repos/main.ts` starts the `protobase-bump.yml` workflow, with the release's version, of each repository the `PROTOBASE_BUMP_REPOS` secret lists, using the `PROTOBASE_BUMP_TOKEN` secret; an unset or empty list bumps nothing. Each listed repository owns that workflow, with a `version` input: it moves the repository's `@protobase` packages to the release in a pull request of its own, which merges itself once its CI is green and otherwise stays open for a fix. A repository that cannot be started within a minute fails the job after the others were started, and nothing else waits on it.
|
|
52
53
|
|
|
53
54
|
When `main` moved on while the checks ran, the run of the newer push releases both. `bun run release --dry-run` prints the next version and its notes without tagging anything.
|
|
54
55
|
|
|
@@ -61,11 +62,11 @@ Where a project's `package.json` lists the `@protobase` packages decides whose c
|
|
|
61
62
|
| `package.json` | Mode | Copies used by `dev`, `build` and `protobase run` | The bundle's `protobase` |
|
|
62
63
|
| --- | --- | --- | --- |
|
|
63
64
|
| `@protobase/*` in `devDependencies` | bundled (what the presets write) | the CLI's own | the CLI's version |
|
|
64
|
-
| `@protobase/*` in `dependencies` | locked | the project's installed copies | the installed version |
|
|
65
|
+
| `@protobase/*` in `dependencies` | locked | the project's installed copies, but the build reads the config with the CLI's own | the installed version |
|
|
65
66
|
| no `@protobase/*`, or no `package.json` | bundled | the CLI's own | the CLI's version |
|
|
66
67
|
|
|
67
68
|
- **Bundled.** Nothing of Protobase has to be installed: a `protobase.config.ts` alone is a project. The CLI that runs, installed globally or as the project's dev dependency, supplies every `@protobase` package and every package the serve runtime supplies to a bundle (`kysely`, `pg`, `react`, ...), whatever the project installed: to `dev`'s API and page, to the build, to [`protobase run`](cli.md#run) and, through [`protobase types`](cli.md#types), to the editor. Installing a newer CLI that the specifier holds updates the app with no change to its files or lockfile: dev and the next build use it, and the bundle names its version. `@types/react` and `@types/node` stay the project's own dev dependencies, since the CLI carries no types for them.
|
|
68
|
-
- **Locked.** Move every `@protobase` package to `dependencies` at one exact version, and install. Dev
|
|
69
|
+
- **Locked.** Move every `@protobase` package to `dependencies` at one exact version, and install. Dev and scripts use the installed copies, and the bundle names that version, so a host serves exactly it. The build reads the config with the building CLI's own `@protobase` packages, as a serve runtime supplies its own to the bundle, so it loads one copy of each. A CLI installed elsewhere hands a locked project over to the `@protobase/cli` it installed (see [CLI](cli.md)). The version moves only when the specifier and the lockfile do.
|
|
69
70
|
|
|
70
71
|
Packages the runtime supplies, such as React, may be listed for their types, in either mode: in a bundle the runtime's copy runs, whichever the project installed. `dependencies` holds the packages the project's own code uses at runtime.
|
|
71
72
|
|
|
@@ -116,6 +116,8 @@ It adds two pages, over a `webhooks` schema that [`protobase migration`](cli.md#
|
|
|
116
116
|
- **Webhook subscriptions** (`webhook_subscriptions`, the table `webhooks.subscriptions`): a name, the events (`orders.created orders.large`, or `orders.*`), the URL, a filter, extra headers, full or thin payload, and whether it is enabled. The server sets the tenant and the owner, the person who made it, and makes the secret, which only a [reveal](api.md#sensitive-fields-and-audit) shows. Saving checks the events and the filter against the resources, and refuses a URL whose host is a private, loopback or link-local address.
|
|
117
117
|
- **Webhook deliveries** (`webhook_deliveries`, a view over the delivery jobs): the deliveries that wait for their next try or failed for good, with their last error. A delivery leaves the list once it succeeds.
|
|
118
118
|
|
|
119
|
+
The migration gives the subscriptions table an index for each sort, filter and search of its list, and creates the `pg_trgm` extension the search's indexes need (`create extension if not exists pg_trgm`, a trusted extension since Postgres 13, so any role that may create in the database can). A database that has an earlier version's schema gets the indexes from the next `protobase migration`; the server does not start until it is applied.
|
|
120
|
+
|
|
119
121
|
Both are scoped to the tenant like any resource with `.tenant()`, and their access decides who may do what: `webhook_subscriptions.create`, `webhook_deliveries.update` and so on, as for the app's own resources. A role's wildcard over resources, such as `*.read`, grants none of them: their URLs and secrets let whoever reads them call the receivers, so with roles only admins (`*`) and roles that [name them](access.md#resources-that-need-named-grants) manage webhook subscriptions.
|
|
120
122
|
|
|
121
123
|
A subscription made here **delivers as its owner**, with the role they act with now in its tenant (the [active role](auth.md#active-role) they last chose, else their first): only records and fields it may read. Letting people make subscriptions therefore grants them nothing they could not read already. The roles are read from the app's sign-in; a project that signs people in otherwise exports its own, `async ({ id, tenant }) => [...]`, as the default of `webhooks/owner-roles.ts`.
|