@protobase/server 0.7.0 → 0.8.1

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.
Files changed (114) hide show
  1. package/dist/assistant/conversations.d.ts +1 -1
  2. package/dist/assistant/tools.d.ts +1 -1
  3. package/dist/assistant/turn.d.ts +2 -2
  4. package/dist/audit/origin.js +12 -0
  5. package/dist/auth/jwt.d.ts +2 -0
  6. package/dist/auth/jwt.js +5 -3
  7. package/dist/batch/reorder.js +1 -1
  8. package/dist/batch/run-batch.js +2 -1
  9. package/dist/better-auth/authenticator.d.ts +7 -3
  10. package/dist/better-auth/authenticator.js +24 -3
  11. package/dist/better-auth/create-auth.d.ts +619 -81
  12. package/dist/better-auth/create-auth.js +64 -27
  13. package/dist/better-auth/organizations/accept-invitation.js +80 -0
  14. package/dist/better-auth/organizations/active-organization.js +12 -0
  15. package/dist/better-auth/organizations/admin-routes.js +83 -0
  16. package/dist/better-auth/organizations/app-roles.js +43 -0
  17. package/dist/better-auth/organizations/host.d.ts +53 -0
  18. package/dist/better-auth/organizations/host.js +251 -0
  19. package/dist/better-auth/organizations/invitation-endpoints.js +133 -0
  20. package/dist/better-auth/organizations/invitation-mail.js +17 -0
  21. package/dist/better-auth/organizations/invitation-token.js +27 -0
  22. package/dist/better-auth/organizations/member-endpoints.js +182 -0
  23. package/dist/better-auth/organizations/options.d.ts +48 -0
  24. package/dist/better-auth/organizations/options.js +23 -0
  25. package/dist/better-auth/organizations/organization-plugin.js +128 -0
  26. package/dist/better-auth/organizations/protobase-plugin.js +21 -0
  27. package/dist/better-auth/organizations/role-definitions.d.ts +15 -0
  28. package/dist/better-auth/organizations/role-definitions.js +56 -0
  29. package/dist/better-auth/organizations/session-organization.js +68 -0
  30. package/dist/better-auth/organizations/store.d.ts +19 -0
  31. package/dist/better-auth/organizations/store.js +73 -0
  32. package/dist/better-auth/staff-checks.js +5 -1
  33. package/dist/better-auth/staff-sign-in-endpoints.js +1 -1
  34. package/dist/better-auth/status-route.js +14 -3
  35. package/dist/better-auth/users.d.ts +2 -1
  36. package/dist/better-auth/users.js +4 -1
  37. package/dist/changes/actor.js +12 -0
  38. package/dist/changes/capture-plan.js +62 -0
  39. package/dist/changes/capture-schema.d.ts +18 -0
  40. package/dist/changes/capture-schema.js +65 -0
  41. package/dist/changes/capture-sql.js +108 -0
  42. package/dist/changes/coalesce.d.ts +38 -0
  43. package/dist/changes/coalesce.js +69 -0
  44. package/dist/changes/events-options.d.ts +10 -0
  45. package/dist/changes/events-options.js +10 -0
  46. package/dist/changes/match-events.d.ts +9 -0
  47. package/dist/changes/match-events.js +55 -0
  48. package/dist/changes/resource-change.d.ts +27 -0
  49. package/dist/changes/resource-change.js +43 -0
  50. package/dist/create-admin.d.ts +11 -2
  51. package/dist/create-admin.js +33 -2
  52. package/dist/deps.d.ts +1 -0
  53. package/dist/files/attach.js +80 -0
  54. package/dist/files/cleanup-runner.d.ts +8 -0
  55. package/dist/files/cleanup-runner.js +58 -0
  56. package/dist/files/cleanup-schedule.d.ts +28 -0
  57. package/dist/files/cleanup-schedule.js +48 -0
  58. package/dist/files/detect.js +51 -0
  59. package/dist/files/download-url.js +43 -0
  60. package/dist/files/download.js +90 -0
  61. package/dist/files/duration.js +35 -0
  62. package/dist/files/file-cleanup.d.ts +18 -0
  63. package/dist/files/file-cleanup.js +17 -0
  64. package/dist/files/local-files.d.ts +17 -0
  65. package/dist/files/local-files.js +101 -0
  66. package/dist/files/media-types.js +99 -0
  67. package/dist/files/options.d.ts +15 -0
  68. package/dist/files/present.d.ts +15 -0
  69. package/dist/files/present.js +59 -0
  70. package/dist/files/processors.d.ts +15 -0
  71. package/dist/files/processors.js +39 -0
  72. package/dist/files/providers.js +70 -0
  73. package/dist/files/routes.js +26 -0
  74. package/dist/files/runtime.d.ts +3 -0
  75. package/dist/files/runtime.js +37 -0
  76. package/dist/files/signer.js +32 -0
  77. package/dist/files/store.d.ts +37 -0
  78. package/dist/files/ticket.js +87 -0
  79. package/dist/files/upload-body.js +85 -0
  80. package/dist/files/upload.js +124 -0
  81. package/dist/files/uri.js +47 -0
  82. package/dist/functions/define-function.d.ts +71 -0
  83. package/dist/functions/define-function.js +36 -0
  84. package/dist/functions/function-app.d.ts +17 -0
  85. package/dist/functions/function-app.js +19 -0
  86. package/dist/functions/read-json.d.ts +35 -0
  87. package/dist/functions/read-json.js +24 -0
  88. package/dist/functions/records.d.ts +43 -0
  89. package/dist/functions/records.js +31 -0
  90. package/dist/functions/require-env.d.ts +7 -0
  91. package/dist/functions/require-env.js +12 -0
  92. package/dist/functions/routes.js +60 -0
  93. package/dist/functions/verify-signature.d.ts +32 -0
  94. package/dist/functions/verify-signature.js +49 -0
  95. package/dist/index.d.ts +26 -3
  96. package/dist/index.js +17 -3
  97. package/dist/list-service.js +4 -2
  98. package/dist/openapi/resource-paths.js +78 -1
  99. package/dist/openapi/schemas.js +28 -3
  100. package/dist/project-config.d.ts +10 -1
  101. package/dist/project-config.js +41 -3
  102. package/dist/read-service.js +5 -1
  103. package/dist/request-access.d.ts +5 -0
  104. package/dist/request-access.js +9 -4
  105. package/dist/resource-source.d.ts +4 -1
  106. package/dist/routes/batch.js +1 -1
  107. package/dist/routes/collection.js +3 -1
  108. package/dist/routes/record.js +2 -5
  109. package/dist/transactions.js +10 -3
  110. package/dist/types.d.ts +25 -5
  111. package/dist/write-pipeline.js +4 -2
  112. package/dist/write-shared.js +6 -3
  113. package/dist/write-update.js +2 -1
  114. package/package.json +6 -4
