@protobase/cli 0.67.1 → 0.67.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/package.js +1 -1
- package/dist/dev/entry.js +2 -3
- package/dist/project/project-admin.js +8 -6
- package/dist/project/project-user-tables.js +3 -8
- package/dist/project/served-admin.js +29 -0
- package/dist/serve/lifecycle.js +2 -3
- package/package.json +7 -7
- package/skills/protobase/references/features/user-tables.md +2 -1
- package/skills/protobase/references/reference/user-tables.md +65 -9
package/dist/cli/package.js
CHANGED
package/dist/dev/entry.js
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
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 {
|
|
5
|
-
import { projectUserTables } from "../project/project-user-tables.js";
|
|
4
|
+
import { servedAdmin } from "../project/served-admin.js";
|
|
6
5
|
import { devJobs } from "./dev-jobs.js";
|
|
7
6
|
import { devRealtimePrune } from "./dev-prune.js";
|
|
8
7
|
import { devDatabase, loadDevConfig } from "./project.js";
|
|
@@ -56,7 +55,7 @@ const ui = {
|
|
|
56
55
|
fieldTypes: {},
|
|
57
56
|
icons: projectIcons(projectDir)
|
|
58
57
|
};
|
|
59
|
-
const { admin, jobs } =
|
|
58
|
+
const { admin, jobs } = await servedAdmin(project, db, ui, reporter, upgradeWebSocket);
|
|
60
59
|
if (process.env.PROTOBASE_DEV_JOBS !== "false") await devJobs(db, jobs, admin.decryptField, project.mailer);
|
|
61
60
|
devRealtimePrune(admin);
|
|
62
61
|
var entry_default = withDevServiceWorker(new Hono(), Boolean(project.notifications)).route("/", admin);
|
|
@@ -7,11 +7,15 @@ import { configExports, createAdmin } from "@protobase/server";
|
|
|
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
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 `
|
|
10
|
+
* beside the config's resources (see `readUserTables`). `report` is what the admin does with an unhandled error.
|
|
11
11
|
*/
|
|
12
12
|
const projectAdmin = (project, db, ui, reporter, upgradeWebSocket, userTables) => {
|
|
13
13
|
const { resources, views, pages, userMenu, bottomBars, subscriptions } = configExports(project.config);
|
|
14
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
|
+
};
|
|
15
19
|
const admin = createAdmin({
|
|
16
20
|
resources,
|
|
17
21
|
views,
|
|
@@ -44,15 +48,13 @@ const projectAdmin = (project, db, ui, reporter, upgradeWebSocket, userTables) =
|
|
|
44
48
|
...upgradeWebSocket && { upgradeWebSocket },
|
|
45
49
|
options: {
|
|
46
50
|
...project.options,
|
|
47
|
-
onUnhandledError:
|
|
48
|
-
reporter?.report(error, request);
|
|
49
|
-
unhandled(error, request);
|
|
50
|
-
}
|
|
51
|
+
onUnhandledError: report
|
|
51
52
|
}
|
|
52
53
|
});
|
|
53
54
|
return {
|
|
54
55
|
admin,
|
|
55
|
-
jobs: [...project.jobs, ...admin.jobs]
|
|
56
|
+
jobs: [...project.jobs, ...admin.jobs],
|
|
57
|
+
report
|
|
56
58
|
};
|
|
57
59
|
};
|
|
58
60
|
//#endregion
|
|
@@ -1,15 +1,10 @@
|
|
|
1
1
|
import { userTableResources } from "@protobase/server";
|
|
2
2
|
//#region src/project/project-user-tables.ts
|
|
3
|
-
/**
|
|
4
|
-
|
|
5
|
-
* when the definitions are in the database. Each definition that does not fit the database is printed as a warning and
|
|
6
|
-
* left out, so the rest of the app serves.
|
|
7
|
-
*/
|
|
8
|
-
const projectUserTables = async (project, db) => {
|
|
9
|
-
if (!project.userTables) return;
|
|
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) => {
|
|
10
5
|
const tables = await userTableResources(db);
|
|
11
6
|
for (const problem of tables.problems) console.warn(problem);
|
|
12
7
|
return tables;
|
|
13
8
|
};
|
|
14
9
|
//#endregion
|
|
15
|
-
export {
|
|
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 };
|
package/dist/serve/lifecycle.js
CHANGED
|
@@ -1,8 +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 {
|
|
5
|
-
import { projectUserTables } from "../project/project-user-tables.js";
|
|
4
|
+
import { servedAdmin } from "../project/served-admin.js";
|
|
6
5
|
import { apiWebSocketServer } from "../project/api-websocket-server.js";
|
|
7
6
|
import { withClientAddress } from "./client-address.js";
|
|
8
7
|
import { createServeApp } from "./app.js";
|
|
@@ -50,7 +49,7 @@ const startServe = async ({ config, env, openDb = (url) => openAppDatabase(pg, u
|
|
|
50
49
|
if (env.startupChecks && project.notifications) await checkNotificationsSchema(db, project.notifications, project.auth);
|
|
51
50
|
if (env.startupChecks && project.status) await checkStatusSchema(db, project.status, project.auth);
|
|
52
51
|
if (env.startupChecks && project.userTables) await checkUserTablesSchema(db);
|
|
53
|
-
const { admin, jobs: definitions } =
|
|
52
|
+
const { admin, jobs: definitions } = await servedAdmin(project, db, ui, reporter, upgradeWebSocket);
|
|
54
53
|
if (env.startupChecks && definitions.length > 0) await checkJobsSchema(db);
|
|
55
54
|
const jobs = definitions.length > 0 ? await startJobs({
|
|
56
55
|
db,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@protobase/cli",
|
|
3
|
-
"version": "0.67.
|
|
3
|
+
"version": "0.67.3",
|
|
4
4
|
"description": "The protobase command: scaffold, doctor, dev, build and serve a Protobase project.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -43,12 +43,12 @@
|
|
|
43
43
|
"dependencies": {
|
|
44
44
|
"@better-auth/core": "^1.7.12",
|
|
45
45
|
"@hono/node-server": "^2.1.3",
|
|
46
|
-
"@protobase/config": "0.67.
|
|
47
|
-
"@protobase/layout": "0.67.
|
|
48
|
-
"@protobase/query": "0.67.
|
|
49
|
-
"@protobase/schema": "0.67.
|
|
50
|
-
"@protobase/server": "0.67.
|
|
51
|
-
"@protobase/ui": "0.67.
|
|
46
|
+
"@protobase/config": "0.67.3",
|
|
47
|
+
"@protobase/layout": "0.67.3",
|
|
48
|
+
"@protobase/query": "0.67.3",
|
|
49
|
+
"@protobase/schema": "0.67.3",
|
|
50
|
+
"@protobase/server": "0.67.3",
|
|
51
|
+
"@protobase/ui": "0.67.3",
|
|
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",
|
|
@@ -4,4 +4,5 @@
|
|
|
4
4
|
|
|
5
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
6
|
|
|
7
|
-
- [Tables defined in the database](../reference/user-tables.md#tables-defined-in-the-database) (
|
|
7
|
+
- [Tables defined in the database](../reference/user-tables.md#tables-defined-in-the-database) (beta): An app can keep tables its users add in its own database, beside the resources in its code. Each one is served like any other resource, at its own address with its list, record page, search and API. Every server process serves a new table within a second of it being added.
|
|
8
|
+
- [Adding tables and fields](../reference/user-tables.md#adding-tables-and-fields) (beta): Someone with the right capability creates a table with its fields, or adds a field to it, through the app's API. It is created in the database at once, with an index for every field but JSON so its list can be filtered by it, and sorted by it unless it is long text, and only admins can see it until access is widened.
|
|
@@ -8,17 +8,15 @@ An app's resources usually come from its code: a resource in `config/` over a ta
|
|
|
8
8
|
|
|
9
9
|
## Tables defined in the database
|
|
10
10
|
|
|
11
|
-
:::caution[
|
|
12
|
-
|
|
11
|
+
:::caution[Beta]
|
|
12
|
+
Tables are added through the API; the admin app has no screens for them yet, and an app with organizations cannot use them.
|
|
13
13
|
:::
|
|
14
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.
|
|
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
16
|
|
|
17
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
18
|
|
|
19
|
-
<gap>Users cannot add a table yet because
|
|
20
|
-
|
|
21
|
-
<gap>A definition added while the server runs cannot be served before the server starts again because picking up new definitions while serving is not implemented.</gap>
|
|
19
|
+
<gap>Users cannot add a table from the admin app yet because its screens for tables and fields are not implemented.</gap>
|
|
22
20
|
|
|
23
21
|
### The switch
|
|
24
22
|
|
|
@@ -36,7 +34,7 @@ The tables users add and their definitions live in two schemas of the app's data
|
|
|
36
34
|
|
|
37
35
|
| Schema | What it holds |
|
|
38
36
|
| --- | --- |
|
|
39
|
-
| `table_definitions` | `tables`, one row per table users add, with its singular and plural label, its title field and a version, and `fields`, each table's fields in order with their label, type and whether they are required |
|
|
37
|
+
| `table_definitions` | `tables`, one row per table users add, with its singular and plural label, its title field and a version, 1 when it is added, and `fields`, each table's fields in order with their label, type and whether they are required |
|
|
40
38
|
| `user_tables` | the tables themselves, each with an `id bigint generated always as identity` primary key and a column per field |
|
|
41
39
|
|
|
42
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.
|
|
@@ -45,7 +43,7 @@ A table's name and each field's name are lowercase letters, digits and `_`, star
|
|
|
45
43
|
|
|
46
44
|
| Type | Column |
|
|
47
45
|
| --- | --- |
|
|
48
|
-
| `text` | `text
|
|
46
|
+
| `text` | `text`, at most 500 characters |
|
|
49
47
|
| `long_text` | `text`, edited in a box that grows with it |
|
|
50
48
|
| `integer` | `integer` |
|
|
51
49
|
| `decimal` | `numeric`, with the definition's precision and scale |
|
|
@@ -56,7 +54,7 @@ A table's name and each field's name are lowercase letters, digits and `_`, star
|
|
|
56
54
|
|
|
57
55
|
### How each one is served
|
|
58
56
|
|
|
59
|
-
When the server starts, it builds a resource from each definition and serves it at `/<table>` and `/<table>/<key>`, with its list, record page, sidebar entry and REST API, and in `/meta`, as the resources in code are. The labels of the definition are the names the admin app shows.
|
|
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.
|
|
60
58
|
|
|
61
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.
|
|
62
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.
|
|
@@ -64,3 +62,61 @@ When the server starts, it builds a resource from each definition and serves it
|
|
|
64
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.
|
|
65
63
|
|
|
66
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`, as the [API](#adding-tables-and-fields) does; 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.
|
|
84
|
+
|
|
85
|
+
## Adding tables and fields
|
|
86
|
+
|
|
87
|
+
:::caution[Beta]
|
|
88
|
+
Tables and fields are added through the API alone, and are never renamed, retyped or removed.
|
|
89
|
+
:::
|
|
90
|
+
|
|
91
|
+
Someone with the right capability creates a table with its fields, or adds a field to it, through the app's API. It is created in the database at once, with an index for every field but JSON so its list can be filtered by it, and sorted by it unless it is long text, and only admins can see it until access is widened.
|
|
92
|
+
|
|
93
|
+
With `userTables: true`, the API has two resources over the definitions, with the standard addresses and routes of the [API](api.md#routes):
|
|
94
|
+
|
|
95
|
+
| Resource | A record | Fields |
|
|
96
|
+
| --- | --- | --- |
|
|
97
|
+
| `user_tables` | a table, at `/user_tables/<id>` | `name`, `singular_label`, `plural_label`, `title_field`; `id` and `version` are read-only |
|
|
98
|
+
| `user_table_fields` | a field of a table, at `/user_table_fields/<table_id>,<name>` | `table_id`, `name`, `label`, `type` (one of the [types](#the-schemas-and-the-migration)), `position` (the order of the fields), `precision` and `scale` (a decimal's alone) |
|
|
99
|
+
|
|
100
|
+
Make a table with its fields in one [batch write](api.md#batch-writes): the table, each field pointing at it, then the title field, which names a field that exists. Nothing is created unless all of it is:
|
|
101
|
+
|
|
102
|
+
```json
|
|
103
|
+
POST /api/v1:batchWrite
|
|
104
|
+
{
|
|
105
|
+
"ops": [
|
|
106
|
+
{ "op": "create", "resource": "user_tables", "ref": "notes", "data": { "name": "notes", "singular_label": "Note", "plural_label": "Notes" } },
|
|
107
|
+
{ "op": "create", "resource": "user_table_fields", "data": { "table_id": { "$ref": "notes" }, "name": "title", "label": "Title", "type": "text", "position": 1 } },
|
|
108
|
+
{ "op": "create", "resource": "user_table_fields", "data": { "table_id": { "$ref": "notes" }, "name": "price", "label": "Price", "type": "decimal", "precision": 12, "scale": 2, "position": 2 } },
|
|
109
|
+
{ "op": "update", "resource": "user_tables", "key": { "$ref": "notes" }, "etag": "*", "data": { "title_field": "title" } }
|
|
110
|
+
]
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
The answer has the table's record, so a client links to the new table at `/notes`. Add a field later with `POST /api/v1/user_table_fields`.
|
|
115
|
+
|
|
116
|
+
- **What is created.** A table is `user_tables.<name>` with its `id bigint generated always as identity` key. A field is a column of its type that may be empty and has no default, so the records already there read it as empty, with a btree index, or a `pg_trgm` one for `long_text`, whose values outgrow a btree entry; a `json` field gets none. The title field gets the `pg_trgm` index search needs. The rows, the table or column, its indexes and the version are written in one transaction: anything refused leaves none of them.
|
|
117
|
+
- **What is refused.** A name outside the rules above, a word SQL reserves such as `select`, a field named `id` or `etag`, the name of a resource in code, of one Protobase adds or of a table `user_tables` already has, a field marked required, and a title that is not a text field of the table, each as a field error. A table whose name a resource in code has now, which is how a table is taken over as code, takes no new field.
|
|
118
|
+
- **What changes later.** The labels, the title field and the fields' order. A name, a type, a decimal's precision and scale never change, and no table or field is deleted. <gap>A table or field cannot be renamed, retyped or removed because changing the columns of a table users add is not implemented.</gap>
|
|
119
|
+
- **Who may.** With the project's [roles](access.md), the capabilities `user_tables.create` and `user_table_fields.create`, which only `*` or a grant that names them holds, never a wildcard such as `*.create`, like [resources that need named grants](access.md#resources-that-need-named-grants); reading and changing the labels need `.read` and `.update` the same way. Without roles, admins. Each new table is its admins' alone, as [each one served](#how-each-one-is-served) is.
|
|
120
|
+
- **The rest of the app.** The process that made the change serves it before it answers, and every other process [within a second](#how-a-change-reaches-every-process). While a deploy freezes writes, a create answers as any write does then.
|
|
121
|
+
|
|
122
|
+
Building an index holds the table's write lock, and every statement runs within the request's statement timeout (1 s by default). <gap>Adding a field to a table with very many records cannot finish when building its index takes longer than the statement timeout, because building it outside the request is not implemented; the field is then not added.</gap>
|