@protobase/cli 0.67.4 → 0.67.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/package.js +1 -1
- package/dist/config/user-tables-config.js +4 -3
- package/dist/eject/read-ejected.js +3 -1
- package/dist/scaffold/data-file.js +9 -1
- package/dist/scaffold/organization-tables.js +21 -0
- package/dist/scaffold/plan.js +6 -2
- package/dist/scaffold/run.js +2 -0
- package/package.json +7 -7
- package/skills/protobase/references/features/user-tables.md +1 -0
- package/skills/protobase/references/reference/auth.md +3 -3
- package/skills/protobase/references/reference/cli.md +1 -1
- package/skills/protobase/references/reference/config/api.md +1 -1
- package/skills/protobase/references/reference/config/sign-in.md +1 -1
- package/skills/protobase/references/reference/user-tables.md +24 -10
- package/skills/protobase/references/reference/versioning.md +1 -1
package/dist/cli/package.js
CHANGED
|
@@ -2,13 +2,14 @@
|
|
|
2
2
|
/** Whether the app keeps tables its users add: the one reading of `userTables`, after `checkUserTables`. */
|
|
3
3
|
const userTablesOn = (config) => config.userTables === true;
|
|
4
4
|
/**
|
|
5
|
-
* Refuses a `userTables` that is no boolean, and user tables
|
|
6
|
-
*
|
|
5
|
+
* Refuses a `userTables` that is no boolean, and user tables in an API that trusts the organization tokens of an auth
|
|
6
|
+
* server elsewhere (`authenticate.organizations` without `auth.organizations`): it cannot read who owns or manages an
|
|
7
|
+
* organization, which decides who sees its tables. With the app's own organizations, each keeps its own tables.
|
|
7
8
|
*/
|
|
8
9
|
const checkUserTables = (config) => {
|
|
9
10
|
const value = config.userTables;
|
|
10
11
|
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
|
|
12
|
+
if (userTablesOn(config) && config.authenticate?.organizations && !(config.auth && config.auth.organizations)) throw new Error("userTables and authenticate.organizations are both on without auth.organizations; tables users add need the app's own organizations, so turn one of them off");
|
|
12
13
|
return config;
|
|
13
14
|
};
|
|
14
15
|
//#endregion
|
|
@@ -7,7 +7,8 @@ import { userTableResources, userTablesSchema } from "@protobase/server/config-e
|
|
|
7
7
|
const notServed = (name, problems) => problems.find((problem) => problem.startsWith(`The user table "${name}" `)) ?? `No table users added is named "${name}"`;
|
|
8
8
|
/**
|
|
9
9
|
* Each named table users added as the server serves it, with the constraints and indexes of its table, read from the
|
|
10
|
-
* database at `url` in read-only sessions. A name the server serves no table of stops it,
|
|
10
|
+
* database at `url` in read-only sessions. A name the server serves no table of, or an organization's table, stops it,
|
|
11
|
+
* with why.
|
|
11
12
|
*/
|
|
12
13
|
const readEjected = async (url, names) => {
|
|
13
14
|
const sql = connect(url);
|
|
@@ -17,6 +18,7 @@ const readEjected = async (url, names) => {
|
|
|
17
18
|
const tables = names.map((name) => {
|
|
18
19
|
const definition = served.definitions.find((each) => each.name === name);
|
|
19
20
|
if (!definition) throw new Error(notServed(name, served.problems));
|
|
21
|
+
if (definition.organization_id !== null) throw new Error(`"${name}" belongs to an organization: take it over with \`protobase scaffold\`, which keeps its records in it`);
|
|
20
22
|
return {
|
|
21
23
|
table: relations.find((each) => each.schema === userTablesSchema && each.name === name),
|
|
22
24
|
definition,
|
|
@@ -58,5 +58,13 @@ const makeLiveInDataFile = (text) => {
|
|
|
58
58
|
chain.chainTop.replaceWithText(`${chain.chainTop.getText()}\n .live()`);
|
|
59
59
|
return source.getFullText();
|
|
60
60
|
};
|
|
61
|
+
const addTenantToDataFile = (text, field) => {
|
|
62
|
+
const source = parseSource(text);
|
|
63
|
+
const chain = resourceChain(source);
|
|
64
|
+
if (!chain) throw new Error("data.ts has no resource(...) builder chain to scope to its tenant");
|
|
65
|
+
if (methodCalls(chain.chainTop, "tenant").length > 0) return text;
|
|
66
|
+
chain.chainTop.replaceWithText(`${chain.chainTop.getText()}\n .tenant((r) => r.${field})`);
|
|
67
|
+
return source.getFullText();
|
|
68
|
+
};
|
|
61
69
|
//#endregion
|
|
62
|
-
export { addFieldsToDataFile, makeLiveInDataFile, renderDataFile };
|
|
70
|
+
export { addFieldsToDataFile, addTenantToDataFile, makeLiveInDataFile, renderDataFile };
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { definitionsSchema, userTablesSchema } from "@protobase/server/config-exports";
|
|
2
|
+
//#region src/scaffold/organization-tables.ts
|
|
3
|
+
/**
|
|
4
|
+
* The tables users add that belong to an organization, as `schema.table` with the organization's id, from their
|
|
5
|
+
* definitions: none when the database keeps no definitions, or none made with organizations.
|
|
6
|
+
*/
|
|
7
|
+
const readOrganizationTables = async (sql) => {
|
|
8
|
+
const [found] = await sql`select exists (select from information_schema.columns
|
|
9
|
+
where table_schema = ${definitionsSchema} and table_name = 'tables' and column_name = 'organization_id') as organizations`;
|
|
10
|
+
if (!found?.organizations) return /* @__PURE__ */ new Map();
|
|
11
|
+
const rows = await sql`select table_name, organization_id
|
|
12
|
+
from ${sql(definitionsSchema)}.tables where organization_id is not null`;
|
|
13
|
+
return new Map(rows.map((row) => [`${userTablesSchema}.${row.table_name}`, row.organization_id]));
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* What scaffold says when it takes over an organization's table: whose definition it took, that the records keep their
|
|
17
|
+
* organization, and that the access rule the server gave the table does not come along.
|
|
18
|
+
*/
|
|
19
|
+
const takenOverLine = (table, organizationId) => `${table}: taken over from the definition of organization ${organizationId}; its records keep their organization through .tenant((r) => r.organization_id). Served from its definition, only that organization's owners and admins reached it; the resource in code follows the project's access rules instead, so add .access(...) to keep it to them`;
|
|
20
|
+
//#endregion
|
|
21
|
+
export { readOrganizationTables, takenOverLine };
|
package/dist/scaffold/plan.js
CHANGED
|
@@ -3,8 +3,9 @@ import { keyColumns } from "../db/keys.js";
|
|
|
3
3
|
import { protobaseSchemas } from "./protobase-tables.js";
|
|
4
4
|
import { camelCase } from "../infer/names.js";
|
|
5
5
|
import { findField } from "./existing-config.js";
|
|
6
|
+
import { takenOverLine } from "./organization-tables.js";
|
|
6
7
|
import { proposeField } from "../infer/proposal.js";
|
|
7
|
-
import { addFieldsToDataFile, makeLiveInDataFile, renderDataFile } from "./data-file.js";
|
|
8
|
+
import { addFieldsToDataFile, addTenantToDataFile, makeLiveInDataFile, renderDataFile } from "./data-file.js";
|
|
8
9
|
import { detectDrift } from "./drift.js";
|
|
9
10
|
import { ensureExports } from "./index-file.js";
|
|
10
11
|
import { assignNames } from "./resource-names.js";
|
|
@@ -96,8 +97,11 @@ const planScaffold = async (input) => {
|
|
|
96
97
|
const clash = decisions.find((d) => current.fields.some((f) => f.name === d.proposal.name));
|
|
97
98
|
if (clash) throw new Error(`${key}: field "${clash.proposal.name}" already exists, edit the name`);
|
|
98
99
|
const file = path.join(configDir, current.dir, "data.ts");
|
|
100
|
+
const organization = input.organizationTables?.get(key);
|
|
99
101
|
const fielded = addFieldsToDataFile(current.text, decisions);
|
|
100
|
-
|
|
102
|
+
const scoped = organization === void 0 ? fielded : addTenantToDataFile(fielded, "organization_id");
|
|
103
|
+
if (organization !== void 0) warnings.push(takenOverLine(key, organization));
|
|
104
|
+
changes.push(...change(file, current.text, isLive(key) ? makeLiveInDataFile(scoped) : scoped));
|
|
101
105
|
if (isLive(key)) live.push(key);
|
|
102
106
|
drift.push(...detectDrift(current, table, context(table)));
|
|
103
107
|
await writeUi(current.dir, current.name, current.identifier, table, current, decisions);
|
package/dist/scaffold/run.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { connect } from "../db/connect.js";
|
|
2
2
|
import { introspect } from "../db/introspect.js";
|
|
3
3
|
import { readExistingResources } from "./existing-config.js";
|
|
4
|
+
import { readOrganizationTables } from "./organization-tables.js";
|
|
4
5
|
import { parseTarget } from "./targets.js";
|
|
5
6
|
import { planScaffold } from "./plan.js";
|
|
6
7
|
import { acceptAll, interactive } from "./prompt.js";
|
|
@@ -31,6 +32,7 @@ const runScaffold = async (options, out) => {
|
|
|
31
32
|
target: parseTarget(options.target),
|
|
32
33
|
excludes: options.excludes,
|
|
33
34
|
live: options.live,
|
|
35
|
+
organizationTables: await readOrganizationTables(sql),
|
|
34
36
|
decide: options.decide ?? prompter?.decide ?? acceptAll,
|
|
35
37
|
ui: options.ui,
|
|
36
38
|
readText
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@protobase/cli",
|
|
3
|
-
"version": "0.67.
|
|
3
|
+
"version": "0.67.6",
|
|
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.6",
|
|
47
|
+
"@protobase/layout": "0.67.6",
|
|
48
|
+
"@protobase/query": "0.67.6",
|
|
49
|
+
"@protobase/schema": "0.67.6",
|
|
50
|
+
"@protobase/server": "0.67.6",
|
|
51
|
+
"@protobase/ui": "0.67.6",
|
|
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",
|
|
@@ -7,3 +7,4 @@ Tables an app's users add from the admin UI, by hand or from a file, and how a d
|
|
|
7
7
|
- [Tables defined in the database](../reference/user-tables.md#tables-defined-in-the-database) (beta): An app can keep tables its users add in its own database, beside the resources in its code. Each one is served like any other resource, at its own address with its list, record page, search and API. Every server process serves a new table within a second of it being added.
|
|
8
8
|
- [Adding tables and fields](../reference/user-tables.md#adding-tables-and-fields) (beta): Someone with the right capability creates a table with its fields, or adds a field to it, through the app's API. It is created in the database at once, with an index for every field but JSON so its list can be filtered by it, and sorted by it unless it is long text, and only admins can see it until access is widened.
|
|
9
9
|
- [Taking a table over as code](../reference/cli.md#eject) (beta): `protobase eject` turns a table someone added in the admin app into ordinary config and a migration in the project, so a developer can customise it like any other resource. Its records stay where they are.
|
|
10
|
+
- [Tables per organization](../reference/user-tables.md#organizations) (beta): In an app with organizations, a table someone adds belongs to their organization. Only its owners and admins, and superusers working in it, see the table and its records.
|
|
@@ -636,13 +636,13 @@ auth: {
|
|
|
636
636
|
|
|
637
637
|
It is Better Auth's organization plugin, with its tables named in snake_case (`organization`, `member`, `invitation`, `session.active_organization_id`); every store has them, and `user.last_organization_id`, with organizations on or off (see [migrations](#migrations)).
|
|
638
638
|
|
|
639
|
-
**Two kinds of role.** Every membership has an organization role, Better Auth's `owner`, `admin` or `member`, which decides who manages the organization: owners and admins invite, remove and change members and edit its details; only owners hand it over, delete it or make owners. App roles, the project's `roles`, decide what the app shows and allows. A user holds them **globally** (the admin plugin's `role` column, as without organizations), which applies in every organization, or **per membership** (`member.app_roles`), which applies there only. A request's role, `ctx.user.roles`, is the session's [active role](#active-role), of either kind; access rules
|
|
639
|
+
**Two kinds of role.** Every membership has an organization role, Better Auth's `owner`, `admin` or `member`, which decides who manages the organization: owners and admins invite, remove and change members and edit its details; only owners hand it over, delete it or make owners. App roles, the project's `roles`, decide what the app shows and allows. A user holds them **globally** (the admin plugin's `role` column, as without organizations), which applies in every organization, or **per membership** (`member.app_roles`), which applies there only. A request's role, `ctx.user.roles`, is the session's [active role](#active-role), of either kind; access rules see the organization role as `ctx.organization.role`, read from the membership on each request, absent for someone who is no member there, such as a superuser who entered it. `admin` is the superuser: global only, it holds every role. With organizations every role needs a label; `owner` and `member` cannot be app role names, and `user` stands for an account without a global role.
|
|
640
640
|
|
|
641
641
|
**Who gives which role.** Someone may give or take away a member's app role only if they hold it, in that organization or globally, or hold a role whose `grants` list it (`manager` above gives `accountant` without holding it); grants do not chain, and a superuser may give any. Only a superuser changes global roles. The creator of an organization becomes its owner with every app role (`creatorAppRoles`, default `'all'`), so they can give them on; for a role added later, list it in a role owners hold, or run `protobase organizations add-app-role <role> --to owners`.
|
|
642
642
|
|
|
643
643
|
**The organization in the token.** A new session starts in the organization the person last worked in, else their oldest membership. `/api/auth/token` adds `org` (the organization), `org_role` and `app_roles` (the membership's, narrowed to the active role), read when the token is issued; a removed member's token, or one carrying an app role taken away, [stops working](#when-a-token-stops-working) on its next request. The app's authenticator makes `org` the tenant and `ctx.organization.id`, and adds `app_roles` to the global roles. `POST /api/auth/organization/switch` with `{ organizationId }` moves the session; the next token names the new organization, without signing in again. Someone with a global role may switch into any organization, without being a member, and sees there what those roles allow; each such entry is an `organization.entered` [audit event](api.md#audit-events). `GET /api/auth/organization/search?query=&offset=` lists every organization for them, ordered by slug, 20 at a time with the `total`, narrowed by text in the name or slug; the Organizations page shows it as All organizations. A list across every organization stays a resource without `.tenant`, readable by the global role; a list across the organizations someone is a member of is a `.tenant` resource with `across: 'memberships'` (see [Lists across organizations](access.md#lists-across-organizations)).
|
|
644
644
|
|
|
645
|
-
**Invitations.** Better Auth's `POST /api/auth/organization/invite-member` with `{ organizationId, email, role, appRoles }`; inviting again replaces the earlier invitation, and it works for `invitationDays` (default 7). The email links to
|
|
645
|
+
**Invitations.** Better Auth's `POST /api/auth/organization/invite-member` with `{ organizationId, email, role, appRoles }`; inviting again replaces the earlier invitation, and it works for `invitationDays` (default 7). The email links to `/-/invitation?token=…` on the origin the invitation was made on ([several origins](#several-origins)), or to `invitationUrl`, with a token signed with the app's `secret`, since members can read invitation ids; without a mailer, the answer carries the `link` to pass on. The link page shows `GET /api/auth/invitation?token=`, accepts with `POST /api/auth/invitation/accept` for someone signed in as the invited address, and makes an account with `POST /api/auth/invitation/sign-up` (`{ token, name, password? }`) for an address without one. Someone signed in sees the invitations for their own address with Better Auth's `GET /api/auth/organization/list-user-invitations`, on the organizations page, and accepts one there with `POST /api/auth/invitation/accept-own` (`{ invitationId }`). The link page and its sign-up serve [invitations to the app](#users-page) too. [Open sign-up](#open-sign-up) is not available with organizations: an invitation is the address someone vouched for.
|
|
646
646
|
|
|
647
647
|
**Members.** Better Auth's `list-members`, `update-member-role`, `remove-member` and `leave`; `POST /api/auth/organization/set-app-roles` (`{ memberId, appRoles }`); `POST /api/auth/organization/transfer-ownership` (`{ memberId, keepAs? }`) makes a member owner and the caller an admin in one step. `GET /api/auth/organization/current` answers the organization a session works in with the caller's roles there. `GET /api/auth/organization/people` answers the members of that organization to any of its members, as `{ people: [{ id, name, image? }] }` with `id` the user id and no email addresses; anyone who is not a member of it, signed out or with a global role only, is refused.
|
|
648
648
|
|
|
@@ -795,7 +795,7 @@ url: 'https://erp-x1.protobase.net',
|
|
|
795
795
|
auth: { origins: ['https://admin.shop.com'], passkeyDomain: 'admin.shop.com' },
|
|
796
796
|
```
|
|
797
797
|
|
|
798
|
-
- A request to one of them is answered for its own host: the sign-in callbacks, the [password reset](#password-reset) link and its `redirectTo`, and a [staff sign-in](#staff-sign-in) stay there. A request to any other host is answered for the app's `url`.
|
|
798
|
+
- A request to one of them is answered for its own host: the sign-in callbacks, the [password reset](#password-reset) link and its `redirectTo`, an invitation link, to the [app](#users-page) or an [organization](#organizations), unless `invitationUrl` names another page, and a [staff sign-in](#staff-sign-in) stay there. A request to any other host is answered for the app's `url`.
|
|
799
799
|
- Tokens keep the app's `url` as issuer and audience, whichever origin issued them, so the API takes them on every origin.
|
|
800
800
|
- Sessions are cookies of each host: a person signs in once per origin.
|
|
801
801
|
- `GET /.well-known/webauthn` answers `{ "origins": [...] }` with every origin of the app, on each of them, so its [passkeys](#passkeys) work on all. `protobase serve` and `protobase dev` send that path to the server, whatever the bundle's `api` says; a host in front must too.
|
|
@@ -146,7 +146,7 @@ Takes [tables users added](user-tables.md) over as code. The URL comes as for [`
|
|
|
146
146
|
- **`ui.ts`** has the definition's singular and plural label, its title field, each field's label, a long text field multiline, and the list `scaffold --ui` writes.
|
|
147
147
|
- **The migration** makes each table as the catalogue has it: `create schema if not exists`, `create extension if not exists pg_trgm` when an index needs it, `create table if not exists` with the columns and constraints, and `create index if not exists` for each index, by its name. On the database the table is in it changes nothing and moves no row; on an empty one it makes the same table.
|
|
148
148
|
|
|
149
|
-
Then it runs [`doctor`](#doctor) on the config. When doctor reports an error in an ejected resource, or cannot load the config, every file is put back as it was and the command fails. It refuses a name no definition the server serves has, saying why the server leaves it out, and one a resource in `config/` has, or whose table one reads, already.
|
|
149
|
+
Then it runs [`doctor`](#doctor) on the config. When doctor reports an error in an ejected resource, or cannot load the config, every file is put back as it was and the command fails. It refuses a name no definition the server serves has, saying why the server leaves it out, and one a resource in `config/` has, or whose table one reads, already. It refuses a table that belongs to an [organization](user-tables.md#organizations), which `scaffold` takes over instead.
|
|
150
150
|
|
|
151
151
|
```sh
|
|
152
152
|
protobase eject notes # config/notes/data.ts, config/notes/ui.ts, db/migrations/NNN_eject_notes.sql
|
|
@@ -178,7 +178,7 @@ export default defineConfig(({ env }) => ({
|
|
|
178
178
|
- **Type:** `boolean`
|
|
179
179
|
- **Default:** `false`
|
|
180
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.
|
|
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. With [organizations](../auth.md#organizations), each organization keeps its own tables; an API that trusts the organization tokens of an auth server elsewhere (`authenticate.organizations` without `auth.organizations`) cannot turn it on. See [User tables](../user-tables.md).
|
|
182
182
|
|
|
183
183
|
```ts
|
|
184
184
|
export default defineConfig({ userTables: true })
|
|
@@ -53,7 +53,7 @@ export default defineConfig({ auth: { roles: ['admin', 'sales', 'warehouse'], de
|
|
|
53
53
|
- **Type:** `true | { create?: 'admins' | 'everyone'; creatorAppRoles?: 'all' | string[]; invitationDays?: number; membershipLimit?: number; deleteRecords?: 'refuse' | 'cascade'; invitationUrl?: string }`
|
|
54
54
|
- **Default:** off: the app works as one organization
|
|
55
55
|
|
|
56
|
-
Turns on organizations: people work in one at a time, records of `.tenant` resources are scoped to it, and roles are held globally or per membership. `create` is who may create one (default `admins`), `creatorAppRoles` the roles its creator holds there (default `all`), `invitationDays` how long an invitation works (default 7), `membershipLimit` the most members (default 1000), `deleteRecords` whether deleting one is refused while it has records (`refuse`, the default) or left to the foreign keys (`cascade`), and `invitationUrl` the page an invitation opens (default
|
|
56
|
+
Turns on organizations: people work in one at a time, records of `.tenant` resources are scoped to it, and roles are held globally or per membership. `create` is who may create one (default `admins`), `creatorAppRoles` the roles its creator holds there (default `all`), `invitationDays` how long an invitation works (default 7), `membershipLimit` the most members (default 1000), `deleteRecords` whether deleting one is refused while it has records (`refuse`, the default) or left to the foreign keys (`cascade`), and `invitationUrl` the page an invitation opens (default `/-/invitation` on the origin the invitation was made on). Its hooks are in `auth/hooks.ts`. See [Organizations](../auth.md#organizations).
|
|
57
57
|
|
|
58
58
|
```ts
|
|
59
59
|
export default defineConfig({ auth: { organizations: { create: 'everyone', invitationDays: 14 } } })
|
|
@@ -9,13 +9,11 @@ An app's resources usually come from its code: a resource in `config/` over a ta
|
|
|
9
9
|
## Tables defined in the database
|
|
10
10
|
|
|
11
11
|
:::caution[Beta]
|
|
12
|
-
Tables are added through the API; the admin app has no screens for them yet
|
|
12
|
+
Tables are added through the API; the admin app has no screens for them yet.
|
|
13
13
|
:::
|
|
14
14
|
|
|
15
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
|
-
<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
17
|
<gap>Users cannot add a table from the admin app yet because its screens for tables and fields are not implemented.</gap>
|
|
20
18
|
|
|
21
19
|
### The switch
|
|
@@ -26,7 +24,7 @@ User tables are off by default. Turn them on in `protobase.config.ts`:
|
|
|
26
24
|
export default defineConfig({ userTables: true })
|
|
27
25
|
```
|
|
28
26
|
|
|
29
|
-
`userTables` takes only `true` or `false`.
|
|
27
|
+
`userTables` takes only `true` or `false`. With [organizations](#organizations), each organization keeps its own tables. An API that trusts the organization tokens of an auth server elsewhere (`authenticate.organizations` without `auth.organizations`) cannot read who manages an organization, so there `dev`, `build` and `serve` stop with an error that names both settings.
|
|
30
28
|
|
|
31
29
|
### The schemas and the migration
|
|
32
30
|
|
|
@@ -34,8 +32,8 @@ The tables users add and their definitions live in two schemas of the app's data
|
|
|
34
32
|
|
|
35
33
|
| Schema | What it holds |
|
|
36
34
|
| --- | --- |
|
|
37
|
-
| `table_definitions` | `tables`, one row per table users add, with its singular and plural label, its title field
|
|
38
|
-
| `user_tables` | the tables themselves, each with an `id bigint generated always as identity` primary key and a column per field |
|
|
35
|
+
| `table_definitions` | `tables`, one row per table users add, with its singular and plural label, its title field, a version, 1 when it is added, its organization with organizations, and its table's name, and `fields`, each table's fields in order with their label, type and whether they are required |
|
|
36
|
+
| `user_tables` | the tables themselves, each with an `id bigint generated always as identity` primary key and a column per field, and with organizations an `organization_id` column |
|
|
39
37
|
|
|
40
38
|
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
39
|
|
|
@@ -56,7 +54,7 @@ A table's name and each field's name are lowercase letters, digits and `_`, star
|
|
|
56
54
|
|
|
57
55
|
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
56
|
|
|
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.
|
|
57
|
+
- **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. With organizations, [its organization's owners and admins](#organizations).
|
|
60
58
|
- **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
59
|
- **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
60
|
- **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.
|
|
@@ -94,7 +92,7 @@ With `userTables: true`, the API has two resources over the definitions, with th
|
|
|
94
92
|
|
|
95
93
|
| Resource | A record | Fields |
|
|
96
94
|
| --- | --- | --- |
|
|
97
|
-
| `user_tables` | a table, at `/user_tables/<id>` | `name`, `singular_label`, `plural_label`, `title_field`; `id` and `
|
|
95
|
+
| `user_tables` | a table, at `/user_tables/<id>` | `name`, `singular_label`, `plural_label`, `title_field`; `id`, `version` and `table_name`, the name of its table and its address, are read-only |
|
|
98
96
|
| `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
97
|
|
|
100
98
|
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:
|
|
@@ -111,10 +109,10 @@ POST /api/v1:batchWrite
|
|
|
111
109
|
}
|
|
112
110
|
```
|
|
113
111
|
|
|
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`.
|
|
112
|
+
The answer has the table's record, so a client links to the new table at its `table_name`, `/notes`. Add a field later with `POST /api/v1/user_table_fields`.
|
|
115
113
|
|
|
116
114
|
- **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 `
|
|
115
|
+
- **What is refused.** A name outside the rules above, a word SQL reserves such as `select`, a field named `id`, `etag` or `organization_id`, 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
116
|
- **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
117
|
- **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
118
|
- **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.
|
|
@@ -139,3 +137,19 @@ When a table needs more than the admin app gives it, such as a validation rule,
|
|
|
139
137
|
Once the code serves, the table takes no new field from the admin app, and a new table cannot take its name. The resource starts as the admins' alone, as it was served; widen its access in `data.ts` as for any resource.
|
|
140
138
|
|
|
141
139
|
<gap>A table cannot be taken over from the admin app or the assistant because an eject that writes to the project's repository is not implemented.</gap>
|
|
140
|
+
|
|
141
|
+
## Organizations
|
|
142
|
+
|
|
143
|
+
:::caution[Beta]
|
|
144
|
+
A table belongs to one organization and is never shared with another. Only the organization's owners and admins reach it; access cannot be widened to its other members yet.
|
|
145
|
+
:::
|
|
146
|
+
|
|
147
|
+
In an app with organizations, a table someone adds belongs to their organization. Only its owners and admins, and superusers working in it, see the table and its records.
|
|
148
|
+
|
|
149
|
+
- **Who makes and sees them.** The owners and admins of the organization a session works in, as its [organization role](auth.md#organizations) stands on that request, and superusers working in it. They make a table, add its fields and work with its records as [above](#adding-tables-and-fields); the project's roles and their capabilities do not widen it. Anyone else, in another organization or in none, gets not found at its address, and does not see it in `/meta`, the sidebar, search, the API document or the assistant. A member who is demoted or removed loses it on their next request. <gap>Members of an organization other than its owners and admins cannot see its tables because widening their access is not implemented.</gap>
|
|
150
|
+
- **Its records.** Each table is a `.tenant` resource over its `organization_id` column, as [organizations](auth.md#organizations) describe: the server sets it on every record from the session, a request body cannot, and every query reads only the organization's own.
|
|
151
|
+
- **Names.** A name is unique within an organization, so two organizations can each have a `contacts` table. Its table, and its address, is `t<id>_<name>`, such as `/t12_contacts`, with `<id>` the definition's; the labels the app shows are the ones it was given. With organizations a table's name is at most 42 bytes, and may be the name of a resource in code.
|
|
152
|
+
- **Taking one over as code.** `protobase eject` refuses an organization's table, as the resource it writes would not keep its records in the organization. Declare a resource over its table instead, such as `resource('t12_contacts').table('user_tables.t12_contacts')`, and run [`protobase scaffold`](cli.md#scaffold): it adds the fields and `.tenant((r) => r.organization_id)`, so the records stay in their organization, and prints which organization's definition it took. The resource in code follows the project's access rules, not the owners-and-admins rule the definition had, so give it the `.access(...)` it needs.
|
|
153
|
+
- **A table made before organizations.** One added while the app had no organizations belongs to none, and stays its superusers' alone.
|
|
154
|
+
|
|
155
|
+
Every process serves the tables of every organization, so [a rebuild](#how-a-change-reaches-every-process) follows a change in any organization, and the times measured there count the tables of all organizations together.
|
|
@@ -49,7 +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
|
+
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, and an unset token bumps nothing with a warning in the run's summary that counts the repositories left unbumped. 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.
|
|
53
53
|
|
|
54
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.
|
|
55
55
|
|