@@ -0,0 +1,108 @@
1
+ //#region src/changes/capture-sql.ts
2
+ const quoteIdent = (name) => `"${name.replaceAll("\"", "\"\"")}"`;
3
+ const quoteLiteral = (text) => `'${text.replaceAll("'", "''")}'`;
4
+ const qualified = ({ schema, name }) => schema ? `${quoteIdent(schema)}.${quoteIdent(name)}` : quoteIdent(name);
5
+ /** A Postgres array literal of text, every element quoted, so the same columns always give the same text. */
6
+ const textArray = (items) => `{${items.map((item) => `"${item.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"")}"`).join(",")}}`;
7
+ const identifier = (name) => {
8
+ const encoder = new TextEncoder();
9
+ let kept = "";
10
+ for (const char of name) {
11
+ if (encoder.encode(kept + char).length > 63) break;
12
+ kept += char;
13
+ }
14
+ return kept;
15
+ };
16
+ /** The table as SQL names it, qualified when the resource names a schema. */
17
+ const tableSql = (model) => qualified(model.table);
18
+ /**
19
+ * The outbox: one row per changed row, written in the writer's transaction. `transaction` is the writer's transaction
20
+ * id; `dispatch_after` holds back a change that the process which wrote it is delivering itself.
21
+ */
22
+ const outboxSql = (schema) => `create table if not exists ${quoteIdent(schema)}."outbox" (
23
+ "id" bigint generated always as identity primary key,
24
+ "transaction" bigint not null,
25
+ "resource" text not null,
26
+ "op" text not null,
27
+ "old" jsonb,
28
+ "new" jsonb,
29
+ "changed" text[],
30
+ "actor" jsonb,
31
+ "dispatch_after" timestamptz not null default now(),
32
+ "created_at" timestamptz not null default now()
33
+ )`;
34
+ /**
35
+ * The body of `capture()`: its arguments are the resource, the columns to keep and the sensitive columns, whose names
36
+ * it lists when they change but whose values it never keeps. It skips a transaction that set `<schema>.capture` to
37
+ * `off`, and an update that changed none of those columns.
38
+ */
39
+ const captureBody = (schema) => `
40
+ declare
41
+ kept text[] := TG_ARGV[1]::text[];
42
+ hidden text[] := TG_ARGV[2]::text[];
43
+ old_row jsonb;
44
+ new_row jsonb;
45
+ changed text[];
46
+ begin
47
+ if current_setting('${schema}.capture', true) = 'off' then
48
+ return null;
49
+ end if;
50
+ if TG_OP <> 'INSERT' then
51
+ old_row := to_jsonb(OLD);
52
+ end if;
53
+ if TG_OP <> 'DELETE' then
54
+ new_row := to_jsonb(NEW);
55
+ end if;
56
+ if TG_OP = 'UPDATE' then
57
+ select array_agg(c.name order by c.position) into changed
58
+ from unnest(kept || hidden) with ordinality as c(name, position)
59
+ where old_row -> c.name is distinct from new_row -> c.name;
60
+ if changed is null then
61
+ return null;
62
+ end if;
63
+ end if;
64
+ insert into ${quoteIdent(schema)}."outbox" ("transaction", "resource", "op", "old", "new", "changed", "actor")
65
+ values (
66
+ pg_current_xact_id()::text::bigint,
67
+ TG_ARGV[0],
68
+ TG_OP,
69
+ (select jsonb_object_agg(o.key, o.value) from jsonb_each(old_row) as o where o.key = any(kept)),
70
+ (select jsonb_object_agg(n.key, n.value) from jsonb_each(new_row) as n where n.key = any(kept)),
71
+ changed,
72
+ nullif(current_setting('${schema}.actor', true), '')::jsonb
73
+ );
74
+ return null;
75
+ end
76
+ `;
77
+ /**
78
+ * `capture()` runs as its owner, with a fixed search path: a writer needs no rights on the schema, so another
79
+ * service writing as its own role keeps working once a resource has events.
80
+ */
81
+ const captureFunctionSql = (schema) => `create or replace function ${quoteIdent(schema)}."capture"() returns trigger language plpgsql security definer set search_path = pg_catalog, pg_temp as $capture$${captureBody(schema)}$capture$`;
82
+ /** The two triggers of a resource with events: inserts and deletes, and updates that change the row. */
83
+ const resourceTriggers = (model) => {
84
+ const fields = Object.values(model.fields);
85
+ const args = [
86
+ model.name,
87
+ textArray(fields.filter((field) => !field.sensitive).map((field) => field.column)),
88
+ textArray(fields.filter((field) => field.sensitive).map((field) => field.column))
89
+ ];
90
+ return [{
91
+ name: identifier(`${model.table.name}_changes`),
92
+ type: 13,
93
+ args
94
+ }, {
95
+ name: identifier(`${model.table.name}_changes_update`),
96
+ type: 17,
97
+ args
98
+ }];
99
+ };
100
+ const dropTriggerSql = (name, table) => `drop trigger if exists ${quoteIdent(name)} on ${table}`;
101
+ const createTriggerSql = (schema, trigger, table) => {
102
+ const timing = trigger.type & 16 ? "after update" : "after insert or delete";
103
+ const when = trigger.type & 16 ? " when (old.* is distinct from new.*)" : "";
104
+ const args = trigger.args.map(quoteLiteral).join(", ");
105
+ return `create trigger ${quoteIdent(trigger.name)} ${timing} on ${table} for each row${when} execute function ${quoteIdent(schema)}."capture"(${args})`;
106
+ };
107
+ //#endregion
108
+ export { captureBody, captureFunctionSql, createTriggerSql, dropTriggerSql, outboxSql, quoteIdent, resourceTriggers, tableSql };
@@ -0,0 +1,38 @@
1
+ import { Row } from "../types.js";
2
+ import { EventActor } from "@protobase/schema";
3
+ //#region src/changes/coalesce.d.ts
4
+ /** A row of the outbox: one row's insert, update or delete, its columns as `changes.capture()` kept them. */
5
+ export type CapturedRow = {
6
+ /** The outbox id: commit order for the changes of one record. */
7
+ id: string;
8
+ transaction: string;
9
+ /** The resource whose table changed, which its trigger names. */
10
+ resource: string;
11
+ op: 'INSERT' | 'UPDATE' | 'DELETE';
12
+ old: Row | null;
13
+ new: Row | null;
14
+ /** The columns an update changed, sensitive ones included; `null` for inserts and deletes. */
15
+ changed: string[] | null;
16
+ actor: EventActor | null;
17
+ };
18
+ /**
19
+ * One record's changes in one transaction, as one: its columns at the start (`before`, none when it was inserted) and
20
+ * at commit (`after`, none when it was deleted), and for an update the columns that differ, sensitive ones included.
21
+ */
22
+ export type CoalescedChange = {
23
+ transaction: string;
24
+ resource: string;
25
+ before: Row | null;
26
+ after: Row | null;
27
+ changed: string[];
28
+ actor: EventActor | null;
29
+ /** The id of its last outbox row. */
30
+ sequence: string;
31
+ };
32
+ /**
33
+ * The outbox rows of one or more transactions, in id order, as one change per (transaction, record). An insert then a
34
+ * delete is nothing, and so is an update that ends where it started; a delete then an insert of the same key is an
35
+ * update. `keyColumns` names a resource's primary key columns, which identify its records.
36
+ */
37
+ export declare const coalesce: (rows: readonly CapturedRow[], keyColumns: (resource: string) => readonly string[]) => CoalescedChange[];
38
+ //#endregion
@@ -0,0 +1,69 @@
1
+ //#region src/changes/coalesce.ts
2
+ const sameValue = (a, b) => a === b || JSON.stringify(a) === JSON.stringify(b);
3
+ const recordKey = (row, columns, keyColumns) => JSON.stringify([
4
+ row.transaction,
5
+ row.resource,
6
+ ...keyColumns.map((column) => columns?.[column] ?? null)
7
+ ]);
8
+ const changedColumns = (before, after, listed) => [.../* @__PURE__ */ new Set([
9
+ ...listed,
10
+ ...Object.keys(before),
11
+ ...Object.keys(after)
12
+ ])].filter((column) => {
13
+ return !(column in before || column in after) || !sameValue(before[column], after[column]);
14
+ });
15
+ const merged = ({ first, last, actor, listed }) => {
16
+ const base = {
17
+ transaction: first.transaction,
18
+ resource: first.resource,
19
+ actor,
20
+ sequence: last.id
21
+ };
22
+ const before = first.op === "INSERT" ? null : first.old;
23
+ const after = last.op === "DELETE" ? null : last.new;
24
+ if (!before && !after) return [];
25
+ if (!before || !after) return [{
26
+ ...base,
27
+ before,
28
+ after,
29
+ changed: []
30
+ }];
31
+ const changed = changedColumns(before, after, listed);
32
+ return changed.length > 0 ? [{
33
+ ...base,
34
+ before,
35
+ after,
36
+ changed
37
+ }] : [];
38
+ };
39
+ /**
40
+ * The outbox rows of one or more transactions, in id order, as one change per (transaction, record). An insert then a
41
+ * delete is nothing, and so is an update that ends where it started; a delete then an insert of the same key is an
42
+ * update. `keyColumns` names a resource's primary key columns, which identify its records.
43
+ */
44
+ const coalesce = (rows, keyColumns) => {
45
+ const current = /* @__PURE__ */ new Map();
46
+ const groups = [];
47
+ for (const row of rows) {
48
+ const columns = keyColumns(row.resource);
49
+ const key = recordKey(row, row.op === "INSERT" ? row.new : row.old, columns);
50
+ let group = current.get(key);
51
+ if (!group) {
52
+ group = {
53
+ first: row,
54
+ last: row,
55
+ actor: row.actor,
56
+ listed: /* @__PURE__ */ new Set()
57
+ };
58
+ groups.push(group);
59
+ }
60
+ group.last = row;
61
+ group.actor = row.actor ?? group.actor;
62
+ for (const column of row.changed ?? []) group.listed.add(column);
63
+ current.delete(key);
64
+ current.set(row.op === "DELETE" ? key : recordKey(row, row.new, columns), group);
65
+ }
66
+ return groups.flatMap(merged);
67
+ };
68
+ //#endregion
69
+ export { coalesce };
@@ -0,0 +1,10 @@
1
+ //#region src/changes/events-options.d.ts
2
+ /** Where captured changes live in the app's database (`events` in `protobase.config.ts`). */
3
+ export type EventsOptions = {
4
+ /**
5
+ * The schema holding the outbox and the capture function, and the prefix of their transaction settings
6
+ * (`changes.actor`, `changes.capture`); default `changes`. Rename it when the database already has a schema by that name.
7
+ */
8
+ schema?: string;
9
+ };
10
+ //#endregion
@@ -0,0 +1,10 @@
1
+ //#region src/changes/events-options.ts
2
+ const schemaPattern = /^[a-z_][a-z0-9_]*$/;
3
+ /** The schema of the options, checked: it also prefixes setting names, which take plain lowercase names only. */
4
+ const changesSchema = (options = {}) => {
5
+ const schema = options.schema ?? "changes";
6
+ if (!schemaPattern.test(schema)) throw new Error(`Invalid events schema "${schema}": use lowercase letters, digits and "_"`);
7
+ return schema;
8
+ };
9
+ //#endregion
10
+ export { changesSchema };
@@ -0,0 +1,9 @@
1
+ import { ResourceChange } from "./resource-change.js";
2
+ import { EventMatch, ResourceModel } from "@protobase/schema";
3
+ //#region src/changes/match-events.d.ts
4
+ /**
5
+ * The names of the resource's events a change raises, in declaration order. The filters are checked once, here; `matches`
6
+ * are the events' code predicates by event name (`eventMatches()` of the resource).
7
+ */
8
+ export declare const eventMatcher: (model: ResourceModel, matches?: Record<string, EventMatch<any>>) => (change: ResourceChange) => string[];
9
+ //#endregion
@@ -0,0 +1,55 @@
1
+ import { checkEventFilter, evaluateFilter } from "@protobase/schema";
2
+ //#region src/changes/match-events.ts
3
+ const filterOf = (model, source) => {
4
+ const { filter, problem } = checkEventFilter(model, source);
5
+ if (!filter) throw new Error(`Resource "${model.name}": event filter "${source}" ${problem}`);
6
+ return filter;
7
+ };
8
+ const holds = (filter, record) => record !== void 0 && evaluateFilter(filter, record) === true;
9
+ const contextOf = (change) => ({
10
+ operation: change.operation,
11
+ ...change.before && { before: change.before },
12
+ ...change.after && { after: change.after },
13
+ changed: change.changed,
14
+ actor: change.actor,
15
+ source: change.source,
16
+ ...change.tenant !== void 0 && { tenant: change.tenant },
17
+ transaction: change.transaction
18
+ });
19
+ const eventTest = (model, event, match) => {
20
+ const tests = [(change) => event.operations.includes(change.operation)];
21
+ if (event.when !== void 0) {
22
+ const when = filterOf(model, event.when);
23
+ tests.push((change) => holds(when, change.after));
24
+ }
25
+ if (event.from !== void 0) {
26
+ const from = filterOf(model, event.from);
27
+ tests.push((change) => holds(from, change.before));
28
+ }
29
+ if (event.becomes !== void 0) {
30
+ const condition = filterOf(model, event.becomes);
31
+ tests.push((change) => holds(condition, change.after) && !holds(condition, change.before));
32
+ }
33
+ if (event.changed) {
34
+ const fields = event.changed;
35
+ tests.push((change) => change.changed.some((name) => fields.includes(name)));
36
+ }
37
+ if (event.match) {
38
+ if (!match) throw new Error(`Resource "${model.name}": event "${event.name}" has no match function`);
39
+ tests.push((change) => match(contextOf(change)));
40
+ }
41
+ return (change) => tests.every((test) => test(change));
42
+ };
43
+ /**
44
+ * The names of the resource's events a change raises, in declaration order. The filters are checked once, here; `matches`
45
+ * are the events' code predicates by event name (`eventMatches()` of the resource).
46
+ */
47
+ const eventMatcher = (model, matches = {}) => {
48
+ const tests = (model.events ?? []).map((event) => ({
49
+ name: event.name,
50
+ test: eventTest(model, event, matches[event.name])
51
+ }));
52
+ return (change) => tests.filter(({ test }) => test(change)).map(({ name }) => name);
53
+ };
54
+ //#endregion
55
+ export { eventMatcher };
@@ -0,0 +1,27 @@
1
+ import { Row, TenantValue } from "../types.js";
2
+ import { CoalescedChange } from "./coalesce.js";
3
+ import { EventActor, EventOperation, ResourceModel } from "@protobase/schema";
4
+ //#region src/changes/resource-change.d.ts
5
+ /** One record's change in one transaction, by field name: what events are matched against and payloads are built from. */
6
+ export type ResourceChange = {
7
+ resource: string;
8
+ /** The record's key as its URL has it. */
9
+ key: string;
10
+ operation: EventOperation;
11
+ /** The record before and after the change, without sensitive fields. */
12
+ before?: Row;
13
+ after?: Row;
14
+ /** The fields an update changed, sensitive ones included. */
15
+ changed: string[];
16
+ actor: EventActor | null;
17
+ source: 'api' | 'database';
18
+ tenant?: TenantValue;
19
+ transaction: string;
20
+ /** Grows with every change of a record, so receivers can drop stale events. */
21
+ sequence: string;
22
+ };
23
+ /** A captured row by field name, fields without a captured column left out. */
24
+ export declare const recordOf: (model: ResourceModel, columns: Row) => Row;
25
+ /** A coalesced change of the resource's table, by field name, with deletes and undeletes told apart from updates. */
26
+ export declare const resourceChange: (model: ResourceModel, change: CoalescedChange) => ResourceChange;
27
+ //#endregion
@@ -0,0 +1,43 @@
1
+ import { encodeKey } from "@protobase/schema";
2
+ //#region src/changes/resource-change.ts
3
+ const fieldValue = (type, value) => {
4
+ if (value === null || value === void 0) return value;
5
+ if (type === "bigint" || type === "decimal") return String(value);
6
+ if (type === "integer") return Number(value);
7
+ return value;
8
+ };
9
+ /** A captured row by field name, fields without a captured column left out. */
10
+ const recordOf = (model, columns) => Object.fromEntries(Object.values(model.fields).filter((field) => Object.hasOwn(columns, field.column)).map((field) => [field.name, fieldValue(field.type, columns[field.column])]));
11
+ const operationOf = (model, before, after) => {
12
+ if (!before) return "create";
13
+ if (!after) return "delete";
14
+ if (!model.softDelete) return "update";
15
+ const wasDeleted = before[model.softDelete] != null;
16
+ const isDeleted = after[model.softDelete] != null;
17
+ if (!wasDeleted && isDeleted) return "delete";
18
+ if (wasDeleted && !isDeleted) return "undelete";
19
+ return "update";
20
+ };
21
+ const fieldsOf = (model, columns) => Object.values(model.fields).filter((field) => columns.includes(field.column)).map((field) => field.name);
22
+ /** A coalesced change of the resource's table, by field name, with deletes and undeletes told apart from updates. */
23
+ const resourceChange = (model, change) => {
24
+ const before = change.before ? recordOf(model, change.before) : void 0;
25
+ const after = change.after ? recordOf(model, change.after) : void 0;
26
+ const record = after ?? before;
27
+ const tenant = model.tenant === void 0 ? void 0 : record[model.tenant];
28
+ return {
29
+ resource: model.name,
30
+ key: encodeKey(model.primaryKey.map((name) => record[name])),
31
+ operation: operationOf(model, before, after),
32
+ ...before && { before },
33
+ ...after && { after },
34
+ changed: fieldsOf(model, change.changed),
35
+ actor: change.actor,
36
+ source: change.actor ? "api" : "database",
37
+ ...tenant != null && { tenant },
38
+ transaction: change.transaction,
39
+ sequence: change.sequence
40
+ };
41
+ };
42
+ //#endregion
43
+ export { recordOf, resourceChange };
@@ -1,6 +1,9 @@
1
1
  import { AdminAuth } from "./better-auth/create-auth.js";
