@protobase/server 0.43.1 → 0.44.0

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.
@@ -20,13 +20,21 @@ export type AdminOptions = {
20
20
  * `PROTOBASE_SECRET_KEY`. Without either, or with `false`, the app serves neither.
21
21
  */
22
22
  apiKeys?: ApiKeysOptions | false;
23
- /** Applied to every request's transaction with SET LOCAL. Default 15000. */
23
+ /**
24
+ * How long a query of an API request may run, set on every request's transaction with SET LOCAL; a query that runs
25
+ * longer is a 504 that names the request. Default 1000: a query that needs longer wants an index or a simpler filter,
26
+ * or belongs in a function or job with `longQueries`.
27
+ */
24
28
  statementTimeoutMs?: number;
25
29
  /** Reads that filter, sort or count values by a column no index serves. Default mode `reject`. */
26
30
  scanGuard?: {
27
31
  mode?: ScanGuardMode;
28
32
  };
29
- /** Runs inside the write transaction after each create, update and delete. */
33
+ /**
34
+ * Runs inside the write transaction after each create, update and delete, with that transaction: database work only.
35
+ * While a hook waits on anything else, such as an HTTP call, the transaction stays open with the row locked; a job the
36
+ * hook enqueues does such work instead.
37
+ */
30
38
  writeHooks?: PipelineHook[];
31
39
  /**
32
40
  * The project's roles (from `defineRoles`): an operation without an access rule needs the matching capability. Their
@@ -44,11 +52,16 @@ export type AdminOptions = {
44
52
  };
45
53
  /** Receives an event for every reveal of a sensitive field. Default: printed to the console, nothing kept. */
46
54
  audit?: AuditQueue;
47
- /** Called with every error the API turns into a 500, and the request it failed, so the host can report it. */
55
+ /**
56
+ * Called with every error the API turns into a 500, and with the problem of every query the database stopped at its
57
+ * statement timeout and every request that found all database connections busy, and the request it failed, so the
58
+ * host can report it.
59
+ */
48
60
  onUnhandledError?: (error: unknown, request?: Request) => void;
49
61
  /**
50
- * Where the app's errors go: the server's 500s and crashes, and the admin app's errors in the browser (see
51
- * `ErrorReportingOptions`). Defaults to the DSN the platform passes in `PROTOBASE_SENTRY_DSN`; `false` turns it off.
62
+ * Where the app's errors go: the server's 500s, stopped queries and crashes, and the admin app's errors in the
63
+ * browser (see `ErrorReportingOptions`). Defaults to the DSN the platform passes in `PROTOBASE_SENTRY_DSN`; `false`
64
+ * turns it off.
52
65
  */
53
66
  errorReporting?: ErrorReportingOptions | false;
54
67
  /** The assistant in the admin app's dock: a backend elsewhere or the built-in one (see `AssistantOptions`); `false` turns it off. */
@@ -0,0 +1,47 @@
1
+ //#region src/app-pool.d.ts
2
+ /** How long a query waits for a free connection of the app's pool before it fails with `ConnectionsBusy`. */
3
+ export declare const poolWaitMs = 2000;
4
+ /** How many connections the app's pool opens: `PROTOBASE_DB_CONNECTIONS`, such as its plan's limit, or 10. */
5
+ export declare const readDbConnections: (env: Record<string, string | undefined>) => number;
6
+ type TypeParser = (oid: number, format?: 'text' | 'binary') => unknown;
7
+ /** The settings `appPool` and `longQueryPool` open node-postgres's pool with. */
8
+ export type AppPoolConfig = {
9
+ connectionString: string;
10
+ max: number;
11
+ connectionTimeoutMillis: number;
12
+ statement_timeout?: number;
13
+ types: {
14
+ getTypeParser: TypeParser;
15
+ };
16
+ };
17
+ type PgPool = {
18
+ readonly ending: boolean;
19
+ on(event: 'connect', listener: (client: {
20
+ on(event: 'error', listener: (error: Error) => void): unknown;
21
+ }) => void): unknown;
22
+ on(event: 'error', listener: (error: Error) => void): unknown;
23
+ };
24
+ /** The part of node-postgres `appPool` needs: the app's own `import pg from 'pg'`. */
25
+ export type PgModule<P extends PgPool> = {
26
+ Pool: new (config: AppPoolConfig) => P;
27
+ types: {
28
+ getTypeParser: TypeParser;
29
+ };
30
+ };
31
+ /**
32
+ * The app's one pool on the database at `url`, with `max` connections (`PROTOBASE_DB_CONNECTIONS` by default): the
33
+ * runtime's, which `protobase dev` and `serve` open, and the one the app's own code uses, such as its Better Auth store.
34
+ * Whoever asks first opens it, and everyone in the process shares it, across a dev server's reloads too, until it ends;
35
+ * so the app never opens more connections than it may. Date and timestamp (without zone) come back as text, which the
36
+ * API formats itself, a dropped connection is printed rather than fatal, and a query waits at most `poolWaitMs` for a
37
+ * free connection.
38
+ */
39
+ export declare const appPool: <P extends PgPool>(pg: PgModule<P>, url: string, max?: number) => P;
40
+ /**
41
+ * The runtime's pool of one connection on `url` for the queries of functions and jobs with `longQueries`: it asks for
42
+ * `statement_timeout` = `longQueryTimeoutMs` when it logs in, which Postgres takes, and the platform's pooler too, since
43
+ * it tracks that setting (`track_extra_parameters`). A query waits for that connection as long as one may run on it.
44
+ * It opens its connection on first use, and nobody else shares it.
45
+ */
46
+ export declare const longQueryPool: <P extends PgPool>(pg: PgModule<P>, url: string) => P;
47
+ //#endregion
@@ -0,0 +1,57 @@
1
+ import { longQueryTimeoutMs } from "./statement-timeout.js";
2
+ import { handleDroppedConnections } from "./dropped-connections.js";
3
+ //#region src/app-pool.ts
4
+ /** How long a query waits for a free connection of the app's pool before it fails with `ConnectionsBusy`. */
5
+ const poolWaitMs = 2e3;
6
+ /** How many connections the app's pool opens: `PROTOBASE_DB_CONNECTIONS`, such as its plan's limit, or 10. */
7
+ const readDbConnections = (env) => {
8
+ if (!env.PROTOBASE_DB_CONNECTIONS) return 10;
9
+ const connections = Number(env.PROTOBASE_DB_CONNECTIONS);
10
+ if (!Number.isInteger(connections) || connections < 1) throw new Error(`PROTOBASE_DB_CONNECTIONS must be a whole number of connections, at least 1, not "${env.PROTOBASE_DB_CONNECTIONS}"`);
11
+ return connections;
12
+ };
13
+ const textDates = (pg) => ({ getTypeParser: (oid, format) => oid === 1082 || oid === 1114 ? (value) => value : pg.types.getTypeParser(oid, format) });
14
+ const store = globalThis;
15
+ /**
16
+ * The app's one pool on the database at `url`, with `max` connections (`PROTOBASE_DB_CONNECTIONS` by default): the
17
+ * runtime's, which `protobase dev` and `serve` open, and the one the app's own code uses, such as its Better Auth store.
18
+ * Whoever asks first opens it, and everyone in the process shares it, across a dev server's reloads too, until it ends;
19
+ * so the app never opens more connections than it may. Date and timestamp (without zone) come back as text, which the
20
+ * API formats itself, a dropped connection is printed rather than fatal, and a query waits at most `poolWaitMs` for a
21
+ * free connection.
22
+ */
23
+ const appPool = (pg, url, max = readDbConnections(globalThis.process?.env ?? {})) => {
24
+ const pools = store.__protobaseAppPools ??= /* @__PURE__ */ new Map();
25
+ for (const [key, entry] of pools) if (entry.pool.ending) pools.delete(key);
26
+ const known = pools.get(url);
27
+ if (known) {
28
+ if (known.max !== max) throw new Error(`The app's pool on this database is open with ${known.max} connections, so it cannot open with ${max}: give both the same PROTOBASE_DB_CONNECTIONS`);
29
+ return known.pool;
30
+ }
31
+ const pool = handleDroppedConnections(new pg.Pool({
32
+ connectionString: url,
33
+ max,
34
+ connectionTimeoutMillis: poolWaitMs,
35
+ types: textDates(pg)
36
+ }));
37
+ pools.set(url, {
38
+ pool,
39
+ max
40
+ });
41
+ return pool;
42
+ };
43
+ /**
44
+ * The runtime's pool of one connection on `url` for the queries of functions and jobs with `longQueries`: it asks for
45
+ * `statement_timeout` = `longQueryTimeoutMs` when it logs in, which Postgres takes, and the platform's pooler too, since
46
+ * it tracks that setting (`track_extra_parameters`). A query waits for that connection as long as one may run on it.
47
+ * It opens its connection on first use, and nobody else shares it.
48
+ */
49
+ const longQueryPool = (pg, url) => handleDroppedConnections(new pg.Pool({
50
+ connectionString: url,
51
+ max: 1,
52
+ connectionTimeoutMillis: longQueryTimeoutMs,
53
+ statement_timeout: longQueryTimeoutMs,
54
+ types: textDates(pg)
55
+ }));
56
+ //#endregion
57
+ export { appPool, longQueryPool, poolWaitMs, readDbConnections };
@@ -1,9 +1,14 @@
1
1
  //#region src/background-work.d.ts
2
2
  /**
3
3
  * Runs `work` as background work: function calls and jobs, as opposed to page and API requests. A pool that knows,
4
- * such as the runtime's, keeps a connection free for requests however many connections background work holds.
4
+ * such as the runtime's, keeps a connection free for requests however many connections background work holds, and
5
+ * gives work with `longQueries` a connection whose queries may run up to `longQueryTimeoutMs`.
5
6
  */
6
- export declare const runInBackground: <T>(work: () => T) => T;
7
+ export declare const runInBackground: <T>(work: () => T, options?: {
8
+ longQueries?: boolean;
9
+ }) => T;
7
10
  /** Whether the caller runs inside `runInBackground`. */
8
11
  export declare const isBackgroundWork: () => boolean;
12
+ /** Whether the caller runs inside `runInBackground` with `longQueries`. */
13
+ export declare const wantsLongQueries: () => boolean;
9
14
  //#endregion
@@ -3,10 +3,13 @@ import { AsyncLocalStorage } from "node:async_hooks";
3
3
  const background = new AsyncLocalStorage();
4
4
  /**
5
5
  * Runs `work` as background work: function calls and jobs, as opposed to page and API requests. A pool that knows,
6
- * such as the runtime's, keeps a connection free for requests however many connections background work holds.
6
+ * such as the runtime's, keeps a connection free for requests however many connections background work holds, and
7
+ * gives work with `longQueries` a connection whose queries may run up to `longQueryTimeoutMs`.
7
8
  */
8
- const runInBackground = (work) => background.run(true, work);
9
+ const runInBackground = (work, options = {}) => background.run({ longQueries: options.longQueries === true }, work);
9
10
  /** Whether the caller runs inside `runInBackground`. */
10
- const isBackgroundWork = () => background.getStore() === true;
11
+ const isBackgroundWork = () => background.getStore() !== void 0;
12
+ /** Whether the caller runs inside `runInBackground` with `longQueries`. */
13
+ const wantsLongQueries = () => background.getStore()?.longQueries === true;
11
14
  //#endregion
12
- export { isBackgroundWork, runInBackground };
15
+ export { isBackgroundWork, runInBackground, wantsLongQueries };
@@ -46,7 +46,7 @@ const moveAside = async (trx, access, field, keys) => {
46
46
  * so that a unique index such as (invoice_id, position) is never violated half way. Everything checks out before any row moves,
47
47
  * and with the position's `after` the new order must keep each record below the records it depends on (see `checkRankAfter`).
48
48
  */
49
- const reorderIn = async (trx, deps, access, input) => {
49
+ const reorderIn = async (trx, deps, access, input, removed) => {
50
50
  const { entry, full } = access;
51
51
  requireOperation(access, "update");
52
52
  const field = full.fields[input.field];
@@ -75,7 +75,7 @@ const reorderIn = async (trx, deps, access, input) => {
75
75
  const results = [];
76
76
  for (const [i, key] of input.keys.entries()) {
77
77
  const plan = plans.get(i);
78
- results.push(await shown(access, plan ? await applyUpdate(trx, deps, access, key, plan) : current[i]));
78
+ results.push(await shown(access, plan ? await applyUpdate(trx, deps, access, key, plan, removed) : current[i]));
79
79
  }
80
80
  await checkRankAfter(trx, deps, access, input.field, changing.map((i) => ({
81
81
  key: input.keys[i],
@@ -2,9 +2,10 @@ import { HttpProblem, badRequest, notFound } from "../problem.js";
2
2
  import { requestAccess } from "../request-access.js";
3
3
  import { actorSettings, wakeDispatch } from "../changes/actor.js";
4
4
  import { problemOf } from "../error-handler.js";
5
+ import { scheduleRemovedFiles } from "../files/attach.js";
5
6
  import { writeTransaction } from "../transactions.js";
6
7
  import { keyOf } from "../write-shared.js";
7
- import { writeIn } from "../write-pipeline.js";
8
+ import { checkBeforeWrite, writeIn } from "../write-pipeline.js";
8
9
  import { resolveData, resolveKey } from "./refs.js";
9
10
  import { reorderIn } from "./reorder.js";
10
11
  //#region src/batch/run-batch.ts
@@ -25,7 +26,8 @@ const atOperation = (error, index, operation) => {
25
26
  };
26
27
  /**
27
28
  * Runs the operations in order inside ONE transaction, each through the normal write pipeline (validation, access with row
28
- * filters and record-level rules, tenant, ETags, hooks). Any failure rolls the whole batch back and names the failing operation.
29
+ * filters and record-level rules, tenant, ETags, hooks), after the checks that come before it. Any failure rolls the
30
+ * whole batch back and names the failing operation.
29
31
  */
30
32
  const runBatch = async (deps, session, operations) => {
31
33
  const refs = operations.flatMap((operation) => operation.op === "create" && operation.ref ? [operation.ref] : []);
@@ -38,6 +40,17 @@ const runBatch = async (deps, session, operations) => {
38
40
  accesses.set(entry.name, access);
39
41
  return access;
40
42
  };
43
+ for (const [index, operation] of operations.entries()) {
44
+ if (operation.op !== "create" && operation.op !== "update") continue;
45
+ await checkBeforeWrite(deps, {
46
+ resource: operation.resource,
47
+ operation: operation.op,
48
+ body: operation.data
49
+ }).catch((error) => {
50
+ throw atOperation(error, index, operation);
51
+ });
52
+ }
53
+ const removed = [];
41
54
  const written = await writeTransaction(deps.db, deps.statementTimeoutMs, async (trx) => {
42
55
  const created = /* @__PURE__ */ new Map();
43
56
  const results = [];
@@ -48,7 +61,7 @@ const runBatch = async (deps, session, operations) => {
48
61
  const written = await writeIn(trx, deps, access, {
49
62
  operation: "create",
50
63
  body: resolveData(created, operation.data)
51
- });
64
+ }, removed);
52
65
  if (operation.ref) created.set(operation.ref, {
53
66
  entry,
54
67
  key: keyOf(access, written.record),
@@ -66,7 +79,7 @@ const runBatch = async (deps, session, operations) => {
66
79
  key: resolveKey(created, entry, operation.key),
67
80
  body: resolveData(created, operation.data),
68
81
  ifMatch: operation.etag ?? null
69
- });
82
+ }, removed);
70
83
  results.push({
71
84
  op: "update",
72
85
  resource: entry.name,
@@ -77,7 +90,7 @@ const runBatch = async (deps, session, operations) => {
77
90
  operation: "delete",
78
91
  key: resolveKey(created, entry, operation.key),
79
92
  ifMatch: operation.etag ?? null
80
- });
93
+ }, removed);
81
94
  results.push({
82
95
  op: "delete",
83
96
  resource: entry.name,
@@ -90,7 +103,7 @@ const runBatch = async (deps, session, operations) => {
90
103
  keys,
91
104
  etags: operation.etags ?? {},
92
105
  start: operation.start ?? 1
93
- });
106
+ }, removed);
94
107
  results.push({
95
108
  op: "reorder",
96
109
  resource: entry.name,
@@ -102,6 +115,7 @@ const runBatch = async (deps, session, operations) => {
102
115
  }
103
116
  return results;
104
117
  }, actorSettings(deps, session));
118
+ await scheduleRemovedFiles(deps, removed);
105
119
  wakeDispatch(deps);
106
120
  return written;
107
121
  };
@@ -0,0 +1,12 @@
1
+ //#region src/connections-busy.d.ts
2
+ /**
3
+ * Every connection of the app's pool stayed in use for as long as a query may wait for one: queries that run long or
4
+ * transactions left open hold them.
5
+ */
6
+ export declare class ConnectionsBusy extends Error {
7
+ readonly connections: number;
8
+ readonly waitedMs: number;
9
+ name: string;
10
+ constructor(connections: number, waitedMs: number, options?: ErrorOptions);
11
+ }
12
+ //#endregion
@@ -0,0 +1,20 @@
1
+ import { HttpProblem } from "./problem.js";
2
+ //#region src/connections-busy.ts
3
+ /**
4
+ * Every connection of the app's pool stayed in use for as long as a query may wait for one: queries that run long or
5
+ * transactions left open hold them.
6
+ */
7
+ var ConnectionsBusy = class extends Error {
8
+ connections;
9
+ waitedMs;
10
+ name = "ConnectionsBusy";
11
+ constructor(connections, waitedMs, options) {
12
+ super(`All ${connections} database connections stayed busy for ${waitedMs / 1e3} s`, options);
13
+ this.connections = connections;
14
+ this.waitedMs = waitedMs;
15
+ }
16
+ };
17
+ /** The 503 of `subject` (such as `GET /api/v1/tasks`), which found every connection busy and so did not run. */
18
+ const connectionsBusyProblem = (error, subject) => new HttpProblem(503, "connections-busy", "Service Unavailable", `${subject} waited ${error.waitedMs / 1e3} s for one of the app's ${error.connections} database connections, which all stayed busy, so it did not run. Queries that run long or transactions left open hold them.`, {}, { "Retry-After": "1" });
19
+ //#endregion
20
+ export { ConnectionsBusy, connectionsBusyProblem };
@@ -4,6 +4,7 @@ import { unauthorized } from "./problem.js";
4
4
  import { fillHook } from "./ai/fill-hook.js";
5
5
  import { isCaptured } from "./changes/capture-sql.js";
6
6
  import { wakeDispatch } from "./changes/actor.js";
7
+ import "./statement-timeout.js";
7
8
  import { createErrorHandler } from "./error-handler.js";
8
9
  import { assistantRecords } from "./assistant/records.js";
9
10
  import { fillJob } from "./ai/fill-job.js";
@@ -49,7 +50,7 @@ import { callerRecords } from "./functions/records.js";
49
50
  import { toApiFunction } from "./functions/define-function.js";
50
51
  import { createWebhooks } from "./webhooks/webhooks.js";
51
52
  import { webhookTable } from "./webhooks/table/webhook-table.js";
52
- import { subscriptionWriteCheck } from "./webhooks/table/validate-hook.js";
53
+ import { subscriptionUrlCheck, subscriptionWriteCheck } from "./webhooks/table/validate-hook.js";
53
54
  import { visitorsBrowse, withAnonymous } from "./anonymous.js";
54
55
  import { checkBottomBars, checkShell } from "./check-shell.js";
55
56
  import { checkOwnerFields } from "./check-owners.js";
@@ -181,14 +182,16 @@ const createAdmin = ({ resources: appResources, views: appViews = [], pages = []
181
182
  bottomBars: bottomBarModels,
182
183
  basePath,
183
184
  systemPath,
184
- statementTimeoutMs: options.statementTimeoutMs ?? 15e3,
185
+ statementTimeoutMs: options.statementTimeoutMs ?? 1e3,
185
186
  guard: createScanGuard(db, options.scanGuard),
186
187
  hooks: [
187
188
  ...options.writeHooks ?? [],
188
- ...table ? [subscriptionWriteCheck(registry.entries.map((entry) => entry.model), table.checkUrl)] : [],
189
+ ...table ? [subscriptionWriteCheck(registry.entries.map((entry) => entry.model))] : [],
189
190
  ...fill ? [fillHook(() => deps, fill)] : []
190
191
  ],
192
+ writeChecks: table?.checkUrl ? [subscriptionUrlCheck(table.checkUrl)] : [],
191
193
  audit: options.audit ?? consoleAuditQueue(),
194
+ report: options.onUnhandledError ?? ((error) => console.error(error)),
192
195
  accessOptions: {
193
196
  ...options.roles && { roles: options.roles },
194
197
  ...options.defaultAccess && { defaultAccess: options.defaultAccess }
@@ -220,7 +223,7 @@ const createAdmin = ({ resources: appResources, views: appViews = [], pages = []
220
223
  };
221
224
  const appName = auth?.appName ?? readAppName(globalThis.process?.env ?? {}) ?? "Protobase";
222
225
  const meta = createMeta(deps, appName, icons?.favicon ? faviconPath : void 0);
223
- const onError = createErrorHandler(options.onUnhandledError);
226
+ const onError = createErrorHandler(options.onUnhandledError, deps.statementTimeoutMs);
224
227
  const guarded = (routes, caller = authenticate) => {
225
228
  const app = new Hono();
226
229
  app.onError(onError);
@@ -296,7 +299,7 @@ const createAdmin = ({ resources: appResources, views: appViews = [], pages = []
296
299
  report: options.onUnhandledError
297
300
  }));
298
301
  }
299
- const report = options.onUnhandledError ?? ((error) => console.error(error));
302
+ const { report } = deps;
300
303
  if (auth?.mcp && options.mcp === false) closedMcpRoutes(app, auth.mcp.path);
301
304
  else if (auth?.mcp && mcpAuthenticate) {
302
305
  const writes = options.mcp !== false && options.mcp?.writes || false;
package/dist/deps.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { AuditQueue, PipelineHook } from "./types.js";
1
+ import { AuditQueue, PipelineHook, WriteCheck } from "./types.js";
2
2
  import { CommentUsers } from "./comments/comment-users.js";
3
3
  import { ErrorReportingSettings } from "./error-reporting/error-reporting-settings.js";
4
4
  import { mentionJob } from "./comments/mention-job.js";
@@ -30,9 +30,13 @@ export type Deps = {
30
30
  statementTimeoutMs: number;
31
31
  guard: ScanGuard;
32
32
  hooks: readonly PipelineHook[];
33
+ /** Run on a create's or update's body before its transaction opens (see `WriteCheck`). */
34
+ writeChecks: readonly WriteCheck[];
33
35
  accessOptions: ResolveOptions;
34
36
  /** Where reveals of sensitive fields are published. */
35
37
  audit: AuditQueue;
38
+ /** Gets the errors of work that runs after a request's response is decided, such as a committed write's file cleanup. */
39
+ report: (error: unknown) => void;
36
40
  /** Whether list items of a resource carry their permissions. */
37
41
  rowPermissions: (resource: string) => boolean;
38
42
  /** The URL of the assistant backend that `/meta` names to callers who may use it: another origin's, or the built-in one's path. */
@@ -1,4 +1,6 @@
1
1
  import { HttpProblem, problemResponse } from "./problem.js";
2
+ import { ConnectionsBusy, connectionsBusyProblem } from "./connections-busy.js";
3
+ import { StatementTimeout } from "./statement-timeout.js";
2
4
  import { HTTPException } from "hono/http-exception";
3
5
  import { QueryError } from "@protobase/query";
4
6
  //#region src/error-handler.ts
@@ -25,7 +27,7 @@ const queryProblem = (error) => {
25
27
  const isDatabaseError = (error) => error instanceof Error && /^[0-9A-Z]{5}$/.test(String(error.code));
26
28
  const databaseProblem = (error) => {
27
29
  if (error.code === "25006") return new HttpProblem(503, "read-only", "Service Unavailable", "The app is read-only while it is being updated", {}, { "Retry-After": "5" });
28
- if (error.code === "57014") return new HttpProblem(504, "statement-timeout", "Gateway Timeout", "The query exceeded the statement timeout");
30
+ if (error.code === "57014") return new HttpProblem(504, "statement-timeout", "Gateway Timeout", "A query ran longer than its statement timeout, so the database stopped it");
29
31
  if (error.code === "23505") return new HttpProblem(409, "conflict", "Conflict", "A record with the same unique value already exists");
30
32
  if (error.code === "23503") return new HttpProblem(409, "conflict", "Conflict", "The record points at, or is pointed at by, another record");
31
33
  if (error.code === "40P01") return new HttpProblem(409, "conflict", "Conflict", "The record was written at the same time as another; send it again");
@@ -34,10 +36,29 @@ const databaseProblem = (error) => {
34
36
  if (error.code.startsWith("22")) return new HttpProblem(400, "invalid-value", "Bad Request", "A value has the wrong format for its column");
35
37
  };
36
38
  /** The problem an error stands for, when it is one the API knows how to report; unknown errors give `undefined`. */
37
- const problemOf = (error) => error instanceof HttpProblem ? error : error instanceof QueryError ? queryProblem(error) : error instanceof HTTPException ? new HttpProblem(error.status, "http-error", titles[error.status] ?? "Error", error.message) : error instanceof SyntaxError ? new HttpProblem(400, "malformed-json", "Bad Request", "The request body is not valid JSON") : isDatabaseError(error) ? databaseProblem(error) : void 0;
38
- /** The single place where errors become responses: RFC 9457 problems, never raw error messages for unexpected failures. */
39
- const createErrorHandler = (onUnhandledError) => (error, c) => {
39
+ const problemOf = (error) => error instanceof HttpProblem ? error : error instanceof QueryError ? queryProblem(error) : error instanceof HTTPException ? new HttpProblem(error.status, "http-error", titles[error.status] ?? "Error", error.message) : error instanceof SyntaxError ? new HttpProblem(400, "malformed-json", "Bad Request", "The request body is not valid JSON") : isDatabaseError(error) ? databaseProblem(error) : error instanceof ConnectionsBusy ? connectionsBusyProblem(error, "The query") : void 0;
40
+ const namedTimeout = (problem, c, statementTimeoutMs) => problem instanceof StatementTimeout ? problem : new StatementTimeout(`${c.req.method} ${c.req.path}`, statementTimeoutMs === void 0 ? { kind: "database" } : {
41
+ kind: "request",
42
+ ms: statementTimeoutMs
43
+ }, problem.extras);
44
+ /**
45
+ * The single place where errors become responses: RFC 9457 problems, never raw error messages for unexpected failures.
46
+ * Unexpected failures go to `onUnhandledError` as well, and so do a query stopped at its statement timeout
47
+ * (`statementTimeoutMs`, the requests' own, when they set one) and a request that found every database connection
48
+ * busy, as the problem that names the request.
49
+ */
50
+ const createErrorHandler = (onUnhandledError, statementTimeoutMs) => (error, c) => {
51
+ if (error instanceof ConnectionsBusy) {
52
+ const named = connectionsBusyProblem(error, `${c.req.method} ${c.req.path}`);
53
+ onUnhandledError?.(named, c.req.raw);
54
+ return problemResponse(named, c.req.path);
55
+ }
40
56
  const known = problemOf(error);
57
+ if (known?.slug === "statement-timeout") {
58
+ const named = namedTimeout(known, c, statementTimeoutMs);
59
+ onUnhandledError?.(named, c.req.raw);
60
+ return problemResponse(named, c.req.path);
61
+ }
41
62
  if (known) return problemResponse(known, c.req.path);
42
63
  onUnhandledError?.(error, c.req.raw);
43
64
  return problemResponse(new HttpProblem(500, "internal-error", titles[500], "The server failed to handle the request"), c.req.path);
@@ -74,16 +74,16 @@ const attachFiles = async (access, values, current) => {
74
74
  return result;
75
75
  };
76
76
  /**
77
- * Schedules the delete of every file a write took out of its row, with its variants: a replaced or cleared value, or all
78
- * of a hard-deleted row's files, each due after its provider's retention. It runs inside the write transaction, before the commit: if
79
- * the write rolls back, the file is still referenced when the entry is due, and stays.
77
+ * The deletes of every file a write took out of its row, with its variants: a replaced or cleared value, or all of a
78
+ * hard-deleted row's files, each due after its provider's retention. The write schedules them once it commits
79
+ * (`scheduleRemovedFiles`).
80
80
  */
81
- const scheduleRemovedFiles = async (access, operation, before, after) => {
81
+ const removedFiles = (access, operation, before, after) => {
82
82
  const files = access.deps.files;
83
- if (!files || !before || operation === "create" || operation === "undelete") return;
84
- if (operation === "delete" && access.full.softDelete) return;
83
+ if (!files || !before || operation === "create" || operation === "undelete") return [];
84
+ if (operation === "delete" && access.full.softDelete) return [];
85
85
  const now = Date.now();
86
- const removed = fileFields(access).flatMap((field) => {
86
+ return fileFields(access).flatMap((field) => {
87
87
  const old = before[field.name];
88
88
  if (typeof old !== "string") return [];
89
89
  const next = after?.[field.name];
@@ -98,7 +98,15 @@ const scheduleRemovedFiles = async (access, operation, before, after) => {
98
98
  ...variants.length > 0 && { variants }
99
99
  }];
100
100
  });
101
- if (removed.length > 0) await files.schedule.add(removed);
101
+ };
102
+ /**
103
+ * Schedules the deletes of the files a committed write took out of its rows. After the commit, so the transaction never
104
+ * waits on the storage; a failure leaves the files in it, never a row that points at a deleted one, and is reported,
105
+ * since the write itself stands.
106
+ */
107
+ const scheduleRemovedFiles = async (deps, removed) => {
108
+ if (!deps.files || removed.length === 0) return;
109
+ await deps.files.schedule.add(removed).catch(deps.report);
102
110
  };
103
111
  //#endregion
104
- export { attachFiles, scheduleRemovedFiles, ticketScope };
112
+ export { attachFiles, removedFiles, scheduleRemovedFiles, ticketScope };
@@ -1,8 +1,8 @@
1
1
  import { HttpProblem, forbidden, notFound } from "../problem.js";
2
- import { readTransaction } from "../transactions.js";
3
- import { fetchRecord } from "../records.js";
4
2
  import { contentDisposition, downloadName, servedType } from "./media-types.js";
5
3
  import { isFilePath, parseUri, tenantSegment, variantPaths } from "./uri.js";
4
+ import { readTransaction } from "../transactions.js";
5
+ import { fetchRecord } from "../records.js";
6
6
  import { downloadExpiry, readDownloadUrl } from "./download-url.js";
7
7
  import { decodeKeyText } from "../key.js";
8
8
  //#region src/files/download.ts
@@ -56,6 +56,11 @@ export type CorsOptions = {
56
56
  type SharedOptions = {
57
57
  /** Answers CORS preflights before authentication and adds the CORS headers to every response, problems included. */
58
58
  cors?: CorsOptions;
59
+ /**
60
+ * Its queries through `db` may run up to 30 seconds instead of the database's own limit, on a connection kept for
61
+ * such work: for imports, reports and other bulk work. Its `records` keep the API's limit.
62
+ */
63
+ longQueries?: boolean;
59
64
  /**
60
65
  * Also answers at `/api/sb/functions/v1/<name>`, as Supabase's `verify_jwt` does, where the app's publishable key alone
61
66
  * admits a caller. For functions moved from Supabase; not together with `roles` or `capability`.
@@ -1,4 +1,5 @@
1
1
  import { forbidden, notFound } from "../problem.js";
2
+ import { namingTimeouts } from "../statement-timeout.js";
2
3
  import { invokeFunction } from "./define-function.js";
3
4
  import { runInBackground } from "../background-work.js";
4
5
  import { socketAcceptor } from "./accept-socket.js";
@@ -32,7 +33,10 @@ const functionRoutes = ({ basePath, functions, authenticate, access, db, records
32
33
  records: records(session)
33
34
  };
34
35
  };
35
- const call = (work) => work().finally(wake);
36
+ const call = (fn, subject, work) => {
37
+ const { longQueries } = fn.options;
38
+ return runInBackground(() => namingTimeouts(subject, { kind: longQueries ? "long" : "database" }, work).finally(wake), { longQueries });
39
+ };
36
40
  const recheckCaller = async (name, gate, request, first) => {
37
41
  const again = await upgradeAuthenticate(request);
38
42
  requireGate(name, gate, again);
@@ -43,7 +47,7 @@ const functionRoutes = ({ basePath, functions, authenticate, access, db, records
43
47
  const { options } = fn;
44
48
  const gate = functionGate(name, options, access);
45
49
  if (options.cors) app.use(`/${name}/*`, cors(options.cors));
46
- app.all(`/${name}/*`, (c) => runInBackground(() => call(async () => {
50
+ app.all(`/${name}/*`, (c) => call(fn, `${c.req.method} ${c.req.path}`, async () => {
47
51
  const request = relativeRequest(c.req.raw, prefix);
48
52
  if (options.public) {
49
53
  let caller;
@@ -83,7 +87,7 @@ const functionRoutes = ({ basePath, functions, authenticate, access, db, records
83
87
  acceptWebSocket: acceptor.accept
84
88
  };
85
89
  return acceptor.answer(name, await invokeFunction(name, fn, request, context));
86
- })));
90
+ }));
87
91
  }
88
92
  app.all("/*", (c) => {
89
93
  throw notFound(`There is no function at ${c.req.path}`);
@@ -1,4 +1,5 @@
1
1
  import { notFound, unauthorized } from "../problem.js";
2
+ import { namingTimeouts } from "../statement-timeout.js";
2
3
  import { supabaseFunctionsPath } from "../supabase-paths.js";
3
4
  import { invokeFunction } from "./define-function.js";
4
5
  import { runInBackground } from "../background-work.js";
@@ -30,7 +31,8 @@ const supabaseFunctionRoutes = ({ functions, db, keys, caller, onError }) => {
30
31
  const gated = roles !== void 0 ? "roles" : capability !== void 0 ? "a capability" : void 0;
31
32
  if (gated) throw new Error(`The function "${name}" has supabase: true and ${gated}, which /api/sb/functions/v1 cannot check: drop one`);
32
33
  if (options.cors) app.use(`/${name}/*`, cors(options.cors));
33
- app.all(`/${name}/*`, (c) => runInBackground(async () => {
34
+ const limit = { kind: options.longQueries ? "long" : "database" };
35
+ app.all(`/${name}/*`, (c) => runInBackground(() => namingTimeouts(`${c.req.method} ${c.req.path}`, limit, async () => {
34
36
  const request = relativeRequest(c.req.raw, supabaseFunctionsPath);
35
37
  if (options.public || c.req.method === "OPTIONS") {
36
38
  let resolved;
@@ -47,7 +49,7 @@ const supabaseFunctionRoutes = ({ functions, db, keys, caller, onError }) => {
47
49
  db,
48
50
  caller: async () => resolved
49
51
  });
50
- }));
52
+ }), { longQueries: options.longQueries }));
51
53
  }
52
54
  app.all("/*", (c) => {
53
55
  throw notFound(`There is no function at ${c.req.path}`);
package/dist/index.d.ts CHANGED
@@ -51,7 +51,10 @@ import { RuntimeOptions, runtimeUrl, runtimeVariable, seesRuntime } from "./runt
51
51
  import { AdminOptions } from "./admin-options.js";
52
52
  import { CreateAdminInput, createAdmin } from "./create-admin.js";
53
53
  import { handleDroppedConnections } from "./dropped-connections.js";
54
- import { isBackgroundWork, runInBackground } from "./background-work.js";
54
+ import { AppPoolConfig, PgModule, appPool, longQueryPool, poolWaitMs, readDbConnections } from "./app-pool.js";
55
+ import { ConnectionsBusy } from "./connections-busy.js";
56
+ import { isBackgroundWork, runInBackground, wantsLongQueries } from "./background-work.js";
57
+ import { longQueryTimeoutMs } from "./statement-timeout.js";
55
58
  import { checkViewQueries } from "./check-view-queries.js";
56
59
  import { ProjectConfig, defineConfig, mergeConfig } from "./project-config.js";
57
60
  import { consoleAuditQueue } from "./audit/console-queue.js";
@@ -133,4 +136,4 @@ import { checkJobsSchema, jobsSchema, jobsSchemaMigration } from "./jobs/schema.
133
136
  import { MobileRelease, ReleaseFile, ServedReleaseFile, composeRelease, releaseManifest } from "./mobile-release/release.js";
134
137
  import { MobileReleaseAppInput, OfferedRelease, mobileReleaseApp } from "./mobile-release/release-app.js";
135
138
  import { mobileSigningKeyVariable, readSigningKey } from "./mobile-release/signing-key.js";
136
- export { type Admin, type AdminAuth, type AdminOptions, type ApiFunction, type ApiKeys, type ApiKeysOptions, type AppIcons, type AppIdentity, type ApplyOptions, type ApplyResult, type ApprovalAnswer, type ApprovalRequest, type AssistantBackend, type AssistantOptions, type AssistantSettings, type AssistantTool, type AuditEvent, type AuditQueue, type Authenticator, type BatchOperation, type BatchResult, type BetterAuthAuthenticator, type BetterAuthAuthenticatorOptions, type Caller, type CallerOptions, type CapturedRow, type CascadingKey, type ChatMessage, type ChatTextPart, type CheckAiInput, type CleanupEntry, type CleanupReport, type CleanupSchedule, type CoalescedChange, type CommentItem, type CommentUsers, type Completion, type CompletionOptions, type Conversation, type ConversationRecord, type ConversationStore, type ConversationsOptions, type CorsOptions, type CorsOrigins, type CreateAdminInput, type CreateAuthOptions, type DrainResult, type EmailComponent, type EmailOptions, type EmailTemplate, type EncryptionStatus, type EncryptionTools, type EncryptionToolsInput, type EnqueueOptions, type ErrorReporter, type ErrorReportingOptions, type ErrorReportingSettings, type EventData, type EventsOptions, FatalJobError, type FieldApplied, type FieldStatus, type FileCleanupInput, type FileLimits, type FileObject, type FileProvider, type FileRef, type FileStore, type FileVariantObject, type FilesOptions, type FunctionApp, type FunctionCaller, type FunctionContext, type FunctionEnv, type FunctionHandler, type FunctionOptions, type FunctionRecords, type FunctionSocket, type FunctionSource, type HistoryOptions, HttpProblem, type IssueTokenOptions, type JobContext, type JobDefinition, type JobOptions, type JobsRuntime, type JwtOptions, type JwtVerifyOptions, type KeyFunctionContext, type LinkedAccount, type ListParams, type LocalFilesOptions, type MailMessage, type Mailer, type MapLayer, type MapOptions, type MapSettings, type McpGateInput, type McpHandlerInput, type McpRoutesInput, type McpServerInput, type McpToolsInput, type McpWrites, type MemberInput, MissingCredentials, type MobileConfig, type MobileRelease, type MobileReleaseAppInput, ModelError, type ModelSettings, type NewOrganization, type NewUser, type OfferedRelease, type OperatorProvider, type OrganizationRole, type OrganizationsOptions, type PipelineHook, type PlatformSignIn, type PluginOption, type PluginRef, type PngIcon, type ProjectConfig, type ProtectedFunctionOptions, type PublicFunctionContext, type PublicFunctionEnv, type PublicFunctionHandler, type PublicFunctionOptions, type QueryOptions, type QueryResult, type ReadableResources, type ReasoningDetail, type ReasoningEffort, type RebindOption, type RecordKeyInput, type RecordPage, type ReleaseFile, type RenderedEmail, type ResourceChange, type ResourceSource, type RoleDefinition, type RolesInput, type RunContext, type RuntimeOptions, type S3Bucket, type S3FilesOptions, type S3ListPage, type S3Object, type ScanGuardMode, type ScheduledDelete, type ServedReleaseFile, type Session, type SessionOrganization, type Sharp, type SignInPolicy, type SignInRule, type SignatureOptions, type SocketEvents, type SourceOption, type SourceRef, type StaffAccessRule, type StaffSignIn, type StandardSchema, type StandardWebhookOptions, type StartJobsOptions, type StoreFileInput, type StoredMember, type StoredObject, type StoredOrganization, type StoredUser, type StrayValues, type SubscriptionDefinition, type SubscriptionEvent, type SubscriptionOptions, type TenantValue, type ToolAnnotations, type ToolCall, type ToolContext, type ToolRecord, type ToolRecords, type ToolSpec, type TrustedApp, type TurnOptions, type UnreadableEntry, type User, type UserCreatedHook, type ViewSource, type WebhooksOptions, type WidgetOptions, type WriteEvent, type WriteOperation, type WriteRecordToolsInput, addAppRoleToOwners, addMember, apiKeyCaller, apiKeySettings, apiKeyVariables, aspectRatio, assistantProtocolRoutes, assistantRoles, assistantSettings, assistantVariables, authSchemaMigration, authStoreTables, backgroundSettled, badRequest, batchWriteTool, beforeSignInPluginsVariable, betterAuthAuthenticator, betterAuthTokens, capSocketMessages, changesMigration, checkAi, checkAuthSchema, checkChanges, checkComments, checkComputed, checkJobsSchema, checkQueryRunner, checkSourceNames, checkViewQueries, checkWebhooksSchema, coalesce, commentsMigration, composeRelease, computedMigration, configExports, consoleAuditQueue, conversationKey, createAdmin, createAuth, createConversations, createEncryptionTools, createFileCleanup, createOrganization, createRecordTool, createUser, defaultAppName, defaultSignInPolicy, defineConfig, defineEmail, defineFunction, defineJob, defineTool, deleteRecordTool, deleteUser, errorReportingSettings, errorReportingVariable, eventMatcher, fileConversationStore, fileSize, fileType, forbidden, formatUri, functionApp, getRecordTool, goTruePath, handleDroppedConnections, hasUsers, honorSocketMessageCaps, imageSize, isBackgroundWork, isCaptured, isJobDefinition, isOAuthWellKnownPath, isSchedule, issueToken, jobDefinitions, jobsDrainApp, jobsSchema, jobsSchemaMigration, jwtAuthenticator, jwtVerifier, limitedFileStore, listOrganizations, listRecordsTool, listResourcesTool, listUsers, localFileStore, localFiles, mapSettings, mapStyleUrl, mapStyleVariable, maxTokenTtlSeconds, maxWidgetPropsBytes, mcpGate, mcpHandler, mcpRoutes, mcpServer, mcpToolContext, mcpTools, mergeConfig, migrationCommand, mobileReleaseApp, mobileSigningKeyVariable, newWebhookSecret, normalizeRoles, notFound, oauthWellKnownPaths, objectKey, parseColumns, parseRoles, passwordLength, pluginOptions, pluginsBeforeSignIn, pluginsFor, pluginsVariable, pngIcons, processor, queryRunnerMigration, readJson, readOnlyQueryTool, readOperatorSettings, readOutbox, readPlatformSignIn, readRecordTools, readSigningKey, readWriteQueryTool, readableResources, realtimeLimits, recordOf, releaseManifest, removeMember, renderVariant, requestApproval, requireEnv, resourceChange, roleChoices, runInBackground, runReadOnlyQuery, runReadWriteQuery, runTurn, runtimeUrl, runtimeVariable, s3FileStore, s3Files, seesAssistant, seesRuntime, sessionOf, setMemberRoles, setUserBanned, setUserRole, signInPolicyMethods, socketMessageCapOf, sourceOptions, sourcesFor, sourcesVariable, startErrorReporting, startJobs, storageCleanupSchedule, storageProvider, storeFile, storeUsage, streamChatCompletion, subscription, supabasePath, thumbHash, unauthorized, updateRecordTool, variantPath, verifySignature, verifyStandardWebhook, waitUntil, webauthnPath, webhooks, webhooksMigration, widget, withCacheBreakpoints, withTrustedApps, writeRecordTools };
139
+ export { type Admin, type AdminAuth, type AdminOptions, type ApiFunction, type ApiKeys, type ApiKeysOptions, type AppIcons, type AppIdentity, type AppPoolConfig, type ApplyOptions, type ApplyResult, type ApprovalAnswer, type ApprovalRequest, type AssistantBackend, type AssistantOptions, type AssistantSettings, type AssistantTool, type AuditEvent, type AuditQueue, type Authenticator, type BatchOperation, type BatchResult, type BetterAuthAuthenticator, type BetterAuthAuthenticatorOptions, type Caller, type CallerOptions, type CapturedRow, type CascadingKey, type ChatMessage, type ChatTextPart, type CheckAiInput, type CleanupEntry, type CleanupReport, type CleanupSchedule, type CoalescedChange, type CommentItem, type CommentUsers, type Completion, type CompletionOptions, ConnectionsBusy, type Conversation, type ConversationRecord, type ConversationStore, type ConversationsOptions, type CorsOptions, type CorsOrigins, type CreateAdminInput, type CreateAuthOptions, type DrainResult, type EmailComponent, type EmailOptions, type EmailTemplate, type EncryptionStatus, type EncryptionTools, type EncryptionToolsInput, type EnqueueOptions, type ErrorReporter, type ErrorReportingOptions, type ErrorReportingSettings, type EventData, type EventsOptions, FatalJobError, type FieldApplied, type FieldStatus, type FileCleanupInput, type FileLimits, type FileObject, type FileProvider, type FileRef, type FileStore, type FileVariantObject, type FilesOptions, type FunctionApp, type FunctionCaller, type FunctionContext, type FunctionEnv, type FunctionHandler, type FunctionOptions, type FunctionRecords, type FunctionSocket, type FunctionSource, type HistoryOptions, HttpProblem, type IssueTokenOptions, type JobContext, type JobDefinition, type JobOptions, type JobsRuntime, type JwtOptions, type JwtVerifyOptions, type KeyFunctionContext, type LinkedAccount, type ListParams, type LocalFilesOptions, type MailMessage, type Mailer, type MapLayer, type MapOptions, type MapSettings, type McpGateInput, type McpHandlerInput, type McpRoutesInput, type McpServerInput, type McpToolsInput, type McpWrites, type MemberInput, MissingCredentials, type MobileConfig, type MobileRelease, type MobileReleaseAppInput, ModelError, type ModelSettings, type NewOrganization, type NewUser, type OfferedRelease, type OperatorProvider, type OrganizationRole, type OrganizationsOptions, type PgModule, type PipelineHook, type PlatformSignIn, type PluginOption, type PluginRef, type PngIcon, type ProjectConfig, type ProtectedFunctionOptions, type PublicFunctionContext, type PublicFunctionEnv, type PublicFunctionHandler, type PublicFunctionOptions, type QueryOptions, type QueryResult, type ReadableResources, type ReasoningDetail, type ReasoningEffort, type RebindOption, type RecordKeyInput, type RecordPage, type ReleaseFile, type RenderedEmail, type ResourceChange, type ResourceSource, type RoleDefinition, type RolesInput, type RunContext, type RuntimeOptions, type S3Bucket, type S3FilesOptions, type S3ListPage, type S3Object, type ScanGuardMode, type ScheduledDelete, type ServedReleaseFile, type Session, type SessionOrganization, type Sharp, type SignInPolicy, type SignInRule, type SignatureOptions, type SocketEvents, type SourceOption, type SourceRef, type StaffAccessRule, type StaffSignIn, type StandardSchema, type StandardWebhookOptions, type StartJobsOptions, type StoreFileInput, type StoredMember, type StoredObject, type StoredOrganization, type StoredUser, type StrayValues, type SubscriptionDefinition, type SubscriptionEvent, type SubscriptionOptions, type TenantValue, type ToolAnnotations, type ToolCall, type ToolContext, type ToolRecord, type ToolRecords, type ToolSpec, type TrustedApp, type TurnOptions, type UnreadableEntry, type User, type UserCreatedHook, type ViewSource, type WebhooksOptions, type WidgetOptions, type WriteEvent, type WriteOperation, type WriteRecordToolsInput, addAppRoleToOwners, addMember, apiKeyCaller, apiKeySettings, apiKeyVariables, appPool, aspectRatio, assistantProtocolRoutes, assistantRoles, assistantSettings, assistantVariables, authSchemaMigration, authStoreTables, backgroundSettled, badRequest, batchWriteTool, beforeSignInPluginsVariable, betterAuthAuthenticator, betterAuthTokens, capSocketMessages, changesMigration, checkAi, checkAuthSchema, checkChanges, checkComments, checkComputed, checkJobsSchema, checkQueryRunner, checkSourceNames, checkViewQueries, checkWebhooksSchema, coalesce, commentsMigration, composeRelease, computedMigration, configExports, consoleAuditQueue, conversationKey, createAdmin, createAuth, createConversations, createEncryptionTools, createFileCleanup, createOrganization, createRecordTool, createUser, defaultAppName, defaultSignInPolicy, defineConfig, defineEmail, defineFunction, defineJob, defineTool, deleteRecordTool, deleteUser, errorReportingSettings, errorReportingVariable, eventMatcher, fileConversationStore, fileSize, fileType, forbidden, formatUri, functionApp, getRecordTool, goTruePath, handleDroppedConnections, hasUsers, honorSocketMessageCaps, imageSize, isBackgroundWork, isCaptured, isJobDefinition, isOAuthWellKnownPath, isSchedule, issueToken, jobDefinitions, jobsDrainApp, jobsSchema, jobsSchemaMigration, jwtAuthenticator, jwtVerifier, limitedFileStore, listOrganizations, listRecordsTool, listResourcesTool, listUsers, localFileStore, localFiles, longQueryPool, longQueryTimeoutMs, mapSettings, mapStyleUrl, mapStyleVariable, maxTokenTtlSeconds, maxWidgetPropsBytes, mcpGate, mcpHandler, mcpRoutes, mcpServer, mcpToolContext, mcpTools, mergeConfig, migrationCommand, mobileReleaseApp, mobileSigningKeyVariable, newWebhookSecret, normalizeRoles, notFound, oauthWellKnownPaths, objectKey, parseColumns, parseRoles, passwordLength, pluginOptions, pluginsBeforeSignIn, pluginsFor, pluginsVariable, pngIcons, poolWaitMs, processor, queryRunnerMigration, readDbConnections, readJson, readOnlyQueryTool, readOperatorSettings, readOutbox, readPlatformSignIn, readRecordTools, readSigningKey, readWriteQueryTool, readableResources, realtimeLimits, recordOf, releaseManifest, removeMember, renderVariant, requestApproval, requireEnv, resourceChange, roleChoices, runInBackground, runReadOnlyQuery, runReadWriteQuery, runTurn, runtimeUrl, runtimeVariable, s3FileStore, s3Files, seesAssistant, seesRuntime, sessionOf, setMemberRoles, setUserBanned, setUserRole, signInPolicyMethods, socketMessageCapOf, sourceOptions, sourcesFor, sourcesVariable, startErrorReporting, startJobs, storageCleanupSchedule, storageProvider, storeFile, storeUsage, streamChatCompletion, subscription, supabasePath, thumbHash, unauthorized, updateRecordTool, variantPath, verifySignature, verifyStandardWebhook, waitUntil, wantsLongQueries, webauthnPath, webhooks, webhooksMigration, widget, withCacheBreakpoints, withTrustedApps, writeRecordTools };
package/dist/index.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import { HttpProblem, MissingCredentials, badRequest, forbidden, notFound, unauthorized } from "./problem.js";
2
2
  import { checkJobsSchema, jobsSchema, jobsSchemaMigration } from "./jobs/schema.js";
3
3
  import { isCaptured } from "./changes/capture-sql.js";
4
+ import { ConnectionsBusy } from "./connections-busy.js";
5
+ import { longQueryTimeoutMs } from "./statement-timeout.js";
4
6
  import { formatUri, variantPath } from "./files/uri.js";
5
7
  import { isSchedule } from "./jobs/schedule.js";
6
8
  import { defineJob, isJobDefinition, jobDefinitions } from "./jobs/define-job.js";
@@ -60,7 +62,7 @@ import { limitedFileStore, storeUsage } from "./files/limits.js";
60
62
  import { localFileStore, localFiles } from "./files/local-files.js";
61
63
  import { s3FileStore, s3Files } from "./files/s3-files.js";
62
64
  import { storageProvider } from "./files/storage-provider.js";
63
- import { isBackgroundWork, runInBackground } from "./background-work.js";
65
+ import { isBackgroundWork, runInBackground, wantsLongQueries } from "./background-work.js";
64
66
  import { capSocketMessages, honorSocketMessageCaps, socketMessageCapOf } from "./functions/socket-message-cap.js";
65
67
  import { mapSettings, mapStyleUrl, mapStyleVariable } from "./maps/map-settings.js";
66
68
  import { beforeSignInPluginsVariable, pluginOptions, pluginsBeforeSignIn, pluginsFor, pluginsVariable } from "./plugins/plugin-settings.js";
@@ -70,6 +72,7 @@ import { renderVariant, thumbHash } from "./files/images.js";
70
72
  import { pngIcons } from "./web-app/app-icons.js";
71
73
  import { createAdmin } from "./create-admin.js";
72
74
  import { handleDroppedConnections } from "./dropped-connections.js";
75
+ import { appPool, longQueryPool, poolWaitMs, readDbConnections } from "./app-pool.js";
73
76
  import { configExports } from "./resource-source.js";
74
77
  import { checkViewQueries } from "./check-view-queries.js";
75
78
  import { defineConfig, mergeConfig } from "./project-config.js";
@@ -100,4 +103,4 @@ import { jobsDrainApp } from "./jobs/drain-route.js";
100
103
  import { composeRelease, releaseManifest } from "./mobile-release/release.js";
101
104
  import { mobileReleaseApp } from "./mobile-release/release-app.js";
102
105
  import { mobileSigningKeyVariable, readSigningKey } from "./mobile-release/signing-key.js";
103
- export { FatalJobError, HttpProblem, MissingCredentials, ModelError, addAppRoleToOwners, addMember, apiKeyCaller, apiKeySettings, apiKeyVariables, aspectRatio, assistantProtocolRoutes, assistantRoles, assistantSettings, assistantVariables, authSchemaMigration, authStoreTables, backgroundSettled, badRequest, batchWriteTool, beforeSignInPluginsVariable, betterAuthAuthenticator, betterAuthTokens, capSocketMessages, changesMigration, checkAi, checkAuthSchema, checkChanges, checkComments, checkComputed, checkJobsSchema, checkQueryRunner, checkSourceNames, checkViewQueries, checkWebhooksSchema, coalesce, commentsMigration, composeRelease, computedMigration, configExports, consoleAuditQueue, conversationKey, createAdmin, createAuth, createConversations, createEncryptionTools, createFileCleanup, createOrganization, createRecordTool, createUser, defaultAppName, defaultSignInPolicy, defineConfig, defineEmail, defineFunction, defineJob, defineTool, deleteRecordTool, deleteUser, errorReportingSettings, errorReportingVariable, eventMatcher, fileConversationStore, fileSize, fileType, forbidden, formatUri, functionApp, getRecordTool, goTruePath, handleDroppedConnections, hasUsers, honorSocketMessageCaps, imageSize, isBackgroundWork, isCaptured, isJobDefinition, isOAuthWellKnownPath, isSchedule, issueToken, jobDefinitions, jobsDrainApp, jobsSchema, jobsSchemaMigration, jwtAuthenticator, jwtVerifier, limitedFileStore, listOrganizations, listRecordsTool, listResourcesTool, listUsers, localFileStore, localFiles, mapSettings, mapStyleUrl, mapStyleVariable, maxTokenTtlSeconds, maxWidgetPropsBytes, mcpGate, mcpHandler, mcpRoutes, mcpServer, mcpToolContext, mcpTools, mergeConfig, migrationCommand, mobileReleaseApp, mobileSigningKeyVariable, newWebhookSecret, normalizeRoles, notFound, oauthWellKnownPaths, objectKey, parseColumns, parseRoles, passwordLength, pluginOptions, pluginsBeforeSignIn, pluginsFor, pluginsVariable, pngIcons, processor, queryRunnerMigration, readJson, readOnlyQueryTool, readOperatorSettings, readOutbox, readPlatformSignIn, readRecordTools, readSigningKey, readWriteQueryTool, readableResources, realtimeLimits, recordOf, releaseManifest, removeMember, renderVariant, requestApproval, requireEnv, resourceChange, roleChoices, runInBackground, runReadOnlyQuery, runReadWriteQuery, runTurn, runtimeUrl, runtimeVariable, s3FileStore, s3Files, seesAssistant, seesRuntime, sessionOf, setMemberRoles, setUserBanned, setUserRole, signInPolicyMethods, socketMessageCapOf, sourceOptions, sourcesFor, sourcesVariable, startErrorReporting, startJobs, storageCleanupSchedule, storageProvider, storeFile, storeUsage, streamChatCompletion, subscription, supabasePath, thumbHash, unauthorized, updateRecordTool, variantPath, verifySignature, verifyStandardWebhook, waitUntil, webauthnPath, webhooks, webhooksMigration, widget, withCacheBreakpoints, withTrustedApps, writeRecordTools };
106
+ export { ConnectionsBusy, FatalJobError, HttpProblem, MissingCredentials, ModelError, addAppRoleToOwners, addMember, apiKeyCaller, apiKeySettings, apiKeyVariables, appPool, aspectRatio, assistantProtocolRoutes, assistantRoles, assistantSettings, assistantVariables, authSchemaMigration, authStoreTables, backgroundSettled, badRequest, batchWriteTool, beforeSignInPluginsVariable, betterAuthAuthenticator, betterAuthTokens, capSocketMessages, changesMigration, checkAi, checkAuthSchema, checkChanges, checkComments, checkComputed, checkJobsSchema, checkQueryRunner, checkSourceNames, checkViewQueries, checkWebhooksSchema, coalesce, commentsMigration, composeRelease, computedMigration, configExports, consoleAuditQueue, conversationKey, createAdmin, createAuth, createConversations, createEncryptionTools, createFileCleanup, createOrganization, createRecordTool, createUser, defaultAppName, defaultSignInPolicy, defineConfig, defineEmail, defineFunction, defineJob, defineTool, deleteRecordTool, deleteUser, errorReportingSettings, errorReportingVariable, eventMatcher, fileConversationStore, fileSize, fileType, forbidden, formatUri, functionApp, getRecordTool, goTruePath, handleDroppedConnections, hasUsers, honorSocketMessageCaps, imageSize, isBackgroundWork, isCaptured, isJobDefinition, isOAuthWellKnownPath, isSchedule, issueToken, jobDefinitions, jobsDrainApp, jobsSchema, jobsSchemaMigration, jwtAuthenticator, jwtVerifier, limitedFileStore, listOrganizations, listRecordsTool, listResourcesTool, listUsers, localFileStore, localFiles, longQueryPool, longQueryTimeoutMs, mapSettings, mapStyleUrl, mapStyleVariable, maxTokenTtlSeconds, maxWidgetPropsBytes, mcpGate, mcpHandler, mcpRoutes, mcpServer, mcpToolContext, mcpTools, mergeConfig, migrationCommand, mobileReleaseApp, mobileSigningKeyVariable, newWebhookSecret, normalizeRoles, notFound, oauthWellKnownPaths, objectKey, parseColumns, parseRoles, passwordLength, pluginOptions, pluginsBeforeSignIn, pluginsFor, pluginsVariable, pngIcons, poolWaitMs, processor, queryRunnerMigration, readDbConnections, readJson, readOnlyQueryTool, readOperatorSettings, readOutbox, readPlatformSignIn, readRecordTools, readSigningKey, readWriteQueryTool, readableResources, realtimeLimits, recordOf, releaseManifest, removeMember, renderVariant, requestApproval, requireEnv, resourceChange, roleChoices, runInBackground, runReadOnlyQuery, runReadWriteQuery, runTurn, runtimeUrl, runtimeVariable, s3FileStore, s3Files, seesAssistant, seesRuntime, sessionOf, setMemberRoles, setUserBanned, setUserRole, signInPolicyMethods, socketMessageCapOf, sourceOptions, sourcesFor, sourcesVariable, startErrorReporting, startJobs, storageCleanupSchedule, storageProvider, storeFile, storeUsage, streamChatCompletion, subscription, supabasePath, thumbHash, unauthorized, updateRecordTool, variantPath, verifySignature, verifyStandardWebhook, waitUntil, wantsLongQueries, webauthnPath, webhooks, webhooksMigration, widget, withCacheBreakpoints, withTrustedApps, writeRecordTools };
@@ -31,6 +31,11 @@ export type JobOptions<Payload> = {
31
31
  maxRetryDelay?: number | string;
32
32
  /** How long one try may run before it counts as failed. Default `15 minutes`. */
33
33
  timeout?: number | string;
34
+ /**
35
+ * Its queries through `db` may run up to 30 seconds instead of the database's own limit, on a connection kept for
36
+ * such work: for imports, backfills, large deletes and other bulk work.
37
+ */
38
+ longQueries?: boolean;
34
39
  /** Jobs of this name running at once in each worker process. Default 1. */
35
40
  concurrency?: number;
36
41
  /**
@@ -1,3 +1,4 @@
1
+ import { isStatementTimeout, statementTimeoutDetail } from "../statement-timeout.js";
1
2
  //#region src/jobs/run-job.ts
2
3
  /** Thrown from a job's `run`, it fails the job now: the attempts it has left are not used. */
3
4
  var FatalJobError = class extends Error {
@@ -44,6 +45,10 @@ const errorOutput = (error) => error instanceof Error ? {
44
45
  message: error.message,
45
46
  ...error.stack && { stack: error.stack }
46
47
  } : { message: String(error) };
48
+ const namedTimeout = (definition, error) => {
49
+ if (!isStatementTimeout(error)) return error;
50
+ return new Error(statementTimeoutDetail(`Job "${definition.name}"`, { kind: definition.options.longQueries ? "long" : "database" }), { cause: error });
51
+ };
47
52
  /**
48
53
  * One try of one job: completed when `run` returns, failed (to be retried while attempts are left) when it throws, and
49
54
  * failed for good on a `FatalJobError` or an invalid payload.
@@ -71,7 +76,7 @@ const runJob = async (definition, job, db, mail) => {
71
76
  return {
72
77
  id: job.id,
73
78
  status: error instanceof FatalJobError ? "deadletter" : "failed",
74
- output: errorOutput(error)
79
+ output: errorOutput(namedTimeout(definition, error))
75
80
  };
76
81
  }
77
82
  };
@@ -52,11 +52,13 @@ const startJobs = async ({ db, jobs, work = true, pollSeconds = 2, shutdownSecon
52
52
  const running = workers.get(definition.name);
53
53
  if (running?.concurrency === concurrency) return;
54
54
  if (running) await boss.offWork(definition.name);
55
- const id = await boss.work(definition.name, workOptions(concurrency, pollSeconds), async ([job]) => runInBackground(async () => {
55
+ const id = await boss.work(definition.name, workOptions(concurrency, pollSeconds), async ([job]) => {
56
56
  const current = definitions.get(definition.name) ?? definition;
57
- if (current.options.schedule !== void 0 && job.singletonKey === "protobase.schedule") await schedules.following(current, job.startAfter);
58
- return [await runJob(current, job, db, mail)];
59
- }));
57
+ return runInBackground(async () => {
58
+ if (current.options.schedule !== void 0 && job.singletonKey === "protobase.schedule") await schedules.following(current, job.startAfter);
59
+ return [await runJob(current, job, db, mail)];
60
+ }, { longQueries: current.options.longQueries });
61
+ });
60
62
  workers.set(definition.name, {
61
63
  id,
62
64
  concurrency
@@ -1,8 +1,17 @@
1
+ import { isStatementTimeout } from "../statement-timeout.js";
1
2
  import { checkedTools } from "../assistant/checked-tools.js";
2
3
  import { ApprovalNeeded, approvalAsked, approvedUnasked, mcpApproval, writesUnasked } from "./mcp-approval.js";
3
4
  import { CLIENT_CAPABILITIES_META_KEY, McpServer, fromJsonSchema } from "@modelcontextprotocol/server";
4
5
  //#region src/mcp/mcp-server.ts
5
6
  const capabilitiesOf = (envelope) => envelope?.[CLIENT_CAPABILITIES_META_KEY];
7
+ const inputSchemas = /* @__PURE__ */ new WeakMap();
8
+ const inputSchemaOf = (parameters) => {
9
+ const known = inputSchemas.get(parameters);
10
+ if (known) return known;
11
+ const schema = fromJsonSchema(parameters);
12
+ inputSchemas.set(parameters, schema);
13
+ return schema;
14
+ };
6
15
  const failed = {
7
16
  content: [{
8
17
  type: "text",
@@ -10,6 +19,13 @@ const failed = {
10
19
  }],
11
20
  isError: true
12
21
  };
22
+ const stopped = (tool) => ({
23
+ content: [{
24
+ type: "text",
25
+ text: `${tool} ran a query longer than its statement timeout, so the database stopped it. Ask for less, such as with a narrower filter or fewer fields.`
26
+ }],
27
+ isError: true
28
+ });
13
29
  /**
14
30
  * An MCP server offering `tools` as MCP tools, each with its JSON Schema, its hints, and the text it returns. A tool
15
31
  * that asks for approval makes the call ask the client's user, and runs again from the start with their answer; for a
@@ -21,7 +37,7 @@ const mcpServer = ({ info, tools, context, report, rolesWithoutApproval = [] })
21
37
  for (const tool of checkedTools(tools)) {
22
38
  const config = {
23
39
  description: tool.description,
24
- inputSchema: fromJsonSchema(tool.parameters),
40
+ inputSchema: inputSchemaOf(tool.parameters),
25
41
  ...tool.annotations && { annotations: tool.annotations }
26
42
  };
27
43
  server.registerTool(tool.name, config, async (args, ctx) => {
@@ -36,7 +52,7 @@ const mcpServer = ({ info, tools, context, report, rolesWithoutApproval = [] })
36
52
  } catch (error) {
37
53
  if (error instanceof ApprovalNeeded) return approvalAsked(error.request, capabilitiesOf(ctx.mcpReq.envelope));
38
54
  report(error);
39
- return failed;
55
+ return isStatementTimeout(error) ? stopped(tool.name) : failed;
40
56
  }
41
57
  });
42
58
  }
@@ -9,10 +9,15 @@ import { batchWriteTool } from "../assistant/batch-write-tool.js";
9
9
  * is on and the caller sees the assistant.
10
10
  */
11
11
  const writeTools = (input) => [...writeRecordTools(input), batchWriteTool(input)];
12
- const mcpTools = ({ db, sql, writes }) => (session) => [
13
- ...readRecordTools(),
14
- ...writes === false ? [] : writeTools(writes === true || !writes.resources ? {} : { resources: writes.resources }),
15
- ...sql && seesAssistant(session.user.roles) ? [readOnlyQueryTool(db)] : []
16
- ];
12
+ const mcpTools = ({ db, sql, writes }) => {
13
+ const read = readRecordTools();
14
+ const write = writes === false ? [] : writeTools(writes === true || !writes.resources ? {} : { resources: writes.resources });
15
+ const query = sql ? [readOnlyQueryTool(db)] : [];
16
+ return (session) => [
17
+ ...read,
18
+ ...write,
19
+ ...seesAssistant(session.user.roles) ? query : []
20
+ ];
21
+ };
17
22
  //#endregion
18
23
  export { mcpTools };
package/dist/package.js CHANGED
@@ -1,4 +1,4 @@
1
1
  //#region package.json
2
- var version = "0.43.1";
2
+ var version = "0.44.0";
3
3
  //#endregion
4
4
  export { version };
@@ -1,4 +1,5 @@
1
1
  import { HttpProblem } from "../problem.js";
2
+ import { StatementTimeout, isStatementTimeout } from "../statement-timeout.js";
2
3
  import { foreignKeyCache } from "./foreign-keys.js";
3
4
  import { restError, restErrorOf } from "./rest-error.js";
4
5
  import { errorResponse } from "./respond.js";
@@ -16,6 +17,14 @@ const restRoutes = ({ deps, caller, report }) => {
16
17
  const app = new Hono();
17
18
  app.onError((error, c) => {
18
19
  const known = restErrorOf(error, c.get("caller")?.role === "anon");
20
+ if (known && isStatementTimeout(error)) {
21
+ const named = new StatementTimeout(`${c.req.method} ${c.req.path}`, {
22
+ kind: "request",
23
+ ms: deps.statementTimeoutMs
24
+ });
25
+ report?.(named, c.req.raw);
26
+ return errorResponse(restError(known.status, known.body.code, known.body.message, known.body.details, named.detail));
27
+ }
19
28
  if (known) return errorResponse(known);
20
29
  if (error instanceof HttpProblem) return errorResponse(restError(error.status, error.status === 401 ? "PGRST301" : "PGRST000", error.detail).withHeaders(error.headers));
21
30
  report?.(error, c.req.raw);
@@ -0,0 +1,7 @@
1
+ //#region src/statement-timeout.d.ts
2
+ /**
3
+ * How long a query of a function or job with `longQueries: true` may run: the most any query of an app may, which the
4
+ * platform's pooler enforces as well.
5
+ */
6
+ export declare const longQueryTimeoutMs = 30000;
7
+ //#endregion
@@ -0,0 +1,34 @@
1
+ import { HttpProblem } from "./problem.js";
2
+ //#region src/statement-timeout.ts
3
+ /** How long a query of an API request may run unless the app sets `statementTimeoutMs`. */
4
+ const defaultStatementTimeoutMs = 1e3;
5
+ /**
6
+ * How long a query of a function or job with `longQueries: true` may run: the most any query of an app may, which the
7
+ * platform's pooler enforces as well.
8
+ */
9
+ const longQueryTimeoutMs = 3e4;
10
+ /** SQLSTATE 57014: Postgres stopped a statement at its `statement_timeout`. */
11
+ const isStatementTimeout = (error) => error instanceof Error && error.code === "57014";
12
+ const seconds = (ms) => `${ms / 1e3} s`;
13
+ /** What ran too long (`subject`, such as `GET /api/v1/tasks`), the limit, and the ways out. */
14
+ const statementTimeoutDetail = (subject, limit) => {
15
+ if (limit.kind === "long") return `${subject} ran a query longer than ${seconds(longQueryTimeoutMs)}, the most a query may run, so the database stopped it. Make the query faster, or split the work into smaller steps, such as a job per batch.`;
16
+ return `${subject} ran a query longer than ${limit.kind === "request" ? seconds(limit.ms) : "the database's statement timeout"}, so the database stopped it. Make the query faster, such as with an index or a narrower filter, or ${limit.kind === "request" ? "move the work into a function or job with longQueries: true" : "give it longQueries: true"}, whose queries may run up to ${seconds(longQueryTimeoutMs)}.`;
17
+ };
18
+ /** The 504 of a query Postgres stopped, whose detail already names what ran too long. */
19
+ var StatementTimeout = class extends HttpProblem {
20
+ constructor(subject, limit, extras = {}) {
21
+ super(504, "statement-timeout", "Gateway Timeout", statementTimeoutDetail(subject, limit), extras);
22
+ }
23
+ };
24
+ /** Runs `work`; a query of it the database stopped becomes the 504 that names `subject` and the limit it ran into. */
25
+ const namingTimeouts = async (subject, limit, work) => {
26
+ try {
27
+ return await work();
28
+ } catch (error) {
29
+ if (isStatementTimeout(error)) throw new StatementTimeout(subject, limit);
30
+ throw error;
31
+ }
32
+ };
33
+ //#endregion
34
+ export { StatementTimeout, defaultStatementTimeoutMs, isStatementTimeout, longQueryTimeoutMs, namingTimeouts, statementTimeoutDetail };
package/dist/types.d.ts CHANGED
@@ -53,6 +53,15 @@ export type WriteEvent = {
53
53
  };
54
54
  /** Extension point of the write pipeline: a hook that throws rolls the write back. */
55
55
  export type PipelineHook = (event: WriteEvent, trx: Db) => Promise<void>;
56
+ /**
57
+ * A check of a create or update before its transaction opens, with the body as the caller sent it, for what needs the
58
+ * network, such as resolving a host: it throws to refuse the write.
59
+ */
60
+ export type WriteCheck = (write: {
61
+ resource: string;
62
+ operation: 'create' | 'update';
63
+ body: unknown;
64
+ }) => Promise<void>;
56
65
  /** Where a request came from, as its headers say; proxies set `forwardedFor`, so it is only as trustworthy as they are. */
57
66
  export type AuditOrigin = {
58
67
  userAgent?: string;
@@ -5,9 +5,9 @@ import { parseEvents } from "./options.js";
5
5
  const isHeaders = (value) => value === null || value === void 0 || typeof value === "object" && !Array.isArray(value) && Object.values(value).every((item) => typeof item === "string");
6
6
  /**
7
7
  * Checks a webhook subscription as it is written: events that are declared, a filter that checks against every resource
8
- * it covers, headers of text, and a URL at a public address. A refusal is a 400 problem, and the write rolls back.
8
+ * it covers, and headers of text. A refusal is a 400 problem, and the write rolls back.
9
9
  */
10
- const subscriptionWriteCheck = (models, checkUrl) => async (event) => {
10
+ const subscriptionWriteCheck = (models) => async (event) => {
11
11
  if (event.resource.name !== "webhook_subscriptions" || !event.after) return;
12
12
  const { events, url, filter, headers } = event.after;
13
13
  const candidate = {
@@ -24,9 +24,18 @@ const subscriptionWriteCheck = (models, checkUrl) => async (event) => {
24
24
  ...subscriptionProblems([candidate], models, []).map((problem) => problem.replace("Subscription \"it\" ", "")),
25
25
  ...isHeaders(headers) ? [] : ["headers must be an object of text values"]
26
26
  ];
27
- const blocked = problems.length === 0 && checkUrl ? await checkUrl(url) : void 0;
28
- const all = [...problems, ...blocked ? [blocked] : []];
29
- if (all.length > 0) throw badRequest("invalid-webhook-subscription", `The webhook subscription cannot be saved: ${all.join("; ")}`);
27
+ if (problems.length > 0) throw badRequest("invalid-webhook-subscription", `The webhook subscription cannot be saved: ${problems.join("; ")}`);
28
+ };
29
+ /**
30
+ * Checks the URL a webhook subscription is given before its write's transaction opens, since that resolves its host:
31
+ * it must be at a public address. A URL that does not parse is left to `subscriptionWriteCheck`.
32
+ */
33
+ const subscriptionUrlCheck = (checkUrl) => async ({ resource, body }) => {
34
+ if (resource !== "webhook_subscriptions" || typeof body !== "object" || body === null) return;
35
+ const { url } = body;
36
+ if (typeof url !== "string" || !URL.canParse(url)) return;
37
+ const blocked = await checkUrl(url);
38
+ if (blocked) throw badRequest("invalid-webhook-subscription", `The webhook subscription cannot be saved: ${blocked}`);
30
39
  };
31
40
  //#endregion
32
- export { subscriptionWriteCheck };
41
+ export { subscriptionUrlCheck, subscriptionWriteCheck };
@@ -3,10 +3,10 @@ import { tenantScope } from "./tenant.js";
3
3
  import { requestAccess, requireOperation, targetFilter, writableFields } from "./request-access.js";
4
4
  import { actorSettings, wakeDispatch } from "./changes/actor.js";
5
5
  import { isDatabaseError } from "./error-handler.js";
6
+ import { attachFiles, scheduleRemovedFiles } from "./files/attach.js";
6
7
  import { writeTransaction } from "./transactions.js";
7
8
  import { bindingOf, pendingBinding } from "./encryption/binding.js";
8
9
  import { deleteRecord, fetchRecord, insertRecord, restoreRecord, updateRecord } from "./records.js";
9
- import { attachFiles } from "./files/attach.js";
10
10
  import { assertWritten, checkParents, checkPrecondition, decide, denied, firstKeyField, keyOf, missing, notifier, shown } from "./write-shared.js";
11
11
  import { blockedDelete } from "./blocked-delete.js";
12
12
  import { encryptedValuesOf, hasEncryptedValues, sealValues } from "./encryption/seal-values.js";
@@ -31,7 +31,7 @@ const bindEncrypted = async (trx, deps, access, row, inserted, shape) => {
31
31
  if (!bound) throw new Error(`A ${entry.name} record was created whose key the API cannot address, so its encrypted fields cannot be bound to it`);
32
32
  return bound;
33
33
  };
34
- const create = async (trx, deps, access, body) => {
34
+ const create = async (trx, deps, access, body, removed) => {
35
35
  const { entry, full } = access;
36
36
  const scope = tenantScope(entry, access.session);
37
37
  const tenantValues = full.tenant && scope ? { [full.tenant]: scope.tenantValue } : {};
@@ -52,13 +52,13 @@ const create = async (trx, deps, access, body) => {
52
52
  key: keyOf(access, created.record),
53
53
  before: null
54
54
  }]);
55
- await notifier(deps, access)(trx, "create", void 0, created.record);
55
+ await notifier(deps, access, removed)(trx, "create", void 0, created.record);
56
56
  return shown(access, created);
57
57
  };
58
- const update = async (trx, deps, access, request) => {
58
+ const update = async (trx, deps, access, request, removed) => {
59
59
  const plan = await planUpdate(trx, access, request.key, request.body, request.ifMatch);
60
60
  if (Object.keys(plan.values).length === 0) return shown(access, plan.current);
61
- const updated = await applyUpdate(trx, deps, access, request.key, plan);
61
+ const updated = await applyUpdate(trx, deps, access, request.key, plan, removed);
62
62
  const rank = access.full.position?.field;
63
63
  if (rank && rank in plan.values) await checkRankAfter(trx, deps, access, rank, [{
64
64
  key: keyOf(access, updated.record),
@@ -66,7 +66,7 @@ const update = async (trx, deps, access, request) => {
66
66
  }]);
67
67
  return shown(access, updated);
68
68
  };
69
- const removeOrRestore = async (trx, deps, access, request) => {
69
+ const removeOrRestore = async (trx, deps, access, request, removed) => {
70
70
  const { entry, full } = access;
71
71
  const undelete = request.operation === "undelete";
72
72
  if (undelete && !full.softDelete) throw badRequest("undelete-unsupported", `Resource "${entry.name}" does not soft delete, so there is nothing to undelete`);
@@ -93,42 +93,56 @@ const removeOrRestore = async (trx, deps, access, request) => {
93
93
  fields: [firstKeyField(access)]
94
94
  })) throw denied(entry, operation);
95
95
  await checkParents(trx, access, operation)(current.record);
96
- const notify = notifier(deps, access);
96
+ const notify = notifier(deps, access, removed);
97
97
  if (undelete) {
98
98
  const restored = await restoreRecord(trx, entry, request.key, scope?.tenantValue, shape);
99
99
  await assertWritten(trx, access, restored, "undelete", access.resolved.rowFilter.update, filter);
100
100
  await notify(trx, "undelete", current.record, restored.record);
101
101
  return shown(access, restored);
102
102
  }
103
- const removed = await deleteRecord(trx, entry, request.key, scope?.tenantValue, shape).catch(async (error) => {
103
+ const gone = await deleteRecord(trx, entry, request.key, scope?.tenantValue, shape).catch(async (error) => {
104
104
  if (isDatabaseError(error) && error.code === "23503") throw await blockedDelete(deps, access, error);
105
105
  throw error;
106
106
  });
107
107
  await notify(trx, "delete", current.record, void 0);
108
- return shown(access, removed);
108
+ return shown(access, gone);
109
109
  };
110
110
  /**
111
111
  * One write inside an open transaction, for a caller whose access is already resolved: operations and field access,
112
112
  * tenant scope, validation against the fields they may write, optimistic concurrency, row filters and record-level
113
113
  * rules checked in the database, then the hooks; a rank written by the resource's position with `after` must keep the
114
- * record below what it depends on (see `checkRankAfter`). Batches run many of these in one transaction.
114
+ * record below what it depends on (see `checkRankAfter`). The files it takes out of the row go to `removed`, which the
115
+ * caller schedules for deletion once the transaction commits (`scheduleRemovedFiles`). Batches run many of these in one
116
+ * transaction.
115
117
  */
116
- const writeIn = async (trx, deps, access, request) => {
118
+ const writeIn = async (trx, deps, access, request, removed) => {
117
119
  requireOperation(access, request.operation === "undelete" ? "update" : request.operation);
118
- if (request.operation === "create") return create(trx, deps, access, request.body);
119
- if (request.operation === "update") return update(trx, deps, access, request);
120
- return removeOrRestore(trx, deps, access, request);
120
+ if (request.operation === "create") return create(trx, deps, access, request.body, removed);
121
+ if (request.operation === "update") return update(trx, deps, access, request, removed);
122
+ return removeOrRestore(trx, deps, access, request, removed);
123
+ };
124
+ /** Runs the checks that come before a create's or update's transaction opens (`WriteCheck`) on the body as sent. */
125
+ const checkBeforeWrite = async (deps, write) => {
126
+ for (const check of deps.writeChecks) await check(write);
121
127
  };
122
128
  /**
123
- * The path every single write takes: resolve access once, then `writeIn` in its own transaction with a statement timeout.
124
- * The caller has already authenticated `session`; `form` is the public form a visitor creates through.
129
+ * The path every single write takes: resolve access once, the checks that come before the transaction, then `writeIn`
130
+ * in its own transaction with a statement timeout. The caller has already authenticated `session`; `form` is the
131
+ * public form a visitor creates through.
125
132
  */
126
133
  const runWrite = async (deps, entry, session, request, form) => {
127
134
  const access = await requestAccess(deps, entry, session, form);
128
135
  requireOperation(access, request.operation === "undelete" ? "update" : request.operation);
129
- const result = await writeTransaction(deps.db, deps.statementTimeoutMs, (trx) => writeIn(trx, deps, access, request), actorSettings(deps, session));
136
+ if (request.operation === "create" || request.operation === "update") await checkBeforeWrite(deps, {
137
+ resource: entry.name,
138
+ operation: request.operation,
139
+ body: request.body
140
+ });
141
+ const removed = [];
142
+ const result = await writeTransaction(deps.db, deps.statementTimeoutMs, (trx) => writeIn(trx, deps, access, request, removed), actorSettings(deps, session));
143
+ await scheduleRemovedFiles(deps, removed);
130
144
  wakeDispatch(deps);
131
145
  return result;
132
146
  };
133
147
  //#endregion
134
- export { runWrite, writeIn };
148
+ export { checkBeforeWrite, runWrite, writeIn };
@@ -2,10 +2,10 @@ import { maskEncrypted } from "./encryption/mask-stored.js";
2
2
  import { forbidden, notFound, preconditionFailed, preconditionRequired } from "./problem.js";
3
3
  import { tenantScope } from "./tenant.js";
4
4
  import { andFilters, recordDecision } from "./request-access.js";
5
+ import { removedFiles } from "./files/attach.js";
5
6
  import { ifMatchSatisfied } from "./etag.js";
6
7
  import { pickFields } from "./rows.js";
7
8
  import { fetchRecord } from "./records.js";
8
- import { scheduleRemovedFiles } from "./files/attach.js";
9
9
  import { parentGuard } from "./parent-access.js";
10
10
  import { presentFiles } from "./files/present.js";
11
11
  //#region src/write-shared.ts
@@ -53,8 +53,11 @@ const shown = async (access, written) => ({
53
53
  record: (await presentFiles(access, [pickFields(written.record, Object.keys(access.model.fields))]))[0],
54
54
  etag: written.etag
55
55
  });
56
- /** Runs the pipeline hooks for one write, inside the transaction. */
57
- const notifier = (deps, access) => async (trx, kind, before, after) => {
56
+ /**
57
+ * Runs the pipeline hooks for one write, inside the transaction, and adds the files it took out of its row to `removed`,
58
+ * which the write schedules for deletion once it commits.
59
+ */
60
+ const notifier = (deps, access, removed) => async (trx, kind, before, after) => {
58
61
  const { session } = access;
59
62
  const event = {
60
63
  operation: kind,
@@ -66,7 +69,7 @@ const notifier = (deps, access) => async (trx, kind, before, after) => {
66
69
  ...after && { after: maskEncrypted(access.full, after) }
67
70
  };
68
71
  for (const hook of deps.hooks) await hook(event, trx);
69
- await scheduleRemovedFiles(access, kind, before, after);
72
+ removed.push(...removedFiles(access, kind, before, after));
70
73
  };
71
74
  //#endregion
72
75
  export { assertWritten, checkParents, checkPrecondition, decide, denied, firstKeyField, keyOf, missing, notifier, shown };
@@ -1,9 +1,9 @@
1
1
  import { badRequest } from "./problem.js";
2
2
  import { tenantScope } from "./tenant.js";
3
3
  import { targetFilter } from "./request-access.js";
4
+ import { attachFiles } from "./files/attach.js";
4
5
  import { bindingOf } from "./encryption/binding.js";
5
6
  import { fetchRecord, updateRecord } from "./records.js";
6
- import { attachFiles } from "./files/attach.js";
7
7
  import { assertWritten, checkParents, checkPrecondition, decide, denied, firstKeyField, missing, notifier } from "./write-shared.js";
8
8
  import { sealValues } from "./encryption/seal-values.js";
9
9
  import { validateBody } from "./validate-body.js";
@@ -48,8 +48,8 @@ const refuseKeyChange = (access, plan) => {
48
48
  if (!Object.values(full.fields).some((field) => field.encrypted)) return;
49
49
  if (full.primaryKey.some((name) => Object.hasOwn(plan.values, name) && String(plan.values[name]) !== String(plan.current.record[name]))) throw badRequest("key-change-unsupported", `The key of a ${entry.name} record cannot change: its encrypted fields are bound to it`);
50
50
  };
51
- /** Writes a planned update, checks the row as written against the filters, and runs the hooks. */
52
- const applyUpdate = async (trx, deps, access, key, plan) => {
51
+ /** Writes a planned update, checks the row as written against the filters, and runs the hooks (see `notifier`). */
52
+ const applyUpdate = async (trx, deps, access, key, plan, removed) => {
53
53
  const { entry, full } = access;
54
54
  refuseKeyChange(access, plan);
55
55
  const tenant = tenantScope(entry, access.session)?.tenantValue;
@@ -57,7 +57,7 @@ const applyUpdate = async (trx, deps, access, key, plan) => {
57
57
  const updated = await updateRecord(trx, entry, key, tenant, values, { etagFields: Object.keys(access.model.fields) });
58
58
  await assertWritten(trx, access, updated, "update", access.resolved.rowFilter.update, plan.filter);
59
59
  await plan.parents(updated.record);
60
- await notifier(deps, access)(trx, "update", plan.current.record, updated.record);
60
+ await notifier(deps, access, removed)(trx, "update", plan.current.record, updated.record);
61
61
  return updated;
62
62
  };
63
63
  //#endregion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@protobase/server",
3
- "version": "0.43.1",
3
+ "version": "0.44.0",
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": {
@@ -36,9 +36,9 @@
36
36
  "@better-auth/passkey": "^1.7.12",
37
37
  "@better-auth/sso": "^1.7.12",
38
38
  "@modelcontextprotocol/server": "^2.3.1",
39
- "@protobase/layout": "0.43.1",
40
- "@protobase/query": "0.43.1",
41
- "@protobase/schema": "0.43.1",
39
+ "@protobase/layout": "0.44.0",
40
+ "@protobase/query": "0.44.0",
41
+ "@protobase/schema": "0.44.0",
42
42
  "@react-email/render": "^2.1.0",
43
43
  "@scalar/hono-api-reference": "^0.12.10",
44
44
  "@sentry/core": "^11.7.0",