@protobase/server 0.67.5 → 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/better-auth/authenticator.js +16 -5
- package/dist/better-auth/token-standing.js +24 -12
- package/dist/create-admin.d.ts +7 -2
- package/dist/create-admin.js +1 -1
- package/dist/package.js +1 -1
- package/dist/types.d.ts +5 -1
- package/dist/user-tables/admin-user-tables.js +5 -3
- package/dist/user-tables/check-definition.js +9 -5
- package/dist/user-tables/definition-resource.d.ts +1 -0
- package/dist/user-tables/definition-resource.js +17 -8
- package/dist/user-tables/definitions-hook.js +9 -7
- package/dist/user-tables/definitions-resources.js +45 -26
- package/dist/user-tables/names.js +12 -6
- package/dist/user-tables/organization-access.js +14 -0
- package/dist/user-tables/owned-definitions.d.ts +4 -3
- package/dist/user-tables/owned-definitions.js +6 -5
- package/dist/user-tables/read-definitions.d.ts +6 -1
- package/dist/user-tables/read-definitions.js +1 -1
- package/dist/user-tables/schema-sql.js +37 -6
- package/dist/user-tables/schema.d.ts +2 -1
- package/dist/user-tables/schema.js +12 -7
- package/dist/user-tables/table-ddl.js +7 -2
- package/package.json +4 -4
|
@@ -47,11 +47,18 @@ const betterAuthAuthenticator = (options) => {
|
|
|
47
47
|
const verify = async (request) => {
|
|
48
48
|
const { payload, session } = await sessions(request);
|
|
49
49
|
if (!auth) return session;
|
|
50
|
-
const signIn = await standing(await auth.$context, payload, session);
|
|
51
|
-
|
|
50
|
+
const { signIn, organizationRole } = await standing(await auth.$context, payload, session);
|
|
51
|
+
const withRole = organizationRole && session.tenant !== void 0 ? {
|
|
52
52
|
...session,
|
|
53
|
-
|
|
53
|
+
organization: {
|
|
54
|
+
id: session.tenant,
|
|
55
|
+
role: organizationRole
|
|
56
|
+
}
|
|
54
57
|
} : session;
|
|
58
|
+
return signIn ? {
|
|
59
|
+
...withRole,
|
|
60
|
+
signIn
|
|
61
|
+
} : withRole;
|
|
55
62
|
};
|
|
56
63
|
if (!organizations) return verify;
|
|
57
64
|
return async (request) => {
|
|
@@ -65,7 +72,10 @@ const betterAuthAuthenticator = (options) => {
|
|
|
65
72
|
return {
|
|
66
73
|
...session,
|
|
67
74
|
user,
|
|
68
|
-
...session.tenant !== void 0 && { organization: {
|
|
75
|
+
...session.tenant !== void 0 && { organization: {
|
|
76
|
+
id: session.tenant,
|
|
77
|
+
...session.organization?.role && { role: session.organization.role }
|
|
78
|
+
} },
|
|
69
79
|
...across
|
|
70
80
|
};
|
|
71
81
|
};
|
|
@@ -81,7 +91,8 @@ const betterAuthAuthenticator = (options) => {
|
|
|
81
91
|
issuedAt: payload.iat
|
|
82
92
|
});
|
|
83
93
|
const { sessionId: _, ...claims } = tokenClaims(payload, session.user.roles, organizations ? session.tenant : void 0);
|
|
84
|
-
await checkUserStanding(context, claims, organizations);
|
|
94
|
+
const organizationRole = await checkUserStanding(context, claims, organizations);
|
|
95
|
+
return organizationRole ? { organizationRole } : {};
|
|
85
96
|
};
|
|
86
97
|
return Object.assign(withTrustedApps(own, issuer, options.apps ?? []), { forAudience: (audience) => sessionOf({
|
|
87
98
|
audience,
|
|
@@ -2,7 +2,7 @@ import { unauthorized } from "../problem.js";
|
|
|
2
2
|
import { noGlobalRole } from "./organizations/role-definitions.js";
|
|
3
3
|
import { parseRoles } from "./parse-roles.js";
|
|
4
4
|
import { effectiveRoles, globalRoles } from "./organizations/app-roles.js";
|
|
5
|
-
import { findMember, findOrganization } from "./organizations/store.js";
|
|
5
|
+
import { findMember, findOrganization, organizationRoleOf } from "./organizations/store.js";
|
|
6
6
|
import { isStaffSession } from "./staff-checks.js";
|
|
7
7
|
import { strongSignInAt } from "./strong-sign-ins.js";
|
|
8
8
|
//#region src/better-auth/token-standing.ts
|
|
@@ -23,20 +23,24 @@ const tokenClaims = (payload, roles, tenant) => ({
|
|
|
23
23
|
const revoked = () => unauthorized("The bearer token was revoked: get a new one");
|
|
24
24
|
const bannedNow = (user, now) => Boolean(user.banned) && (!user.banExpires || user.banExpires > now);
|
|
25
25
|
const heldWhere = async ({ adapter }, user, organizations, organizationId) => {
|
|
26
|
-
if (!organizations) return parseRoles(user.role);
|
|
26
|
+
if (!organizations) return { roles: parseRoles(user.role) };
|
|
27
27
|
const global = globalRoles(user.role);
|
|
28
|
-
if (!organizationId) return global;
|
|
28
|
+
if (!organizationId) return { roles: global };
|
|
29
29
|
const member = await findMember(adapter, {
|
|
30
30
|
organizationId,
|
|
31
31
|
userId: user.id
|
|
32
32
|
});
|
|
33
|
-
if (member) return
|
|
34
|
-
|
|
33
|
+
if (member) return {
|
|
34
|
+
roles: effectiveRoles(global, member.appRoles),
|
|
35
|
+
organizationRole: organizationRoleOf(member)
|
|
36
|
+
};
|
|
37
|
+
return global.length > 0 && await findOrganization(adapter, organizationId) !== null ? { roles: global } : void 0;
|
|
35
38
|
};
|
|
36
39
|
/**
|
|
37
40
|
* Whether a token's user still stands, read from the store: they exist and are not banned, and each role the token
|
|
38
41
|
* carries is one they still hold where it works. The part of `checkTokenStanding` an MCP access token gets, whose
|
|
39
|
-
* grant outlives the browser session that made it.
|
|
42
|
+
* grant outlives the browser session that made it. Answers their organization role where the token works, as the
|
|
43
|
+
* store holds it now, `undefined` for someone who is no member there.
|
|
40
44
|
*/
|
|
41
45
|
const checkUserStanding = async (context, claims, organizations, now = /* @__PURE__ */ new Date()) => {
|
|
42
46
|
const user = await context.adapter.findOne({
|
|
@@ -48,14 +52,16 @@ const checkUserStanding = async (context, claims, organizations, now = /* @__PUR
|
|
|
48
52
|
});
|
|
49
53
|
if (!user || bannedNow(user, now)) throw revoked();
|
|
50
54
|
const held = await heldWhere(context, user, organizations, claims.organizationId);
|
|
51
|
-
if (!held || claims.roles.some((role) => !held.includes(role))) throw revoked();
|
|
55
|
+
if (!held || claims.roles.some((role) => !held.roles.includes(role))) throw revoked();
|
|
56
|
+
return held.organizationRole;
|
|
52
57
|
};
|
|
53
58
|
/**
|
|
54
59
|
* Whether a token still stands, read from the store on every request: its session is not signed out or expired, and
|
|
55
60
|
* its user still stands (`checkUserStanding`). A token that fails is a 401, so the client asks its session for a new
|
|
56
61
|
* one, which carries the roles as they are now or is refused. Answers the sign-in the token's session stands for
|
|
57
62
|
* (`signInOf`), `undefined` for a token without one; the user's own one also reads, when asked, when it last proved a
|
|
58
|
-
* passkey or a second step (`strongSignInAt`).
|
|
63
|
+
* passkey or a second step (`strongSignInAt`). Answers it with the user's organization role where the token works
|
|
64
|
+
* (`checkUserStanding`).
|
|
59
65
|
*/
|
|
60
66
|
const checkTokenStanding = async (context, claims, organizations, now = /* @__PURE__ */ new Date()) => {
|
|
61
67
|
const session = claims.sessionId === void 0 ? void 0 : await context.adapter.findOne({
|
|
@@ -66,12 +72,18 @@ const checkTokenStanding = async (context, claims, organizations, now = /* @__PU
|
|
|
66
72
|
}]
|
|
67
73
|
});
|
|
68
74
|
if (claims.sessionId !== void 0 && (!session || session.userId !== claims.userId || session.expiresAt <= now)) throw revoked();
|
|
69
|
-
await checkUserStanding(context, claims, organizations, now);
|
|
75
|
+
const organizationRole = await checkUserStanding(context, claims, organizations, now);
|
|
70
76
|
const signIn = session ? signInOf(session, claims.sessionId) : void 0;
|
|
71
|
-
if (!signIn || signIn.staff) return
|
|
77
|
+
if (!signIn || signIn.staff) return {
|
|
78
|
+
signIn,
|
|
79
|
+
...organizationRole && { organizationRole }
|
|
80
|
+
};
|
|
72
81
|
return {
|
|
73
|
-
|
|
74
|
-
|
|
82
|
+
signIn: {
|
|
83
|
+
...signIn,
|
|
84
|
+
strongAt: () => strongSignInAt(context.internalAdapter, claims.sessionId)
|
|
85
|
+
},
|
|
86
|
+
...organizationRole && { organizationRole }
|
|
75
87
|
};
|
|
76
88
|
};
|
|
77
89
|
//#endregion
|
package/dist/create-admin.d.ts
CHANGED
|
@@ -27,8 +27,13 @@ import { UpgradeWebSocket } from "hono/ws";
|
|
|
27
27
|
export type CreateAdminInput = {
|
|
28
28
|
resources: ResourceSource[];
|
|
29
29
|
views?: ViewSource[];
|
|
30
|
-
/**
|
|
31
|
-
|
|
30
|
+
/**
|
|
31
|
+
* The tables users add, from their definitions (`userTableResources`): one a resource here or a built-in names is left
|
|
32
|
+
* out with a warning. `organizations`, by default whether `auth` has them, keeps each definition in its organization.
|
|
33
|
+
*/
|
|
34
|
+
userTables?: UserTableSources & {
|
|
35
|
+
organizations?: boolean;
|
|
36
|
+
};
|
|
32
37
|
/** The `userMenu(...)` export of the config module: pages under the signed-in user in the sidebar. */
|
|
33
38
|
userMenu?: UserMenuSource;
|
|
34
39
|
/** The `bottomBar(...)` exports of the config module, in order: tabs on phones for the roles each names. */
|
package/dist/create-admin.js
CHANGED
|
@@ -138,7 +138,7 @@ const createAdmin = ({ resources: appResources, views: appViews = [], userTables
|
|
|
138
138
|
notifications: notifications !== void 0,
|
|
139
139
|
...events && { events }
|
|
140
140
|
});
|
|
141
|
-
const served = adminUserTables(userTables, [...appModels, ...builtIn.resources.map((resource) => resource.toModel())].map((model) => model.name), options.roles !== void 0);
|
|
141
|
+
const served = adminUserTables(userTables, [...appModels, ...builtIn.resources.map((resource) => resource.toModel())].map((model) => model.name), options.roles !== void 0, userTables?.organizations ?? Boolean(auth?.organizations));
|
|
142
142
|
for (const warning of served.warnings) console.warn(warning);
|
|
143
143
|
const resources = [
|
|
144
144
|
...appResources,
|
package/dist/package.js
CHANGED
package/dist/types.d.ts
CHANGED
|
@@ -11,9 +11,13 @@ export type User = {
|
|
|
11
11
|
id: string | number;
|
|
12
12
|
roles: string[];
|
|
13
13
|
};
|
|
14
|
-
/**
|
|
14
|
+
/**
|
|
15
|
+
* The organization a request works in, with organizations on: the same value as `tenant`, and the caller's organization
|
|
16
|
+
* role there, read from the store for this request; absent for someone who is no member there.
|
|
17
|
+
*/
|
|
15
18
|
export type SessionOrganization = {
|
|
16
19
|
id: TenantValue;
|
|
20
|
+
role?: 'owner' | 'admin' | 'member';
|
|
17
21
|
};
|
|
18
22
|
/** A tenant the person is a member of, with the roles they act with there. */
|
|
19
23
|
export type Membership = {
|
|
@@ -5,9 +5,10 @@ import { servedUserTables } from "./served-user-tables.js";
|
|
|
5
5
|
/**
|
|
6
6
|
* What the tables users add bring to the admin, with `userTables` on: the resources over their definitions, through
|
|
7
7
|
* which they are created, each user table a name in code (`codeNames`, the app's and Protobase's) does not take, and the
|
|
8
|
-
* hook that creates their tables and columns. Without `tables`, nothing.
|
|
8
|
+
* hook that creates their tables and columns. Without `tables`, nothing. With `organizations`, each definition belongs
|
|
9
|
+
* to the organization it was made in (`definitionsResources`).
|
|
9
10
|
*/
|
|
10
|
-
const adminUserTables = (tables, codeNames, roles) => {
|
|
11
|
+
const adminUserTables = (tables, codeNames, roles, organizations = false) => {
|
|
11
12
|
if (!tables) return {
|
|
12
13
|
resources: [],
|
|
13
14
|
views: [],
|
|
@@ -16,7 +17,8 @@ const adminUserTables = (tables, codeNames, roles) => {
|
|
|
16
17
|
};
|
|
17
18
|
const definitions = definitionsResources({
|
|
18
19
|
roles,
|
|
19
|
-
taken: codeNames
|
|
20
|
+
taken: codeNames,
|
|
21
|
+
organizations
|
|
20
22
|
});
|
|
21
23
|
const served = servedUserTables(tables, [
|
|
22
24
|
...codeNames,
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { definitionColumnTypes, definitionFieldTypes, isUserTableName, userTablesSchema } from "./names.js";
|
|
1
|
+
import { definitionColumnTypes, definitionFieldTypes, isUserTableName, organizationColumn, userTablesSchema } from "./names.js";
|
|
2
2
|
//#region src/user-tables/check-definition.ts
|
|
3
3
|
const reservedFields = ["id", "etag"];
|
|
4
4
|
const isFieldType = (type) => definitionFieldTypes.includes(type);
|
|
5
|
-
const fieldProblem = (field, columns) => {
|
|
5
|
+
const fieldProblem = (field, columns, organization) => {
|
|
6
6
|
if (!isUserTableName(field.name)) return `the field name ${JSON.stringify(field.name)} is not lowercase letters, digits and "_", starting with a letter, at most 63 bytes`;
|
|
7
|
-
if (reservedFields.includes(field.name)) return `the field name "${field.name}" is reserved`;
|
|
7
|
+
if (reservedFields.includes(field.name) || organization && field.name === "organization_id") return `the field name "${field.name}" is reserved`;
|
|
8
8
|
if (!isFieldType(field.type)) return `the field "${field.name}" has the type ${JSON.stringify(field.type)}, which is not one of ${definitionFieldTypes.join(", ")}`;
|
|
9
9
|
if (field.type === "decimal" && (field.precision === null || field.scale === null)) return `the decimal field "${field.name}" has no precision and scale`;
|
|
10
10
|
const column = columns.get(field.name);
|
|
@@ -15,7 +15,8 @@ const fieldProblem = (field, columns) => {
|
|
|
15
15
|
/**
|
|
16
16
|
* The definition checked against the user tables schema, or the first thing wrong with it: every name a safe
|
|
17
17
|
* identifier, every type one of the eight, its table there with an `id bigint` primary key and a column of the right
|
|
18
|
-
* type for each field, and its title a text field of it.
|
|
18
|
+
* type for each field, and its title a text field of it. An organization's table also has its `organization_id text
|
|
19
|
+
* not null` column, which `.tenant` scopes its records by.
|
|
19
20
|
*/
|
|
20
21
|
const checkDefinition = (definition, tables) => {
|
|
21
22
|
const problem = (text) => ({ problem: `The user table ${JSON.stringify(definition.name)} is not served: ${text}.` });
|
|
@@ -24,11 +25,14 @@ const checkDefinition = (definition, tables) => {
|
|
|
24
25
|
if (!columns) return problem(`the table ${userTablesSchema}.${definition.name} does not exist`);
|
|
25
26
|
const id = columns.get("id");
|
|
26
27
|
if (!id || id.type !== "bigint" || !id.key) return problem(`${userTablesSchema}.${definition.name} has no "id" bigint primary key`);
|
|
28
|
+
const organization = definition.organization_id !== null;
|
|
29
|
+
const tenant = columns.get(organizationColumn);
|
|
30
|
+
if (organization && (!tenant || tenant.type !== "text" || !tenant.notNull)) return problem(`${userTablesSchema}.${definition.name} has no "${organizationColumn}" text NOT NULL column for its organization`);
|
|
27
31
|
const seen = /* @__PURE__ */ new Set();
|
|
28
32
|
for (const field of definition.fields) {
|
|
29
33
|
if (seen.has(field.name)) return problem(`the field "${field.name}" is defined twice`);
|
|
30
34
|
seen.add(field.name);
|
|
31
|
-
const found = fieldProblem(field, columns);
|
|
35
|
+
const found = fieldProblem(field, columns, organization);
|
|
32
36
|
if (found) return problem(found);
|
|
33
37
|
}
|
|
34
38
|
const title = definition.fields.find((field) => field.name === definition.title_field);
|
|
@@ -6,6 +6,7 @@ export declare const maxTextLength = 500;
|
|
|
6
6
|
/**
|
|
7
7
|
* The resource and view of a checked definition over `user_tables.<name>`, with the indexes its table has (none before
|
|
8
8
|
* they are read): built with the schema builders, so it is checked, accessed and guarded as a resource of the config.
|
|
9
|
+
* An organization's table is a `.tenant` resource over its `organization_id`.
|
|
9
10
|
*/
|
|
10
11
|
export declare const definitionResource: (definition: CheckedDefinition, indexes?: TableIndexes) => {
|
|
11
12
|
resource: import("@protobase/schema").ResourceBuilder<any, import("@protobase/schema").Ref<string, unknown>>;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { userTablesSchema } from "./names.js";
|
|
1
|
+
import { organizationColumn, userTablesSchema } from "./names.js";
|
|
2
|
+
import { organizationAdminsOf } from "./organization-access.js";
|
|
2
3
|
import { f, resource, view } from "@protobase/schema";
|
|
3
4
|
import { indexServes } from "@protobase/query";
|
|
4
5
|
//#region src/user-tables/definition-resource.ts
|
|
@@ -31,6 +32,7 @@ const fieldOf = (field, indexes) => {
|
|
|
31
32
|
/**
|
|
32
33
|
* The resource and view of a checked definition over `user_tables.<name>`, with the indexes its table has (none before
|
|
33
34
|
* they are read): built with the schema builders, so it is checked, accessed and guarded as a resource of the config.
|
|
35
|
+
* An organization's table is a `.tenant` resource over its `organization_id`.
|
|
34
36
|
*/
|
|
35
37
|
const definitionResource = (definition, indexes = {
|
|
36
38
|
kind: "table",
|
|
@@ -38,15 +40,22 @@ const definitionResource = (definition, indexes = {
|
|
|
38
40
|
}) => {
|
|
39
41
|
const key = f.bigint().readOnly().dbDefault();
|
|
40
42
|
const keyed = served(indexes, "id", "btree") ? key.filterable().sortable() : key;
|
|
41
|
-
const
|
|
43
|
+
const organization = definition.organization_id === null ? [] : [[organizationColumn, f.text()]];
|
|
44
|
+
const fields = Object.fromEntries([
|
|
45
|
+
["id", keyed],
|
|
46
|
+
...organization,
|
|
47
|
+
...definition.fields.map((field) => [field.name, fieldOf(field, indexes)])
|
|
48
|
+
]);
|
|
42
49
|
const title = definition.fields.find((field) => field.name === definition.title_field);
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
50
|
+
const who = definition.organization_id === null ? admins : organizationAdminsOf(definition.organization_id);
|
|
51
|
+
const accessed = resource(definition.name).table(`${userTablesSchema}.${definition.name}`).fields(fields).primaryKey((r) => r.id).explicitGrants().access({
|
|
52
|
+
list: who,
|
|
53
|
+
read: who,
|
|
54
|
+
create: who,
|
|
55
|
+
update: who,
|
|
56
|
+
delete: who
|
|
49
57
|
});
|
|
58
|
+
const base = definition.organization_id === null ? accessed : accessed.tenant((r) => r[organizationColumn]);
|
|
50
59
|
const searchable = title && served(indexes, title.name, "trigram") ? base.search((r) => [r[title.name]]) : base;
|
|
51
60
|
const labelled = view(definition.name).names({
|
|
52
61
|
singular: definition.singular_label,
|
|
@@ -4,7 +4,7 @@ import { checkDefinition } from "./check-definition.js";
|
|
|
4
4
|
import { definitionResource } from "./definition-resource.js";
|
|
5
5
|
import "./definitions-resources.js";
|
|
6
6
|
import { readDefinitions, readUserTableColumns } from "./read-definitions.js";
|
|
7
|
-
import { createTableSql, fieldSql, trigramIndexSql } from "./table-ddl.js";
|
|
7
|
+
import { createOrganizationTableSql, createTableSql, fieldSql, trigramIndexSql } from "./table-ddl.js";
|
|
8
8
|
import { indexServes, tableIndexes } from "@protobase/query";
|
|
9
9
|
import { sql } from "kysely";
|
|
10
10
|
//#region src/user-tables/definitions-hook.ts
|
|
@@ -15,8 +15,9 @@ const refuse = (field, message) => badRequest("invalid-record", `The request bod
|
|
|
15
15
|
}] });
|
|
16
16
|
const tablesTable = sql.table(`${definitionsSchema}.tables`);
|
|
17
17
|
const raiseVersion = (trx, tableId) => sql`update ${tablesTable} set version = version + 1 where id = ${tableId}`.execute(trx);
|
|
18
|
-
const lockedTable = async (trx, tableId) => {
|
|
19
|
-
const { rows } = await sql`select name
|
|
18
|
+
const lockedTable = async (trx, tableId, organizationId) => {
|
|
19
|
+
const { rows } = await sql`select table_name as name from ${tablesTable} where id = ${tableId} and tenant_key = coalesce(${organizationId ?? null}::text, '-') for update`.execute(trx);
|
|
20
|
+
if (!rows[0]) throw refuse("table_id", "There is no such table");
|
|
20
21
|
return rows[0];
|
|
21
22
|
};
|
|
22
23
|
const builtDefinition = async (trx, tableId) => {
|
|
@@ -33,13 +34,14 @@ const builtDefinition = async (trx, tableId) => {
|
|
|
33
34
|
};
|
|
34
35
|
const createTable = async (trx, table) => {
|
|
35
36
|
if (table.title_field != null) throw refuse("title_field", "Set the title field once its field is added");
|
|
36
|
-
const { rows } = await sql`select to_regclass(format('%I.%I', ${userTablesSchema}::text, ${table.
|
|
37
|
-
if (rows[0].found) throw refuse("name", `The database already has a table "${table.
|
|
38
|
-
|
|
37
|
+
const { rows } = await sql`select to_regclass(format('%I.%I', ${userTablesSchema}::text, ${table.table_name}::text)) is not null as found`.execute(trx);
|
|
38
|
+
if (rows[0].found) throw refuse("name", `The database already has a table "${table.table_name}" for the tables users add`);
|
|
39
|
+
const statements = table.organization_id == null ? [createTableSql(table.table_name)] : createOrganizationTableSql(table.table_name);
|
|
40
|
+
for (const statement of statements) await statement.execute(trx);
|
|
39
41
|
await builtDefinition(trx, table.id);
|
|
40
42
|
};
|
|
41
43
|
const addField = async (trx, field, taken) => {
|
|
42
|
-
const table = await lockedTable(trx, field.table_id);
|
|
44
|
+
const table = await lockedTable(trx, field.table_id, field.organization_id);
|
|
43
45
|
if (taken.includes(table.name)) throw refuse("table_id", `"${table.name}" is a resource in the app's code now, which owns its fields`);
|
|
44
46
|
const { rows } = await sql`select exists (select from pg_attribute a join pg_class c on c.oid = a.attrelid join pg_namespace n on n.oid = c.relnamespace
|
|
45
47
|
where n.nspname = ${userTablesSchema} and c.relname = ${table.name} and a.attname = ${field.name} and a.attnum > 0 and not a.attisdropped) as found`.execute(trx);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { definitionFieldTypes, definitionsSchema, nameProblem } from "./names.js";
|
|
1
|
+
import { definitionFieldTypes, definitionsSchema, nameProblem, organizationColumn } from "./names.js";
|
|
2
|
+
import { organizationAdmins } from "./organization-access.js";
|
|
2
3
|
import { f, resource } from "@protobase/schema";
|
|
3
4
|
//#region src/user-tables/definitions-resources.ts
|
|
4
5
|
/** The names of the resources over the definitions: a table users add takes neither. */
|
|
@@ -7,13 +8,20 @@ const fieldsResourceName = "user_table_fields";
|
|
|
7
8
|
const admins = ({ user }) => user?.roles?.includes("admin") ?? false;
|
|
8
9
|
const never = () => false;
|
|
9
10
|
const createOnly = { update: never };
|
|
10
|
-
const accessOf = (roles) =>
|
|
11
|
+
const accessOf = (roles, organizations) => organizations ? {
|
|
12
|
+
list: organizationAdmins,
|
|
13
|
+
read: organizationAdmins,
|
|
14
|
+
create: organizationAdmins,
|
|
15
|
+
update: organizationAdmins,
|
|
16
|
+
delete: never
|
|
17
|
+
} : roles ? { delete: never } : {
|
|
11
18
|
list: admins,
|
|
12
19
|
read: admins,
|
|
13
20
|
create: admins,
|
|
14
21
|
update: admins,
|
|
15
22
|
delete: never
|
|
16
23
|
};
|
|
24
|
+
const withOrganization = (source, organizations) => organizations ? source.tenant((r) => r[organizationColumn]) : source;
|
|
17
25
|
const issue = (field, problem) => problem ? [{
|
|
18
26
|
field,
|
|
19
27
|
message: problem
|
|
@@ -22,32 +30,43 @@ const issue = (field, problem) => problem ? [{
|
|
|
22
30
|
* The definitions of the tables users add, as resources: a table at `/user_tables/<id>`, each of its fields at
|
|
23
31
|
* `/user_table_fields/<table_id>,<name>`. Writing one creates its table or column in the database, in the same
|
|
24
32
|
* transaction (`definitionsHook`). `taken` holds the names of the app's resources and those Protobase adds, which a
|
|
25
|
-
* new table may not have.
|
|
33
|
+
* new table may not have. With `organizations`, each belongs to the organization it was made in and only that one's
|
|
34
|
+
* owners and admins reach it; its table is `t<id>_<name>` (`table_name`), so the name is free in every organization.
|
|
26
35
|
*/
|
|
27
|
-
const definitionsResources = ({ roles, taken }) => {
|
|
36
|
+
const definitionsResources = ({ roles, taken, organizations = false }) => {
|
|
37
|
+
const organization = organizations ? { [organizationColumn]: f.text() } : {};
|
|
38
|
+
const nameIssue = (name) => {
|
|
39
|
+
if (organizations) return nameProblem(name, "table", 42);
|
|
40
|
+
return nameProblem(name, "table") ?? (taken.includes(name) || ["user_tables", "user_table_fields"].includes(name) ? `"${name}" is the name of a resource of the app` : void 0);
|
|
41
|
+
};
|
|
42
|
+
const tables = resource(tablesResourceName).table(`${definitionsSchema}.tables`).fields({
|
|
43
|
+
id: f.bigint().readOnly().dbDefault().filterable().sortable(),
|
|
44
|
+
name: f.text().access(createOnly).filterable().sortable(),
|
|
45
|
+
singular_label: f.text().max(200),
|
|
46
|
+
plural_label: f.text().max(200),
|
|
47
|
+
title_field: f.text().optional(),
|
|
48
|
+
version: f.bigint().readOnly().dbDefault(),
|
|
49
|
+
table_name: f.text().readOnly().dbDefault(),
|
|
50
|
+
...organization
|
|
51
|
+
}).primaryKey((r) => r.id).explicitGrants().access(accessOf(roles, organizations)).validate((table) => [...issue("name", nameIssue(table.name)), ...issue("title_field", table.title_field ? nameProblem(table.title_field, "field") : void 0)]);
|
|
52
|
+
const fields = resource(fieldsResourceName).table(`${definitionsSchema}.fields`).fields({
|
|
53
|
+
table_id: f.relation(tablesResourceName).access(createOnly).filterable(),
|
|
54
|
+
name: f.text().access(createOnly),
|
|
55
|
+
label: f.text().max(200),
|
|
56
|
+
type: f.enum(definitionFieldTypes).access(createOnly),
|
|
57
|
+
required: f.boolean().default(false).access(createOnly),
|
|
58
|
+
position: f.integer().sortable(),
|
|
59
|
+
precision: f.integer().optional().access(createOnly),
|
|
60
|
+
scale: f.integer().optional().access(createOnly),
|
|
61
|
+
...organization
|
|
62
|
+
}).primaryKey((r) => [r.table_id, r.name]).explicitGrants().access(accessOf(roles, organizations)).validate((field) => [
|
|
63
|
+
...issue("name", nameProblem(field.name, "field")),
|
|
64
|
+
...issue("required", field.required ? "A field users add is optional: the records already there have no value for it" : void 0),
|
|
65
|
+
...field.type === "decimal" ? [...issue("precision", field.precision == null || field.precision < 1 || field.precision > 1e3 ? "A decimal needs a precision from 1 to 1000" : void 0), ...issue("scale", field.scale == null || field.scale < 0 || field.precision != null && field.scale > field.precision ? "A decimal needs a scale from 0 to its precision" : void 0)] : [...issue("precision", field.precision == null ? void 0 : "Only a decimal has a precision"), ...issue("scale", field.scale == null ? void 0 : "Only a decimal has a scale")]
|
|
66
|
+
]);
|
|
28
67
|
return {
|
|
29
|
-
tables:
|
|
30
|
-
|
|
31
|
-
name: f.text().access(createOnly).filterable().sortable(),
|
|
32
|
-
singular_label: f.text().max(200),
|
|
33
|
-
plural_label: f.text().max(200),
|
|
34
|
-
title_field: f.text().optional(),
|
|
35
|
-
version: f.bigint().readOnly().dbDefault()
|
|
36
|
-
}).primaryKey((r) => r.id).explicitGrants().access(accessOf(roles)).validate((table) => [...issue("name", nameProblem(table.name, "table") ?? (taken.includes(table.name) || ["user_tables", "user_table_fields"].includes(table.name) ? `"${table.name}" is the name of a resource of the app` : void 0)), ...issue("title_field", table.title_field ? nameProblem(table.title_field, "field") : void 0)]),
|
|
37
|
-
fields: resource(fieldsResourceName).table(`${definitionsSchema}.fields`).fields({
|
|
38
|
-
table_id: f.relation(tablesResourceName).access(createOnly).filterable(),
|
|
39
|
-
name: f.text().access(createOnly),
|
|
40
|
-
label: f.text().max(200),
|
|
41
|
-
type: f.enum(definitionFieldTypes).access(createOnly),
|
|
42
|
-
required: f.boolean().default(false).access(createOnly),
|
|
43
|
-
position: f.integer().sortable(),
|
|
44
|
-
precision: f.integer().optional().access(createOnly),
|
|
45
|
-
scale: f.integer().optional().access(createOnly)
|
|
46
|
-
}).primaryKey((r) => [r.table_id, r.name]).explicitGrants().access(accessOf(roles)).validate((field) => [
|
|
47
|
-
...issue("name", nameProblem(field.name, "field")),
|
|
48
|
-
...issue("required", field.required ? "A field users add is optional: the records already there have no value for it" : void 0),
|
|
49
|
-
...field.type === "decimal" ? [...issue("precision", field.precision == null || field.precision < 1 || field.precision > 1e3 ? "A decimal needs a precision from 1 to 1000" : void 0), ...issue("scale", field.scale == null || field.scale < 0 || field.precision != null && field.scale > field.precision ? "A decimal needs a scale from 0 to its precision" : void 0)] : [...issue("precision", field.precision == null ? void 0 : "Only a decimal has a precision"), ...issue("scale", field.scale == null ? void 0 : "Only a decimal has a scale")]
|
|
50
|
-
])
|
|
68
|
+
tables: withOrganization(tables, organizations),
|
|
69
|
+
fields: withOrganization(fields, organizations)
|
|
51
70
|
};
|
|
52
71
|
};
|
|
53
72
|
//#endregion
|
|
@@ -19,13 +19,19 @@ const definitionColumnTypes = {
|
|
|
19
19
|
};
|
|
20
20
|
const definitionFieldTypes = Object.keys(definitionColumnTypes);
|
|
21
21
|
const isUserTableName = (name) => userTableName.test(name) && new TextEncoder().encode(name).length <= 63;
|
|
22
|
-
/** The
|
|
23
|
-
const
|
|
24
|
-
/**
|
|
25
|
-
const
|
|
26
|
-
|
|
22
|
+
/** The column that holds the organization of each record of a table users add with organizations, and of each definition. */
|
|
23
|
+
const organizationColumn = "organization_id";
|
|
24
|
+
/** The names a field never takes: the key's, the API's for the record version, and the organization's. */
|
|
25
|
+
const reservedFieldNames = [
|
|
26
|
+
"id",
|
|
27
|
+
"etag",
|
|
28
|
+
organizationColumn
|
|
29
|
+
];
|
|
30
|
+
/** What is wrong with a name a table or field users add is given, or `undefined` when it may have it; `maxBytes` defaults to 63. */
|
|
31
|
+
const nameProblem = (name, kind, maxBytes = 63) => {
|
|
32
|
+
if (typeof name !== "string" || !isUserTableName(name) || new TextEncoder().encode(name).length > maxBytes) return `Use lowercase letters, digits and "_", starting with a letter, at most ${maxBytes} bytes`;
|
|
27
33
|
if (reservedWords.has(name)) return `"${name}" is a word SQL reserves`;
|
|
28
34
|
if (kind === "field" && reservedFieldNames.includes(name)) return `"${name}" is the name of a field every table has`;
|
|
29
35
|
};
|
|
30
36
|
//#endregion
|
|
31
|
-
export { definitionColumnTypes, definitionFieldTypes, definitionsSchema, isUserTableName, nameProblem, reservedFieldNames, userTableName, userTablesSchema };
|
|
37
|
+
export { definitionColumnTypes, definitionFieldTypes, definitionsSchema, isUserTableName, nameProblem, organizationColumn, reservedFieldNames, userTableName, userTablesSchema };
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
//#region src/user-tables/organization-access.ts
|
|
2
|
+
/**
|
|
3
|
+
* The organization's owners and admins, as the auth store holds their membership for this request, and superusers
|
|
4
|
+
* working in it: no one without an organization, so a session that names none reaches nothing.
|
|
5
|
+
*/
|
|
6
|
+
const organizationAdmins = ({ user, organization }) => {
|
|
7
|
+
if (!organization) return false;
|
|
8
|
+
if (user?.roles?.includes("admin")) return true;
|
|
9
|
+
return organization.role === "owner" || organization.role === "admin";
|
|
10
|
+
};
|
|
11
|
+
/** `organizationAdmins` of the one organization `organizationId`, the one a table users add belongs to. */
|
|
12
|
+
const organizationAdminsOf = (organizationId) => (context) => context.organization !== void 0 && String(context.organization.id) === organizationId && organizationAdmins(context);
|
|
13
|
+
//#endregion
|
|
14
|
+
export { organizationAdmins, organizationAdminsOf };
|
|
@@ -4,9 +4,10 @@ import { Db } from "@protobase/query";
|
|
|
4
4
|
/** The user tables a resource in code owns, as `protobase eject` writes one: over `user_tables.<name>`, with that name. */
|
|
5
5
|
export declare const ownedTableNames: (models: readonly ResourceModel[]) => string[];
|
|
6
6
|
/**
|
|
7
|
-
* The delete of the definitions of the user tables a resource in code owns, with their fields
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* The delete of the definitions of the user tables a resource in code owns, with their fields, matched on the table
|
|
8
|
+
* they serve, so an organization's table of the same name stays; their tables and rows stay. `undefined` when no such
|
|
9
|
+
* definition is left. `protobase migration` writes it once the code that owns them has deployed, so a definition goes
|
|
10
|
+
* in a later deploy than the one that stopped reading it.
|
|
10
11
|
*/
|
|
11
12
|
export declare const ownedDefinitionsMigration: (db: Db, models: readonly ResourceModel[]) => Promise<string | undefined>;
|
|
12
13
|
//#endregion
|
|
@@ -5,18 +5,19 @@ import { sql } from "kysely";
|
|
|
5
5
|
const ownedTableNames = (models) => models.filter((model) => model.table.schema === "user_tables" && model.table.name === model.name).map((model) => model.name);
|
|
6
6
|
const literal = (text) => `'${text.replaceAll("'", "''")}'`;
|
|
7
7
|
/**
|
|
8
|
-
* The delete of the definitions of the user tables a resource in code owns, with their fields
|
|
9
|
-
*
|
|
10
|
-
*
|
|
8
|
+
* The delete of the definitions of the user tables a resource in code owns, with their fields, matched on the table
|
|
9
|
+
* they serve, so an organization's table of the same name stays; their tables and rows stay. `undefined` when no such
|
|
10
|
+
* definition is left. `protobase migration` writes it once the code that owns them has deployed, so a definition goes
|
|
11
|
+
* in a later deploy than the one that stopped reading it.
|
|
11
12
|
*/
|
|
12
13
|
const ownedDefinitionsMigration = async (db, models) => {
|
|
13
14
|
const owned = ownedTableNames(models);
|
|
14
15
|
if (owned.length === 0) return;
|
|
15
16
|
const { rows: found } = await sql`select to_regclass(${`${definitionsSchema}.tables`}) is not null as found`.execute(db);
|
|
16
17
|
if (!found[0].found) return;
|
|
17
|
-
const { rows } = await sql`select name from ${sql.table(`${definitionsSchema}.tables`)} where
|
|
18
|
+
const { rows } = await sql`select table_name as name from ${sql.table(`${definitionsSchema}.tables`)} where table_name = any(${owned}) order by table_name`.execute(db);
|
|
18
19
|
if (rows.length === 0) return;
|
|
19
|
-
return `delete from ${definitionsSchema}.tables where
|
|
20
|
+
return `delete from ${definitionsSchema}.tables where table_name in (${rows.map((row) => literal(row.name)).join(", ")});`;
|
|
20
21
|
};
|
|
21
22
|
//#endregion
|
|
22
23
|
export { ownedDefinitionsMigration, ownedTableNames };
|
|
@@ -9,10 +9,15 @@ export type StoredField = {
|
|
|
9
9
|
precision: number | null;
|
|
10
10
|
scale: number | null;
|
|
11
11
|
};
|
|
12
|
-
/**
|
|
12
|
+
/**
|
|
13
|
+
* A stored definition of a table users add, as its rows hold it, with its fields in order. `name` is its table's name in
|
|
14
|
+
* the user tables schema and its resource's (`table_name`); `organization_id` the organization it belongs to, `null`
|
|
15
|
+
* without organizations.
|
|
16
|
+
*/
|
|
13
17
|
export type StoredDefinition = {
|
|
14
18
|
id: string;
|
|
15
19
|
name: string;
|
|
20
|
+
organization_id: string | null;
|
|
16
21
|
singular_label: string;
|
|
17
22
|
plural_label: string;
|
|
18
23
|
title_field: string | null;
|
|
@@ -3,7 +3,7 @@ import { sql } from "kysely";
|
|
|
3
3
|
//#region src/user-tables/read-definitions.ts
|
|
4
4
|
/** The stored definitions, by id, each with its fields by position. */
|
|
5
5
|
const readDefinitions = async (db) => {
|
|
6
|
-
const { rows } = await sql`select t.id::text as id, t.name, t.singular_label, t.plural_label, t.title_field, t.version::text as version,
|
|
6
|
+
const { rows } = await sql`select t.id::text as id, t.table_name as name, t.organization_id, t.singular_label, t.plural_label, t.title_field, t.version::text as version,
|
|
7
7
|
coalesce((select json_agg(json_build_object('name', f.name, 'label', f.label, 'type', f.type, 'required', f.required, 'precision', f.precision, 'scale', f.scale) order by f.position, f.name)
|
|
8
8
|
from ${sql.table(`${definitionsSchema}.fields`)} f where f.table_id = t.id), '[]') as fields
|
|
9
9
|
from ${sql.table(`${definitionsSchema}.tables`)} t order by t.id`.execute(db);
|
|
@@ -1,20 +1,39 @@
|
|
|
1
1
|
import { definitionFieldTypes, definitionsSchema, userTableName, userTablesSchema } from "./names.js";
|
|
2
2
|
//#region src/user-tables/schema-sql.ts
|
|
3
3
|
const nameCheck = (column) => `check (${column} ~ '${userTableName.source}' and octet_length(${column}) <= 63)`;
|
|
4
|
-
/**
|
|
4
|
+
/**
|
|
5
|
+
* The table of definitions: one row per table users add, its labels and a version, 1 when added, that each change of it
|
|
6
|
+
* raises. With organizations a definition belongs to the one it was made in (`organization_id`), and its table is named
|
|
7
|
+
* `t<id>_<name>`, so two organizations can each have a table of one name; without, the table is the name. `table_name`
|
|
8
|
+
* is the table's name in the user tables schema and the name of its resource.
|
|
9
|
+
*/
|
|
5
10
|
const tablesSql = () => `create table ${definitionsSchema}.tables (
|
|
6
11
|
id bigint generated always as identity primary key,
|
|
7
|
-
name text not null
|
|
12
|
+
name text not null ${nameCheck("name")},
|
|
13
|
+
organization_id text,
|
|
8
14
|
singular_label text not null,
|
|
9
15
|
plural_label text not null,
|
|
10
16
|
title_field text ${nameCheck("title_field")},
|
|
11
|
-
version bigint not null default 1 check (version > 0)
|
|
17
|
+
version bigint not null default 1 check (version > 0),
|
|
18
|
+
${tableNameColumn},
|
|
19
|
+
${tenantKeyColumn},
|
|
20
|
+
unique (id, tenant_key)
|
|
12
21
|
)`;
|
|
22
|
+
const tableNameColumn = `table_name text not null unique generated always as (case when organization_id is null then name else 't' || id || '_' || name end) stored ${nameCheck("table_name")}`;
|
|
23
|
+
const tenantKeyColumn = "tenant_key text not null generated always as (coalesce(organization_id, '-')) stored";
|
|
24
|
+
/** A name is unique among the definitions of one organization, or of the app without organizations. */
|
|
25
|
+
const nameIndexesSql = () => [`create unique index tables_name_key on ${definitionsSchema}.tables (name) where organization_id is null`, `create unique index tables_organization_name_key on ${definitionsSchema}.tables (organization_id, name) where organization_id is not null`];
|
|
13
26
|
/** Gives a definitions table made before new definitions started at version 1 that default. */
|
|
14
27
|
const versionDefaultSql = () => `alter table ${definitionsSchema}.tables alter column version set default 1`;
|
|
15
|
-
/**
|
|
28
|
+
/**
|
|
29
|
+
* The fields of each definition, in order, each with its label and type; a decimal names its precision and scale. A
|
|
30
|
+
* field belongs to its table's organization: the foreign key compares both, so no field reaches another organization's
|
|
31
|
+
* table, or one of none.
|
|
32
|
+
*/
|
|
16
33
|
const fieldsSql = () => `create table ${definitionsSchema}.fields (
|
|
17
|
-
table_id bigint not null
|
|
34
|
+
table_id bigint not null,
|
|
35
|
+
organization_id text,
|
|
36
|
+
${tenantKeyColumn},
|
|
18
37
|
name text not null ${nameCheck("name")} check (name not in ('id', 'etag')),
|
|
19
38
|
label text not null,
|
|
20
39
|
type text not null check (type in (${definitionFieldTypes.map((type) => `'${type}'`).join(", ")})),
|
|
@@ -23,8 +42,20 @@ const fieldsSql = () => `create table ${definitionsSchema}.fields (
|
|
|
23
42
|
precision integer check (precision between 1 and 1000),
|
|
24
43
|
scale integer check (scale between 0 and precision),
|
|
25
44
|
primary key (table_id, name),
|
|
45
|
+
foreign key (table_id, tenant_key) references ${definitionsSchema}.tables (id, tenant_key) on delete cascade,
|
|
26
46
|
check ((type = 'decimal') = (precision is not null and scale is not null))
|
|
27
47
|
)`;
|
|
48
|
+
/**
|
|
49
|
+
* Brings a definitions table made before organizations to the shape of `tablesSql`: a name unique per organization and
|
|
50
|
+
* the served name.
|
|
51
|
+
*/
|
|
52
|
+
const organizationsSql = () => [
|
|
53
|
+
`alter table ${definitionsSchema}.tables add column organization_id text, add column ${tableNameColumn}, add column ${tenantKeyColumn}, add unique (id, tenant_key)`,
|
|
54
|
+
`alter table ${definitionsSchema}.tables drop constraint tables_name_key`,
|
|
55
|
+
...nameIndexesSql()
|
|
56
|
+
];
|
|
57
|
+
/** Ties the fields made before organizations to their table's organization, as `fieldsSql` does. */
|
|
58
|
+
const fieldsOrganizationSql = () => `alter table ${definitionsSchema}.fields add column organization_id text, add column ${tenantKeyColumn}, add foreign key (table_id, tenant_key) references ${definitionsSchema}.tables (id, tenant_key) on delete cascade`;
|
|
28
59
|
const schemasSql = () => [
|
|
29
60
|
"create extension if not exists pg_trgm",
|
|
30
61
|
`create schema if not exists ${definitionsSchema}`,
|
|
@@ -34,4 +65,4 @@ const schemasSql = () => [
|
|
|
34
65
|
`grant usage, create on schema ${userTablesSchema} to current_user`
|
|
35
66
|
];
|
|
36
67
|
//#endregion
|
|
37
|
-
export { fieldsSql, schemasSql, tablesSql, versionDefaultSql };
|
|
68
|
+
export { fieldsOrganizationSql, fieldsSql, nameIndexesSql, organizationsSql, schemasSql, tablesSql, versionDefaultSql };
|
|
@@ -2,7 +2,8 @@ import { Db } from "@protobase/query";
|
|
|
2
2
|
//#region src/user-tables/schema.d.ts
|
|
3
3
|
/**
|
|
4
4
|
* The SQL that adds what user tables need: pg_trgm, the definitions schema with its `tables` and `fields`, and the
|
|
5
|
-
* schema the tables themselves live in. `undefined` when current. Nothing is dropped
|
|
5
|
+
* schema the tables themselves live in. `undefined` when current. Nothing is dropped but the unique constraint on a
|
|
6
|
+
* definition's name, which a name unique per organization replaces.
|
|
6
7
|
*/
|
|
7
8
|
export declare const userTablesMigration: (db: Db) => Promise<string | undefined>;
|
|
8
9
|
/** Refuses to go on while the schemas of user tables are missing; the project's migrations add them. */
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { migrationCommand } from "../changes/capture-schema.js";
|
|
2
2
|
import { definitionsSchema, userTablesSchema } from "./names.js";
|
|
3
|
-
import { fieldsSql, schemasSql, tablesSql, versionDefaultSql } from "./schema-sql.js";
|
|
3
|
+
import { fieldsOrganizationSql, fieldsSql, nameIndexesSql, organizationsSql, schemasSql, tablesSql, versionDefaultSql } from "./schema-sql.js";
|
|
4
4
|
import { sql } from "kysely";
|
|
5
5
|
//#region src/user-tables/schema.ts
|
|
6
|
+
const current = (found) => found.tables && found.versionDefault && found.organizations && found.fields && found.data && found.trgm;
|
|
6
7
|
const readUserTablesSchema = async (db) => {
|
|
7
8
|
const { rows } = await sql`select
|
|
8
9
|
to_regclass(${`${definitionsSchema}.tables`}) is not null as tables,
|
|
9
10
|
exists (select from information_schema.columns where table_schema = ${definitionsSchema} and table_name = 'tables' and column_name = 'version' and column_default is not null) as "versionDefault",
|
|
11
|
+
exists (select from information_schema.columns where table_schema = ${definitionsSchema} and table_name = 'tables' and column_name = 'table_name') as organizations,
|
|
10
12
|
to_regclass(${`${definitionsSchema}.fields`}) is not null as fields,
|
|
11
13
|
exists (select from pg_namespace where nspname = ${userTablesSchema}) as data,
|
|
12
14
|
exists (select from pg_extension where extname = 'pg_trgm') as trgm`.execute(db);
|
|
@@ -14,22 +16,25 @@ const readUserTablesSchema = async (db) => {
|
|
|
14
16
|
};
|
|
15
17
|
/**
|
|
16
18
|
* The SQL that adds what user tables need: pg_trgm, the definitions schema with its `tables` and `fields`, and the
|
|
17
|
-
* schema the tables themselves live in. `undefined` when current. Nothing is dropped
|
|
19
|
+
* schema the tables themselves live in. `undefined` when current. Nothing is dropped but the unique constraint on a
|
|
20
|
+
* definition's name, which a name unique per organization replaces.
|
|
18
21
|
*/
|
|
19
22
|
const userTablesMigration = async (db) => {
|
|
20
23
|
const found = await readUserTablesSchema(db);
|
|
21
|
-
if (found
|
|
24
|
+
if (current(found)) return void 0;
|
|
25
|
+
const tables = found.tables ? [...found.versionDefault ? [] : [versionDefaultSql()], ...found.organizations ? [] : organizationsSql()] : [tablesSql(), ...nameIndexesSql()];
|
|
26
|
+
const fields = found.fields ? found.tables && !found.organizations ? [fieldsOrganizationSql()] : [] : [fieldsSql()];
|
|
22
27
|
return `${[
|
|
23
28
|
...schemasSql(),
|
|
24
|
-
...
|
|
25
|
-
...
|
|
29
|
+
...tables,
|
|
30
|
+
...fields
|
|
26
31
|
].join(";\n\n")};`;
|
|
27
32
|
};
|
|
28
33
|
/** Refuses to go on while the schemas of user tables are missing; the project's migrations add them. */
|
|
29
34
|
const checkUserTablesSchema = async (db) => {
|
|
30
35
|
const found = await readUserTablesSchema(db);
|
|
31
|
-
if (found
|
|
32
|
-
throw new Error(`The ${definitionsSchema} or ${userTablesSchema} schema, the default of a definition's version, or pg_trgm, is missing, and userTables keeps the tables users add there. Write it as a migration with \`${migrationCommand}\`, apply it with the project's migrations, then start again.`);
|
|
36
|
+
if (current(found)) return;
|
|
37
|
+
throw new Error(`The ${definitionsSchema} or ${userTablesSchema} schema, the default of a definition's version, a definition's organization, or pg_trgm, is missing, and userTables keeps the tables users add there. Write it as a migration with \`${migrationCommand}\`, apply it with the project's migrations, then start again.`);
|
|
33
38
|
};
|
|
34
39
|
//#endregion
|
|
35
40
|
export { checkUserTablesSchema, userTablesMigration };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { nameProblem, userTablesSchema } from "./names.js";
|
|
1
|
+
import { nameProblem, organizationColumn, userTablesSchema } from "./names.js";
|
|
2
2
|
import { sql } from "kysely";
|
|
3
3
|
//#region src/user-tables/table-ddl.ts
|
|
4
4
|
const checkedName = (name, kind) => {
|
|
@@ -41,6 +41,11 @@ const indexKind = (type) => {
|
|
|
41
41
|
};
|
|
42
42
|
/** The new table, with its key alone: its fields are added as columns. */
|
|
43
43
|
const createTableSql = (table) => sql`create table ${tableId(table)} (id bigint generated always as identity primary key)`;
|
|
44
|
+
/**
|
|
45
|
+
* The new table of an organization: its key, and the organization of each record, which `.tenant` sets and every query
|
|
46
|
+
* filters by, with the index that filter reads.
|
|
47
|
+
*/
|
|
48
|
+
const createOrganizationTableSql = (table) => [sql`create table ${tableId(table)} (id bigint generated always as identity primary key, ${sql.id(organizationColumn)} text not null)`, sql`create index on ${tableId(table)} (${sql.id(organizationColumn)})`];
|
|
44
49
|
/** The field's column: nullable and without a default, so the records already there read it as empty. */
|
|
45
50
|
const addColumnSql = (table, field) => sql`alter table ${tableId(table)} add column ${sql.id(checkedName(field.name, "field"))} ${columnType(field)}`;
|
|
46
51
|
const btreeSql = (table, column) => sql`create index on ${tableId(table)} (${sql.id(checkedName(column, "field"))})`;
|
|
@@ -53,4 +58,4 @@ const fieldSql = (table, field) => {
|
|
|
53
58
|
return [addColumnSql(table, field), ...index];
|
|
54
59
|
};
|
|
55
60
|
//#endregion
|
|
56
|
-
export { addColumnSql, createTableSql, fieldSql, indexKind, trigramIndexSql };
|
|
61
|
+
export { addColumnSql, createOrganizationTableSql, createTableSql, fieldSql, indexKind, trigramIndexSql };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@protobase/server",
|
|
3
|
-
"version": "0.67.
|
|
3
|
+
"version": "0.67.6",
|
|
4
4
|
"description": "The Protobase REST API as a Hono app, with Better Auth sign-in.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -44,9 +44,9 @@
|
|
|
44
44
|
"@better-auth/sso": "^1.7.12",
|
|
45
45
|
"@livekit/protocol": "1.53.1",
|
|
46
46
|
"@modelcontextprotocol/server": "^2.3.1",
|
|
47
|
-
"@protobase/layout": "0.67.
|
|
48
|
-
"@protobase/query": "0.67.
|
|
49
|
-
"@protobase/schema": "0.67.
|
|
47
|
+
"@protobase/layout": "0.67.6",
|
|
48
|
+
"@protobase/query": "0.67.6",
|
|
49
|
+
"@protobase/schema": "0.67.6",
|
|
50
50
|
"@react-email/render": "^2.1.0",
|
|
51
51
|
"@react-pdf/primitives": "^4.4.0",
|
|
52
52
|
"@react-pdf/renderer": "^4.9.0",
|