2
- import { ResourceSource } from "./resource-source.js";
2
+ import { EventsOptions } from "./changes/events-options.js";
3
+ import { FilesOptions } from "./files/options.js";
3
4
  import { AdminEnv, Authenticator, PageSource, ViewSource } from "./types.js";
5
+ import { ResourceSource } from "./resource-source.js";
6
+ import { FunctionSource } from "./functions/define-function.js";
4
7
  import { AdminOptions } from "./admin-options.js";
5
8
  import { Hono } from "hono";
6
9
  import { UserMenuSource } from "@protobase/schema";
@@ -17,8 +20,14 @@ export type CreateAdminInput = {
17
20
  authenticate: Authenticator;
18
21
  /** A Better Auth instance (see `createAuth`): its handler is mounted at `/api/auth`. */
19
22
  auth?: AdminAuth;
23
+ /** API functions of the app's own, each served at `<systemPath>/functions/<name>` (see `defineFunction`). */
24
+ functions?: Record<string, FunctionSource>;
25
+ /** Where file fields keep their bytes (see `FilesOptions`). */
26
+ files?: FilesOptions;
27
+ /** Where the changes that resources' events are raised from are captured (see `EventsOptions`). */
28
+ events?: EventsOptions;
20
29
  options?: AdminOptions;
21
30
  };
22
31
  /** The admin REST API as a Hono app. It uses only Web APIs, so it runs on Node, Workers and anywhere else Hono does. */
23
- export declare const createAdmin: ({ resources, views, pages, userMenu, db, authenticate, auth, options }: CreateAdminInput) => Hono<AdminEnv, import("hono/types").BlankSchema, "/">;
32
+ export declare const createAdmin: ({ resources, views, pages, userMenu, db, authenticate, auth, functions, files: filesOptions, events, options }: CreateAdminInput) => Hono<AdminEnv, import("hono/types").BlankSchema, "/">;
24
33
  //#endregion
@@ -4,9 +4,16 @@ import { assistantRecords } from "./assistant/records.js";
4
4
  import { builtInAssistant } from "./assistant/built-in-assistant.js";
5
5
  import { consoleAuditQueue } from "./audit/console-queue.js";
6
6
  import { authBasePath } from "./better-auth/create-auth.js";
7
+ import { organizationRoutes } from "./better-auth/organizations/admin-routes.js";
7
8
  import { statusRoute } from "./better-auth/status-route.js";
9
+ import { changesSchema } from "./changes/events-options.js";
8
10
  import { checkShell } from "./check-shell.js";
9
11
  import { checkViews } from "./check-views.js";
12
+ import { createFilesRuntime } from "./files/runtime.js";
13
+ import { fileRoutes } from "./files/routes.js";
14
+ import { toApiFunction } from "./functions/define-function.js";
15
+ import { callerRecords } from "./functions/records.js";
16
+ import { functionRoutes } from "./functions/routes.js";
10
17
  import { runtimeUrl } from "./runtime/runtime-settings.js";
11
18
  import { createMeta } from "./meta.js";
12
19
  import { buildRegistry } from "./registry.js";
@@ -20,9 +27,10 @@ import { Hono } from "hono";
20
27
  import { checkPages } from "@protobase/layout";
21
28
  //#region src/create-admin.ts
22
29
  /** The admin REST API as a Hono app. It uses only Web APIs, so it runs on Node, Workers and anywhere else Hono does. */
23
- const createAdmin = ({ resources, views = [], pages = [], userMenu, db, authenticate, auth, options = {} }) => {
30
+ const createAdmin = ({ resources, views = [], pages = [], userMenu, db, authenticate, auth, functions = {}, files: filesOptions, events, options = {} }) => {
24
31
  const basePath = options.basePath ?? "/api/v1";
25
32
  const registry = buildRegistry(resources);
33
+ const apiFunctions = Object.fromEntries(Object.entries(functions).map(([name, source]) => [name, toApiFunction(name, source)]));
26
34
  const viewModels = views.map((view) => view.toModel());
27
35
  const userMenuModel = userMenu?.toUserMenuModel();
28
36
  const pageModels = pages.map((entry) => entry.toModel());
@@ -35,6 +43,12 @@ const createAdmin = ({ resources, views = [], pages = [], userMenu, db, authenti
35
43
  const systemPath = basePath.slice(0, basePath.lastIndexOf("/"));
36
44
  const assistant = assistantSettings(options.assistant, globalThis.process?.env ?? {});
37
45
  const runtime = runtimeUrl(options.runtime, globalThis.process?.env ?? {});
46
+ const files = createFilesRuntime({
47
+ ...filesOptions && { options: filesOptions },
48
+ env: globalThis.process?.env ?? {},
49
+ models: registry.entries.map((entry) => entry.model),
50
+ systemPath
51
+ });
38
52
  const deps = {
39
53
  db,
40
54
  registry,
@@ -53,7 +67,9 @@ const createAdmin = ({ resources, views = [], pages = [], userMenu, db, authenti
53
67
  },
54
68
  rowPermissions: (resource) => typeof options.rowPermissions === "object" ? !options.rowPermissions.except.includes(resource) : options.rowPermissions !== false,
55
69
  ...assistant && { assistant: assistant.kind === "external" ? assistant.url : `${systemPath}/assistant` },
56
- ...runtime && { runtime }
70
+ ...runtime && { runtime },
71
+ ...files && { files },
72
+ ...registry.entries.some((entry) => entry.model.events) && { actorSetting: `${changesSchema(events)}.actor` }
57
73
  };
58
74
  const meta = createMeta(deps);
59
75
  const onError = createErrorHandler(options.onUnhandledError);
@@ -83,6 +99,12 @@ const createAdmin = ({ resources, views = [], pages = [], userMenu, db, authenti
83
99
  app.onError(onError);
84
100
  if (auth) {
85
101
  app.route(authBasePath, statusRoute(auth));
102
+ if (auth.organizations) app.route(authBasePath, organizationRoutes({
103
+ auth,
104
+ audit: deps.audit,
105
+ registry,
106
+ db
107
+ }));
86
108
  app.on(["GET", "POST"], `${authBasePath}/*`, (c) => auth.handler(c.req.raw));
87
109
  }
88
110
  if (assistant?.kind === "built-in") {
@@ -99,7 +121,16 @@ const createAdmin = ({ resources, views = [], pages = [], userMenu, db, authenti
99
121
  report: options.onUnhandledError
100
122
  }));
101
123
  }
124
+ app.route("/", functionRoutes({
125
+ basePath: `${systemPath}/functions`,
126
+ functions: apiFunctions,
127
+ authenticate,
128
+ db,
129
+ records: (session) => callerRecords(deps, session),
130
+ onError
131
+ }));
102
132
  app.route("/", batchRoute(deps, authenticate, meta));
133
+ app.route("/", fileRoutes(deps, authenticate));
103
134
  app.route(basePath, api);
104
135
  app.route(deps.systemPath || "/", system);
105
136
  return app;
package/dist/deps.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import "./types.js";
2
+ import "./files/runtime.js";
2
3
  import "./registry.js";
3
4
  import "./scan-guard.js";
4
5
  import { PageModel } from "@protobase/layout";
@@ -0,0 +1,80 @@
1
+ import { badRequest } from "../problem.js";
2
+ import { baseOf, parseUri } from "./uri.js";
3
+ import { tenantScope } from "../tenant.js";
4
+ import { readTicket } from "./ticket.js";
5
+ //#region src/files/attach.ts
6
+ /** The ticket scope of a caller's upload for one field. */
7
+ const ticketScope = (access, field) => ({
8
+ tenant: String(tenantScope(access.entry, access.session)?.tenantValue ?? ""),
9
+ resource: access.entry.name,
10
+ field,
11
+ user: String(access.session.user.id)
12
+ });
13
+ const fileFields = (access) => Object.values(access.full.fields).filter((field) => field.file);
14
+ /**
15
+ * File values of a validated create or update body, made ready to store: a ticket from `:upload` becomes its clean URI
16
+ * plus the derived fields it carries, `null` clears the field and its derived fields, and the stored value itself is a
17
+ * no-op. Anything else is refused, so a row can only point at a file its writer uploaded for that field.
18
+ */
19
+ const attachFiles = async (access, values, current) => {
20
+ const names = Object.keys(values).filter((name) => access.full.fields[name]?.file);
21
+ const files = access.deps.files;
22
+ if (names.length === 0 || !files) return values;
23
+ const result = { ...values };
24
+ const errors = [];
25
+ const now = Math.floor(Date.now() / 1e3);
26
+ for (const name of names) {
27
+ const field = access.full.fields[name];
28
+ const derive = field.file.derive ?? [];
29
+ const value = values[name];
30
+ if (value === null) {
31
+ for (const target of derive) result[target] = null;
32
+ continue;
33
+ }
34
+ if (current && value === current[name]) {
35
+ delete result[name];
36
+ continue;
37
+ }
38
+ const ticket = await readTicket(files.signer, String(value), ticketScope(access, name), now);
39
+ if (!ticket.ok || ticket.ref.provider !== field.file.provider) {
40
+ const expired = !ticket.ok && ticket.reason === "expired";
41
+ errors.push({
42
+ field: name,
43
+ code: "invalid-value",
44
+ message: expired ? "The upload expired; upload the file again" : "Not a file uploaded for this field; upload it with :upload first"
45
+ });
46
+ continue;
47
+ }
48
+ result[name] = ticket.uri;
49
+ for (const target of derive) result[target] = Object.hasOwn(ticket.derived, target) ? ticket.derived[target] : null;
50
+ }
51
+ if (errors.length > 0) throw badRequest("invalid-record", `The request body is invalid: ${errors[0].field}: ${errors[0].message}`, { errors });
52
+ return result;
53
+ };
54
+ /**
55
+ * Schedules the delete of every file a write took out of its row: a replaced or cleared value, or all of a hard-deleted
56
+ * row's files, each due after its provider's retention. It runs inside the write transaction, before the commit: if
57
+ * the write rolls back, the file is still referenced when the entry is due, and stays.
58
+ */
59
+ const scheduleRemovedFiles = async (access, operation, before, after) => {
60
+ const files = access.deps.files;
61
+ if (!files || !before || operation === "create" || operation === "undelete") return;
62
+ if (operation === "delete" && access.full.softDelete) return;
63
+ const now = Date.now();
64
+ const removed = fileFields(access).flatMap((field) => {
65
+ const old = before[field.name];
66
+ if (typeof old !== "string") return [];
67
+ const next = after?.[field.name];
68
+ if (typeof next === "string" && baseOf(next) === baseOf(old)) return [];
69
+ const ref = parseUri(old);
70
+ if (!ref || !files.providers[ref.provider]) return [];
71
+ return [{
72
+ provider: ref.provider,
73
+ path: ref.path,
74
+ due: new Date(now + files.retention(ref.provider))
75
+ }];
76
+ });
77
+ if (removed.length > 0) await files.schedule.add(removed);
78
+ };
79
+ //#endregion
80
+ export { attachFiles, scheduleRemovedFiles, ticketScope };
@@ -0,0 +1,8 @@
1
+ import { ResourceModel } from "@protobase/schema";
2
+ import { Db } from "@protobase/query";
3
+ //#region src/files/cleanup-runner.d.ts
4
+ export type CleanupReport = {
5
+ deleted: string[];
6
+ kept: string[];
7
+ };
8
+ //#endregion
@@ -0,0 +1,58 @@
1
+ import { sql } from "kysely";
2
+ //#region src/files/cleanup-runner.ts
3
+ /** Every declared file column, once per table and column even when two resources share them. */
4
+ const fileColumns = (models) => {
5
+ const seen = /* @__PURE__ */ new Map();
6
+ for (const model of models) for (const field of Object.values(model.fields)) {
7
+ if (field.type !== "file") continue;
8
+ seen.set(JSON.stringify([
9
+ model.table.schema ?? "",
10
+ model.table.name,
11
+ field.column
12
+ ]), {
13
+ table: model.table,
14
+ column: field.column
15
+ });
16
+ }
17
+ return [...seen.values()];
18
+ };
19
+ const chunk = (items, size) => Array.from({ length: Math.ceil(items.length / size) }, (_, i) => items.slice(i * size, (i + 1) * size));
20
+ /** The identities (`provider:path`) among `uris` that some row still holds. */
21
+ const referenced = async (db, columns, uris) => {
22
+ const found = /* @__PURE__ */ new Set();
23
+ for (const { table, column } of columns) {
24
+ const value = sql`split_part(${sql.id(column)}, '?', 1)`;
25
+ const name = table.schema ? sql.id(table.schema, table.name) : sql.id(table.name);
26
+ const rows = await sql`select distinct ${value} as uri from ${name} where ${value} = any(${uris})`.execute(db);
27
+ for (const row of rows.rows) found.add(row.uri);
28
+ }
29
+ return found;
30
+ };
31
+ /**
32
+ * Handles every scheduled delete that is due: right before deleting, it checks that no declared file column holds the
33
+ * file; one that is referenced again (a rolled-back write, a restore, a shared value) is kept and its entry dropped.
34
+ * Uploads never attached are deleted here too, after their ticket expired. Running it twice at once is harmless.
35
+ */
36
+ const runCleanup = async ({ db, columns, providers, schedule, now = /* @__PURE__ */ new Date() }) => {
37
+ const deleted = /* @__PURE__ */ new Set();
38
+ const kept = /* @__PURE__ */ new Set();
39
+ for (const entries of chunk(await schedule.due(now), 500)) {
40
+ const uris = [...new Set(entries.map((entry) => `${entry.provider}:${entry.path}`))];
41
+ const inUse = await referenced(db, columns, uris);
42
+ for (const entry of entries) {
43
+ const uri = `${entry.provider}:${entry.path}`;
44
+ if (inUse.has(uri)) kept.add(uri);
45
+ else if (!deleted.has(uri)) {
46
+ await providers[entry.provider].store.delete(entry.path);
47
+ deleted.add(uri);
48
+ }
49
+ await schedule.done(entry);
50
+ }
51
+ }
52
+ return {
53
+ deleted: [...deleted],
54
+ kept: [...kept]
55
+ };
56
+ };
57
+ //#endregion
58
+ export { fileColumns, runCleanup };