@protobase/server 0.8.1 → 0.9.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.
- package/dist/batch/run-batch.js +4 -2
- package/dist/changes/actor.js +7 -1
- package/dist/changes/capture-schema.js +1 -1
- package/dist/changes/capture-sql.js +13 -2
- package/dist/changes/coalesce.d.ts +4 -0
- package/dist/changes/coalesce.js +2 -1
- package/dist/changes/outbox.d.ts +14 -0
- package/dist/changes/outbox.js +51 -0
- package/dist/changes/resource-change.d.ts +2 -0
- package/dist/changes/resource-change.js +2 -1
- package/dist/create-admin.d.ts +11 -3
- package/dist/create-admin.js +21 -5
- package/dist/functions/require-env.d.ts +1 -1
- package/dist/index.d.ts +15 -3
- package/dist/index.js +15 -5
- package/dist/jobs/current.d.ts +10 -0
- package/dist/jobs/current.js +20 -0
- package/dist/jobs/database.js +36 -0
- package/dist/jobs/define-job.d.ts +8 -0
- package/dist/jobs/define-job.js +38 -0
- package/dist/jobs/drain-route.d.ts +21 -0
- package/dist/jobs/drain-route.js +28 -0
- package/dist/jobs/failed-jobs.js +67 -0
- package/dist/jobs/functions-sql.js +126 -0
- package/dist/jobs/job-types.d.ts +73 -0
- package/dist/jobs/options.js +44 -0
- package/dist/jobs/run-job.d.ts +9 -0
- package/dist/jobs/run-job.js +78 -0
- package/dist/jobs/runtime.d.ts +46 -0
- package/dist/jobs/runtime.js +158 -0
- package/dist/jobs/schedule.js +25 -0
- package/dist/jobs/schedules.js +41 -0
- package/dist/jobs/schema.d.ts +15 -0
- package/dist/jobs/schema.js +63 -0
- package/dist/project-config.d.ts +7 -2
- package/dist/project-config.js +24 -3
- package/dist/resource-source.d.ts +8 -3
- package/dist/resource-source.js +6 -3
- package/dist/types.d.ts +5 -1
- package/dist/webhooks/attempt.js +91 -0
- package/dist/webhooks/retry-schedule.js +40 -0
- package/dist/webhooks/route-events.js +61 -0
- package/dist/webhooks/standard-webhooks.d.ts +4 -0
- package/dist/webhooks/standard-webhooks.js +36 -0
- package/dist/webhooks/subscriber-view.js +76 -0
- package/dist/webhooks/subscription.d.ts +92 -0
- package/dist/webhooks/subscription.js +28 -0
- package/dist/webhooks/table/address-check.js +28 -0
- package/dist/webhooks/table/options.d.ts +23 -0
- package/dist/webhooks/table/options.js +14 -0
- package/dist/webhooks/table/owner-roles.js +23 -0
- package/dist/webhooks/table/plugin.d.ts +10 -0
- package/dist/webhooks/table/plugin.js +18 -0
- package/dist/webhooks/table/schema-sql.js +116 -0
- package/dist/webhooks/table/schema.d.ts +13 -0
- package/dist/webhooks/table/schema.js +44 -0
- package/dist/webhooks/table/table-subscriptions.js +77 -0
- package/dist/webhooks/table/validate-hook.js +32 -0
- package/dist/webhooks/table/webhook-table.js +23 -0
- package/dist/webhooks/test-event.js +48 -0
- package/dist/webhooks/webhooks.js +233 -0
- package/dist/write-pipeline.js +4 -2
- package/package.json +6 -4
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Db } from "@protobase/query";
|
|
2
|
+
//#region src/jobs/schema.d.ts
|
|
3
|
+
/** The schema the job queue lives in: pg-boss's tables and Protobase's functions. */
|
|
4
|
+
export declare const jobsSchema = "jobs";
|
|
5
|
+
/**
|
|
6
|
+
* The SQL that brings the jobs schema to what this version of Protobase runs: pg-boss's own construction plan when it
|
|
7
|
+
* is missing, or its migration plan from the version installed, then Protobase's functions. `undefined` when current.
|
|
8
|
+
*/
|
|
9
|
+
export declare const jobsSchemaMigration: (db: Db) => Promise<string | undefined>;
|
|
10
|
+
/**
|
|
11
|
+
* Refuses to go on while the jobs schema is missing or not the version this Protobase runs. The server never creates
|
|
12
|
+
* or migrates it: the project's migrations do, before the new version serves.
|
|
13
|
+
*/
|
|
14
|
+
export declare const checkJobsSchema: (db: Db) => Promise<void>;
|
|
15
|
+
//#endregion
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { functionNames, functionsComment, functionsSql } from "./functions-sql.js";
|
|
2
|
+
import { CompiledQuery } from "kysely";
|
|
3
|
+
import { getConstructionPlans, getMigrationPlans } from "pg-boss";
|
|
4
|
+
//#region src/jobs/schema.ts
|
|
5
|
+
/** The schema the job queue lives in: pg-boss's tables and Protobase's functions. */
|
|
6
|
+
const jobsSchema = "jobs";
|
|
7
|
+
/** The command that writes what the database lacks as the project's next migration. */
|
|
8
|
+
const migrationCommand = "protobase migration";
|
|
9
|
+
/**
|
|
10
|
+
* A pg-boss plan as a migration the project applies in its own transaction: without its BEGIN and COMMIT, without the
|
|
11
|
+
* timeouts it sets for that transaction, which would hold for the project's later migrations too, and with index
|
|
12
|
+
* builds that are not `CONCURRENTLY`, which Postgres refuses in a transaction. Migrations run while nothing else
|
|
13
|
+
* writes, so a plain index build only costs a lock nobody waits on.
|
|
14
|
+
*/
|
|
15
|
+
const migrationStatements = (plan) => {
|
|
16
|
+
const match = /^\s*BEGIN;\n\s*SET LOCAL lock_timeout = \d+;\n\s*SET LOCAL idle_in_transaction_session_timeout = \d+;\n([\s\S]*?)\n\s*COMMIT;\n?([\s\S]*)$/.exec(plan);
|
|
17
|
+
if (!match) throw new Error(`Unexpected pg-boss plan:\n${plan.slice(0, 200)}`);
|
|
18
|
+
return `${match[1].trim()}\n${match[2]}`.replace(/\b(CREATE (?:UNIQUE )?INDEX|DROP INDEX|REINDEX INDEX) CONCURRENTLY\b/gi, "$1").trim();
|
|
19
|
+
};
|
|
20
|
+
const rows = async (db, text, parameters = []) => (await db.executeQuery(CompiledQuery.raw(text, parameters))).rows;
|
|
21
|
+
/** What the database holds of the jobs schema: pg-boss's version, when installed, and whether Protobase's functions are current. */
|
|
22
|
+
const readJobsSchema = async (db) => {
|
|
23
|
+
const [{ installed }] = await rows(db, `select to_regclass('${jobsSchema}.version') is not null as installed`);
|
|
24
|
+
if (!installed) return {
|
|
25
|
+
pgBoss: void 0,
|
|
26
|
+
functionsCurrent: false
|
|
27
|
+
};
|
|
28
|
+
const [version] = await rows(db, `select version from ${jobsSchema}.version`);
|
|
29
|
+
const functions = await rows(db, `select p.proname as name, obj_description(p.oid, 'pg_proc') as comment from pg_proc p
|
|
30
|
+
join pg_namespace n on n.oid = p.pronamespace where n.nspname = $1 and p.proname = any($2)`, [jobsSchema, functionNames]);
|
|
31
|
+
const functionsCurrent = functionNames.every((name) => functions.some((found) => found.name === name && found.comment === functionsComment));
|
|
32
|
+
return {
|
|
33
|
+
pgBoss: version === void 0 ? void 0 : Number(version.version),
|
|
34
|
+
functionsCurrent
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
const newer = (version) => /* @__PURE__ */ new Error(`The jobs schema is at pg-boss schema version ${version}, newer than the 45 this version of Protobase runs; upgrade Protobase`);
|
|
38
|
+
/**
|
|
39
|
+
* The SQL that brings the jobs schema to what this version of Protobase runs: pg-boss's own construction plan when it
|
|
40
|
+
* is missing, or its migration plan from the version installed, then Protobase's functions. `undefined` when current.
|
|
41
|
+
*/
|
|
42
|
+
const jobsSchemaMigration = async (db) => {
|
|
43
|
+
const { pgBoss, functionsCurrent } = await readJobsSchema(db);
|
|
44
|
+
if (pgBoss !== void 0 && pgBoss > 45) throw newer(pgBoss);
|
|
45
|
+
if (pgBoss === 45 && functionsCurrent) return void 0;
|
|
46
|
+
const parts = [];
|
|
47
|
+
if (pgBoss === void 0) parts.push(migrationStatements(getConstructionPlans(jobsSchema)));
|
|
48
|
+
else if (pgBoss < 45) parts.push(migrationStatements(getMigrationPlans(jobsSchema, pgBoss)));
|
|
49
|
+
parts.push(functionsSql(jobsSchema));
|
|
50
|
+
return parts.join("\n\n");
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* Refuses to go on while the jobs schema is missing or not the version this Protobase runs. The server never creates
|
|
54
|
+
* or migrates it: the project's migrations do, before the new version serves.
|
|
55
|
+
*/
|
|
56
|
+
const checkJobsSchema = async (db) => {
|
|
57
|
+
const { pgBoss, functionsCurrent } = await readJobsSchema(db);
|
|
58
|
+
if (pgBoss !== void 0 && pgBoss > 45) throw newer(pgBoss);
|
|
59
|
+
if (pgBoss === 45 && functionsCurrent) return;
|
|
60
|
+
throw new Error(`The jobs schema is ${pgBoss === void 0 ? "missing" : "behind this version of Protobase"}. Write it as a migration with \`${migrationCommand}\`, apply it with the project's migrations, then start again.`);
|
|
61
|
+
};
|
|
62
|
+
//#endregion
|
|
63
|
+
export { checkJobsSchema, jobsSchema, jobsSchemaMigration, migrationCommand, migrationStatements, readJobsSchema };
|
package/dist/project-config.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { AdminAuth } from "./better-auth/create-auth.js";
|
|
2
2
|
import { EventsOptions } from "./changes/events-options.js";
|
|
3
|
-
import { FilesOptions } from "./files/options.js";
|
|
4
3
|
import { Authenticator } from "./types.js";
|
|
4
|
+
import { WebhooksOptions } from "./webhooks/table/options.js";
|
|
5
|
+
import { FilesOptions } from "./files/options.js";
|
|
5
6
|
import { FunctionSource } from "./functions/define-function.js";
|
|
6
7
|
import { AdminOptions } from "./admin-options.js";
|
|
7
8
|
import { Db } from "@protobase/query";
|
|
@@ -24,13 +25,17 @@ export type ProjectConfig = {
|
|
|
24
25
|
files?: FilesOptions;
|
|
25
26
|
/** Where the changes that resources' events are raised from are captured (see `EventsOptions`). */
|
|
26
27
|
events?: EventsOptions;
|
|
28
|
+
/** Webhook subscriptions made in the admin app; set by `extends: [webhooks()]` (see `WebhooksOptions`). */
|
|
29
|
+
webhooks?: WebhooksOptions;
|
|
30
|
+
/** The module of the app's jobs, e.g. `import * as jobs from './config/jobs'`: every `defineJob` it exports. */
|
|
31
|
+
jobs?: Record<string, unknown>;
|
|
27
32
|
options?: AdminOptions;
|
|
28
33
|
};
|
|
29
34
|
/** Typed identity, for `export default defineConfig({ extends: [...], config, auth, ... })` in `protobase.config.ts`. */
|
|
30
35
|
export declare const defineConfig: (config: ProjectConfig) => ProjectConfig;
|
|
31
36
|
/**
|
|
32
37
|
* One config from several, each after the ones it `extends`, the last being the app's own. Named things are all kept:
|
|
33
|
-
* resources, views and pages of the config modules, functions, assistant tools, file providers; one name defined by two configs is an error naming
|
|
38
|
+
* resources, views and pages of the config modules, functions, jobs, assistant tools, file providers; one name defined by two configs is an error naming
|
|
34
39
|
* both. Write hooks run in the same order. Every other value is the last config's that sets it, so the app's own wins.
|
|
35
40
|
*/
|
|
36
41
|
export declare const mergeConfig: (...configs: ProjectConfig[]) => ProjectConfig;
|
package/dist/project-config.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { jobDefinitions } from "./jobs/define-job.js";
|
|
2
|
+
import { isSubscription } from "./webhooks/subscription.js";
|
|
1
3
|
import { isPageDefinition } from "@protobase/layout";
|
|
2
4
|
//#region src/project-config.ts
|
|
3
5
|
/** Typed identity, for `export default defineConfig({ extends: [...], config, auth, ... })` in `protobase.config.ts`. */
|
|
@@ -40,6 +42,11 @@ const mergedModule = (named) => {
|
|
|
40
42
|
value,
|
|
41
43
|
label
|
|
42
44
|
})));
|
|
45
|
+
checkUnique("The subscription", exports.filter(({ value }) => isSubscription(value)).map(({ value, label }) => ({
|
|
46
|
+
name: value.name,
|
|
47
|
+
value,
|
|
48
|
+
label
|
|
49
|
+
})));
|
|
43
50
|
const menus = values.filter(({ value }) => typeof value?.toUserMenuModel === "function");
|
|
44
51
|
if (new Set(menus.map(({ value }) => value)).size > 1) throw new Error(`Only one config may export a user menu; ${menus.map(({ label }) => label).join(" and ")} each do`);
|
|
45
52
|
const module = {};
|
|
@@ -104,6 +111,16 @@ const mergedFiles = (named) => {
|
|
|
104
111
|
...secret !== void 0 && { secret }
|
|
105
112
|
};
|
|
106
113
|
};
|
|
114
|
+
const mergedJobs = (named) => {
|
|
115
|
+
const all = named.flatMap(({ config, label }) => jobDefinitions(config.jobs ?? {}).map((value) => ({
|
|
116
|
+
name: value.name,
|
|
117
|
+
value,
|
|
118
|
+
label
|
|
119
|
+
})));
|
|
120
|
+
if (all.length === 0) return void 0;
|
|
121
|
+
checkUnique("The job", all);
|
|
122
|
+
return Object.fromEntries(all.map(({ name, value }) => [name, value]));
|
|
123
|
+
};
|
|
107
124
|
const mergedOptions = (named) => {
|
|
108
125
|
const all = named.flatMap(({ config }) => config.options ? [config.options] : []);
|
|
109
126
|
if (all.length === 0) return void 0;
|
|
@@ -117,20 +134,22 @@ const mergedOptions = (named) => {
|
|
|
117
134
|
};
|
|
118
135
|
/**
|
|
119
136
|
* One config from several, each after the ones it `extends`, the last being the app's own. Named things are all kept:
|
|
120
|
-
* resources, views and pages of the config modules, functions, assistant tools, file providers; one name defined by two configs is an error naming
|
|
137
|
+
* resources, views and pages of the config modules, functions, jobs, assistant tools, file providers; one name defined by two configs is an error naming
|
|
121
138
|
* both. Write hooks run in the same order. Every other value is the last config's that sets it, so the app's own wins.
|
|
122
139
|
*/
|
|
123
140
|
const mergeConfig = (...configs) => {
|
|
124
141
|
const named = configs.flatMap((config, index) => flatten(config, config.name ?? `config ${index + 1}`));
|
|
125
|
-
const { db, authenticate, auth, events } = lastSet(named.map(({ config }) => ({
|
|
142
|
+
const { db, authenticate, auth, events, webhooks } = lastSet(named.map(({ config }) => ({
|
|
126
143
|
db: config.db,
|
|
127
144
|
authenticate: config.authenticate,
|
|
128
145
|
auth: config.auth,
|
|
129
|
-
events: config.events
|
|
146
|
+
events: config.events,
|
|
147
|
+
webhooks: config.webhooks
|
|
130
148
|
})));
|
|
131
149
|
const module = mergedModule(named);
|
|
132
150
|
const functions = mergedFunctions(named);
|
|
133
151
|
const files = mergedFiles(named);
|
|
152
|
+
const jobs = mergedJobs(named);
|
|
134
153
|
const options = mergedOptions(named);
|
|
135
154
|
const name = configs.at(-1)?.name;
|
|
136
155
|
return {
|
|
@@ -142,6 +161,8 @@ const mergeConfig = (...configs) => {
|
|
|
142
161
|
...functions && { functions },
|
|
143
162
|
...files && { files },
|
|
144
163
|
...events && { events },
|
|
164
|
+
...webhooks && { webhooks },
|
|
165
|
+
...jobs && { jobs },
|
|
145
166
|
...options && { options }
|
|
146
167
|
};
|
|
147
168
|
};
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { SubscriptionDefinition } from "./webhooks/subscription.js";
|
|
2
|
+
import "./index.js";
|
|
1
3
|
import { PageDefinition } from "@protobase/layout";
|
|
2
|
-
import { AccessSource, FileProcessor, UserMenuSource, ViewModel } from "@protobase/schema";
|
|
4
|
+
import { AccessSource, EventMatch, FileProcessor, UserMenuSource, ViewModel } from "@protobase/schema";
|
|
3
5
|
import { z } from "zod";
|
|
4
6
|
//#region src/resource-source.d.ts
|
|
5
7
|
type FieldSource = {
|
|
@@ -22,10 +24,12 @@ export type ResourceSource = AccessSource & {
|
|
|
22
24
|
message: string;
|
|
23
25
|
}> | undefined)[];
|
|
24
26
|
recordSchema(): z.ZodType;
|
|
27
|
+
/** The code predicates of its events, by event name. */
|
|
28
|
+
eventMatches?(): Record<string, EventMatch<any>>;
|
|
25
29
|
};
|
|
26
30
|
/**
|
|
27
|
-
* Splits a config module (`import * as config from './config'`) into its resources, views, pages and,
|
|
28
|
-
* one `userMenu(...)`, the user menu.
|
|
31
|
+
* Splits a config module (`import * as config from './config'`) into its resources, views, pages, subscriptions and,
|
|
32
|
+
* when it exports one `userMenu(...)`, the user menu.
|
|
29
33
|
*/
|
|
30
34
|
export declare const configExports: (module: Record<string, unknown>) => {
|
|
31
35
|
userMenu?: UserMenuSource | undefined;
|
|
@@ -34,5 +38,6 @@ export declare const configExports: (module: Record<string, unknown>) => {
|
|
|
34
38
|
toModel(): ViewModel;
|
|
35
39
|
}>;
|
|
36
40
|
pages: PageDefinition[];
|
|
41
|
+
subscriptions: SubscriptionDefinition[];
|
|
37
42
|
};
|
|
38
43
|
//#endregion
|
package/dist/resource-source.js
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
|
+
import { isSubscription } from "./webhooks/subscription.js";
|
|
1
2
|
import { isPageDefinition } from "@protobase/layout";
|
|
2
3
|
//#region src/resource-source.ts
|
|
3
4
|
/**
|
|
4
|
-
* Splits a config module (`import * as config from './config'`) into its resources, views, pages and,
|
|
5
|
-
* one `userMenu(...)`, the user menu.
|
|
5
|
+
* Splits a config module (`import * as config from './config'`) into its resources, views, pages, subscriptions and,
|
|
6
|
+
* when it exports one `userMenu(...)`, the user menu.
|
|
6
7
|
*/
|
|
7
8
|
const configExports = (module) => {
|
|
8
9
|
const values = Object.values(module);
|
|
9
10
|
const pages = values.filter(isPageDefinition);
|
|
10
|
-
const
|
|
11
|
+
const subscriptions = [...new Set(values.filter(isSubscription))];
|
|
12
|
+
const others = values.filter((value) => !isPageDefinition(value) && !isSubscription(value));
|
|
11
13
|
const buildable = others.filter((value) => typeof value?.toModel === "function");
|
|
12
14
|
const menus = others.filter((value) => typeof value?.toUserMenuModel === "function");
|
|
13
15
|
if (menus.length > 1) throw new Error(`The config module exports ${menus.length} user menus; export one userMenu(...)`);
|
|
@@ -15,6 +17,7 @@ const configExports = (module) => {
|
|
|
15
17
|
resources: buildable.filter((value) => typeof value.recordSchema === "function"),
|
|
16
18
|
views: buildable.filter((value) => typeof value.recordSchema !== "function"),
|
|
17
19
|
pages,
|
|
20
|
+
subscriptions,
|
|
18
21
|
...menus[0] && { userMenu: menus[0] }
|
|
19
22
|
};
|
|
20
23
|
};
|
package/dist/types.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { JobDefinition } from "./jobs/job-types.js";
|
|
1
2
|
import { Hono } from "hono";
|
|
2
3
|
import { PageModel } from "@protobase/layout";
|
|
3
4
|
import { ResourceModel, ViewModel } from "@protobase/schema";
|
|
@@ -88,5 +89,8 @@ export type ViewSource = {
|
|
|
88
89
|
export type PageSource = {
|
|
89
90
|
toModel(): PageModel;
|
|
90
91
|
};
|
|
91
|
-
|
|
92
|
+
/** The admin API, and the jobs it needs a worker to run: the delivery of resource events. */
|
|
93
|
+
export type Admin = Hono<AdminEnv> & {
|
|
94
|
+
jobs: JobDefinition[];
|
|
95
|
+
};
|
|
92
96
|
//#endregion
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { invokeFunction } from "../functions/define-function.js";
|
|
2
|
+
import { retryAfterMs } from "./retry-schedule.js";
|
|
3
|
+
import { standardWebhookHeaders } from "./standard-webhooks.js";
|
|
4
|
+
import { subscriberSession } from "./subscriber-view.js";
|
|
5
|
+
//#region src/webhooks/attempt.ts
|
|
6
|
+
/** How long a receiver has to answer. */
|
|
7
|
+
const attemptTimeoutMs = 15e3;
|
|
8
|
+
const excerpt = async (response) => (await response.text()).slice(0, 200);
|
|
9
|
+
const resultOf = async (response) => {
|
|
10
|
+
if (response.status >= 200 && response.status < 300) return {
|
|
11
|
+
ok: true,
|
|
12
|
+
status: response.status
|
|
13
|
+
};
|
|
14
|
+
const body = await excerpt(response);
|
|
15
|
+
const wait = retryAfterMs(response.headers.get("retry-after"));
|
|
16
|
+
return {
|
|
17
|
+
ok: false,
|
|
18
|
+
status: response.status,
|
|
19
|
+
error: `${response.status}${body ? `: ${body}` : ""}`,
|
|
20
|
+
...wait !== void 0 && { retryAfterMs: wait },
|
|
21
|
+
final: response.status === 410
|
|
22
|
+
};
|
|
23
|
+
};
|
|
24
|
+
const request = async (url, event, options, signal) => {
|
|
25
|
+
const body = JSON.stringify(options.body ? options.body(event) : event);
|
|
26
|
+
const signed = await standardWebhookHeaders(event.id, Math.floor(Date.now() / 1e3), body, options.secret);
|
|
27
|
+
return new Request(url, {
|
|
28
|
+
method: "POST",
|
|
29
|
+
headers: {
|
|
30
|
+
...options.headers,
|
|
31
|
+
...signed,
|
|
32
|
+
"content-type": "application/json"
|
|
33
|
+
},
|
|
34
|
+
body,
|
|
35
|
+
redirect: "manual",
|
|
36
|
+
signal
|
|
37
|
+
});
|
|
38
|
+
};
|
|
39
|
+
const callFunction = async (name, subscription, event, options, context, signal) => {
|
|
40
|
+
const fn = context.functions[name];
|
|
41
|
+
if (!fn) throw new Error(`The app has no function "${name}"`);
|
|
42
|
+
const session = subscriberSession(subscription) ?? { user: {
|
|
43
|
+
id: `subscription:${subscription.name}`,
|
|
44
|
+
roles: []
|
|
45
|
+
} };
|
|
46
|
+
const caller = {
|
|
47
|
+
session,
|
|
48
|
+
records: context.records(session)
|
|
49
|
+
};
|
|
50
|
+
const functionContext = {
|
|
51
|
+
name,
|
|
52
|
+
db: context.db,
|
|
53
|
+
caller: async () => caller
|
|
54
|
+
};
|
|
55
|
+
return invokeFunction(name, fn, await request("http://protobase.internal/", event, options, signal), fn.options.public ? functionContext : {
|
|
56
|
+
...functionContext,
|
|
57
|
+
...caller
|
|
58
|
+
});
|
|
59
|
+
};
|
|
60
|
+
const failure = (error) => ({
|
|
61
|
+
ok: false,
|
|
62
|
+
error: error instanceof Error ? error.message : String(error),
|
|
63
|
+
final: false
|
|
64
|
+
});
|
|
65
|
+
/** One try of one event to one subscription's target, within `attemptTimeoutMs`. It never throws. */
|
|
66
|
+
const attemptDelivery = async (subscription, event, context) => {
|
|
67
|
+
const signal = AbortSignal.any([context.signal, AbortSignal.timeout(attemptTimeoutMs)]);
|
|
68
|
+
const { options } = subscription;
|
|
69
|
+
try {
|
|
70
|
+
if ("run" in options) {
|
|
71
|
+
await options.run(event, {
|
|
72
|
+
db: context.db,
|
|
73
|
+
attempt: context.attempt,
|
|
74
|
+
signal
|
|
75
|
+
});
|
|
76
|
+
return { ok: true };
|
|
77
|
+
}
|
|
78
|
+
if ("function" in options) return await resultOf(await callFunction(options.function, subscription, event, options, context, signal));
|
|
79
|
+
const blocked = await context.checkUrl?.(options.url);
|
|
80
|
+
if (blocked) return {
|
|
81
|
+
ok: false,
|
|
82
|
+
error: blocked,
|
|
83
|
+
final: true
|
|
84
|
+
};
|
|
85
|
+
return await resultOf(await fetch(await request(options.url, event, options, signal)));
|
|
86
|
+
} catch (error) {
|
|
87
|
+
return failure(error);
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
//#endregion
|
|
91
|
+
export { attemptDelivery, attemptTimeoutMs };
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
//#region src/webhooks/retry-schedule.ts
|
|
2
|
+
const second = 1e3;
|
|
3
|
+
const minute = 60 * second;
|
|
4
|
+
const hour = 60 * minute;
|
|
5
|
+
/**
|
|
6
|
+
* The waits before each retry, as Standard Webhooks recommends: 5 s, 5 m, 30 m, 2 h, 5 h, 10 h, 14 h, 20 h and 24 h,
|
|
7
|
+
* about three days in all, so a delivery has 10 tries.
|
|
8
|
+
*/
|
|
9
|
+
const retryDelays = [
|
|
10
|
+
5 * second,
|
|
11
|
+
5 * minute,
|
|
12
|
+
30 * minute,
|
|
13
|
+
2 * hour,
|
|
14
|
+
5 * hour,
|
|
15
|
+
10 * hour,
|
|
16
|
+
14 * hour,
|
|
17
|
+
20 * hour,
|
|
18
|
+
24 * hour
|
|
19
|
+
];
|
|
20
|
+
const maxTries = retryDelays.length + 1;
|
|
21
|
+
/**
|
|
22
|
+
* How long to wait before the try after `tried` failed ones: the schedule with up to 10% jitter, or longer when the
|
|
23
|
+
* receiver asked with Retry-After, up to a day. `undefined` once the tries are used up.
|
|
24
|
+
*/
|
|
25
|
+
const nextRetryDelay = (tried, retryAfterMs, random = Math.random) => {
|
|
26
|
+
const scheduled = retryDelays[tried - 1];
|
|
27
|
+
if (scheduled === void 0) return void 0;
|
|
28
|
+
const jittered = Math.round(scheduled * (1 + (random() * 2 - 1) * .1));
|
|
29
|
+
return Math.min(Math.max(jittered, retryAfterMs ?? 0), 24 * hour);
|
|
30
|
+
};
|
|
31
|
+
/** A Retry-After header in milliseconds from `now`: seconds, or an HTTP date. */
|
|
32
|
+
const retryAfterMs = (header, now = Date.now()) => {
|
|
33
|
+
if (header === null) return void 0;
|
|
34
|
+
const text = header.trim();
|
|
35
|
+
if (/^\d+$/.test(text)) return Number(text) * second;
|
|
36
|
+
const at = Date.parse(text);
|
|
37
|
+
return Number.isNaN(at) ? void 0 : Math.max(0, at - now);
|
|
38
|
+
};
|
|
39
|
+
//#endregion
|
|
40
|
+
export { maxTries, nextRetryDelay, retryAfterMs, retryDelays };
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { eventPatternMatches } from "./subscription.js";
|
|
2
|
+
import { checkEventFilter, evaluateFilter } from "@protobase/schema";
|
|
3
|
+
//#region src/webhooks/route-events.ts
|
|
4
|
+
const declaredEvents = (models) => models.flatMap((model) => (model.events ?? []).map((event) => ({
|
|
5
|
+
model,
|
|
6
|
+
name: event.name
|
|
7
|
+
})));
|
|
8
|
+
const coveredModels = (subscription, models) => {
|
|
9
|
+
const events = declaredEvents(models).filter(({ name }) => subscription.options.events.some((pattern) => eventPatternMatches(pattern, name)));
|
|
10
|
+
return [...new Set(events.map(({ model }) => model))];
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* What is wrong with the subscriptions, given the resources and the app's API functions: a pattern that names no
|
|
14
|
+
* declared event, a `when` that does not check against a resource it covers, a missing function or URL, roles left
|
|
15
|
+
* empty. Disabled subscriptions are checked too, except for their URL.
|
|
16
|
+
*/
|
|
17
|
+
const subscriptionProblems = (subscriptions, models, functions) => subscriptions.flatMap((subscription) => {
|
|
18
|
+
const { name, options } = subscription;
|
|
19
|
+
const declared = declaredEvents(models).map((event) => event.name);
|
|
20
|
+
return [
|
|
21
|
+
...options.events.filter((pattern) => !declared.some((event) => eventPatternMatches(pattern, event))).map((pattern) => `"${pattern}" matches no declared event`),
|
|
22
|
+
...options.when === void 0 ? [] : coveredModels(subscription, models).flatMap((model) => {
|
|
23
|
+
const { problem } = checkEventFilter(model, options.when);
|
|
24
|
+
return problem ? [`when ${problem} on ${model.name}`] : [];
|
|
25
|
+
}),
|
|
26
|
+
..."function" in options && !functions.includes(options.function) ? [`calls the function "${options.function}", which the app does not have`] : [],
|
|
27
|
+
..."url" in options && options.enabled !== false && !options.url ? ["has an empty url; is its environment variable set?"] : [],
|
|
28
|
+
...options.as && options.as.roles.length === 0 ? ["delivers as no roles"] : []
|
|
29
|
+
].map((problem) => `Subscription "${name}" ${problem}`);
|
|
30
|
+
});
|
|
31
|
+
/**
|
|
32
|
+
* Which subscriptions take which of a change's events: enabled, naming the event, of the change's tenant when they set
|
|
33
|
+
* one, and passing their `when` on the record after the change (before it, for a delete). Filters are checked once,
|
|
34
|
+
* here; run `subscriptionProblems` first.
|
|
35
|
+
*/
|
|
36
|
+
const subscriptionRouter = (subscriptions, models) => {
|
|
37
|
+
const filters = /* @__PURE__ */ new Map();
|
|
38
|
+
for (const subscription of subscriptions) {
|
|
39
|
+
if (subscription.options.when === void 0) continue;
|
|
40
|
+
for (const model of coveredModels(subscription, models)) {
|
|
41
|
+
const { filter } = checkEventFilter(model, subscription.options.when);
|
|
42
|
+
if (filter) filters.set(`${subscription.name}\0${model.name}`, filter);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const passes = (subscription, change) => {
|
|
46
|
+
const { options } = subscription;
|
|
47
|
+
if (options.enabled === false) return false;
|
|
48
|
+
if (options.tenant !== void 0 && String(options.tenant) !== String(change.tenant)) return false;
|
|
49
|
+
const filter = filters.get(`${subscription.name}\0${change.resource}`);
|
|
50
|
+
return !filter || evaluateFilter(filter, change.after ?? change.before) === true;
|
|
51
|
+
};
|
|
52
|
+
return (change, events) => subscriptions.flatMap((subscription) => {
|
|
53
|
+
if (!passes(subscription, change)) return [];
|
|
54
|
+
return events.filter((event) => subscription.options.events.some((pattern) => eventPatternMatches(pattern, event))).map((event) => ({
|
|
55
|
+
subscription,
|
|
56
|
+
event
|
|
57
|
+
}));
|
|
58
|
+
});
|
|
59
|
+
};
|
|
60
|
+
//#endregion
|
|
61
|
+
export { subscriptionProblems, subscriptionRouter };
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
//#region src/webhooks/standard-webhooks.ts
|
|
2
|
+
const base64 = (bytes) => btoa(String.fromCharCode(...bytes));
|
|
3
|
+
const secretKey = (secret) => {
|
|
4
|
+
const text = secret.replace(/^whsec_/, "");
|
|
5
|
+
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(text)) throw new Error("A webhook secret is `whsec_` and base64");
|
|
6
|
+
return Uint8Array.from(atob(text), (char) => char.charCodeAt(0));
|
|
7
|
+
};
|
|
8
|
+
/** A new signing secret, `whsec_` and 32 random bytes in base64. */
|
|
9
|
+
const newWebhookSecret = () => `whsec_${base64(crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(32)))}`;
|
|
10
|
+
const sign = async (secret, content) => {
|
|
11
|
+
const key = await crypto.subtle.importKey("raw", secretKey(secret), {
|
|
12
|
+
name: "HMAC",
|
|
13
|
+
hash: "SHA-256"
|
|
14
|
+
}, false, ["sign"]);
|
|
15
|
+
return `v1,${base64(new Uint8Array(await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(content))))}`;
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* The Standard Webhooks headers of one request (https://www.standardwebhooks.com): its id, its time in seconds and,
|
|
19
|
+
* with a secret, the HMAC-SHA256 of `id.timestamp.body` as `v1,<base64>`; with several, one signature per secret,
|
|
20
|
+
* separated by spaces, as during a rotation.
|
|
21
|
+
*/
|
|
22
|
+
const standardWebhookHeaders = async (id, timestamp, body, secret) => {
|
|
23
|
+
const headers = {
|
|
24
|
+
"webhook-id": id,
|
|
25
|
+
"webhook-timestamp": String(timestamp)
|
|
26
|
+
};
|
|
27
|
+
const secrets = secret === void 0 ? [] : [secret].flat();
|
|
28
|
+
if (secrets.length === 0) return headers;
|
|
29
|
+
const signatures = await Promise.all(secrets.map((each) => sign(each, `${id}.${timestamp}.${body}`)));
|
|
30
|
+
return {
|
|
31
|
+
...headers,
|
|
32
|
+
"webhook-signature": signatures.join(" ")
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
//#endregion
|
|
36
|
+
export { newWebhookSecret, standardWebhookHeaders };
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { requestAccess } from "../request-access.js";
|
|
2
|
+
import { checkRecord, evaluateFilter } from "@protobase/schema";
|
|
3
|
+
//#region src/webhooks/subscriber-view.ts
|
|
4
|
+
/**
|
|
5
|
+
* The session a subscription delivers as, or `undefined` for the system: every record, every field but sensitive ones.
|
|
6
|
+
* Its tenant is the change's, typed as the app's, when the subscription has one.
|
|
7
|
+
*/
|
|
8
|
+
const subscriberSession = ({ name, options }, change) => {
|
|
9
|
+
if (!options.as) return void 0;
|
|
10
|
+
const tenant = options.tenant === void 0 ? void 0 : change?.tenant ?? options.tenant;
|
|
11
|
+
return {
|
|
12
|
+
user: {
|
|
13
|
+
id: `subscription:${name}`,
|
|
14
|
+
roles: options.as.roles
|
|
15
|
+
},
|
|
16
|
+
...tenant !== void 0 && {
|
|
17
|
+
tenant,
|
|
18
|
+
organization: { id: tenant }
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
};
|
|
22
|
+
const pick = (record, fields) => fields ? Object.fromEntries(Object.entries(record).filter(([name]) => fields.includes(name))) : record;
|
|
23
|
+
/** A change's event as a subscriber sees it: only `fields` of its records (all without), none at all when `thin`. */
|
|
24
|
+
const subscriptionEvent = (change, event, { fields, thin = false } = {}) => ({
|
|
25
|
+
id: `evt_${change.sequence}_${event.slice(event.indexOf(".") + 1)}`,
|
|
26
|
+
type: event,
|
|
27
|
+
timestamp: change.at,
|
|
28
|
+
data: {
|
|
29
|
+
resource: change.resource,
|
|
30
|
+
operation: change.operation,
|
|
31
|
+
key: change.key,
|
|
32
|
+
sequence: change.sequence,
|
|
33
|
+
...!thin && change.after && { after: pick(change.after, fields) },
|
|
34
|
+
...!thin && change.before && { before: pick(change.before, fields) },
|
|
35
|
+
changed: fields ? change.changed.filter((name) => fields.includes(name)) : change.changed,
|
|
36
|
+
...change.tenant !== void 0 && { tenant: change.tenant },
|
|
37
|
+
actor: change.actor,
|
|
38
|
+
source: change.source,
|
|
39
|
+
transaction: change.transaction
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
const mayRead = async (access, record) => {
|
|
43
|
+
const { resolved, session, entry } = access;
|
|
44
|
+
if (!resolved.operations.read) return false;
|
|
45
|
+
if (resolved.rowFilter.read && evaluateFilter(resolved.rowFilter.read, record) !== true) return false;
|
|
46
|
+
if (!resolved.recordChecks.includes("read")) return true;
|
|
47
|
+
const context = {
|
|
48
|
+
user: session.user,
|
|
49
|
+
...session.organization && { organization: session.organization }
|
|
50
|
+
};
|
|
51
|
+
return checkRecord(entry.source, context, "read", record, void 0, access.deps.accessOptions);
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* Builds each subscriber's event, under the access rules of the session it delivers as, resolved once per subscription
|
|
55
|
+
* and resource: `undefined` when it may not read the record, after the change or before a delete.
|
|
56
|
+
*/
|
|
57
|
+
const subscriberEvents = (deps) => {
|
|
58
|
+
const accesses = /* @__PURE__ */ new Map();
|
|
59
|
+
return async (subscription, change, event) => {
|
|
60
|
+
const thin = subscription.options.payload === "thin";
|
|
61
|
+
const session = subscriberSession(subscription, change);
|
|
62
|
+
if (!session) return subscriptionEvent(change, event, { thin });
|
|
63
|
+
const entry = deps.registry.find(change.resource);
|
|
64
|
+
if (!entry) throw new Error(`Not implemented: a change of "${change.resource}", which is no resource of the app`);
|
|
65
|
+
const cacheKey = `${subscription.name}\0${change.resource}`;
|
|
66
|
+
if (!accesses.has(cacheKey)) accesses.set(cacheKey, requestAccess(deps, entry, session));
|
|
67
|
+
const access = await accesses.get(cacheKey);
|
|
68
|
+
if (!await mayRead(access, change.after ?? change.before)) return void 0;
|
|
69
|
+
return subscriptionEvent(change, event, {
|
|
70
|
+
fields: Object.keys(access.readable.fields),
|
|
71
|
+
thin
|
|
72
|
+
});
|
|
73
|
+
};
|
|
74
|
+
};
|
|
75
|
+
//#endregion
|
|
76
|
+
export { subscriberEvents, subscriberSession, subscriptionEvent };
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { Row, TenantValue } from "../types.js";
|
|
2
|
+
import { EventActor, EventOperation } from "@protobase/schema";
|
|
3
|
+
import { Db } from "@protobase/query";
|
|
4
|
+
//#region src/webhooks/subscription.d.ts
|
|
5
|
+
/** What happened, as a receiver gets it in the `data` of an event. Records use field names, as the API shows them. */
|
|
6
|
+
export type EventData = {
|
|
7
|
+
resource: string;
|
|
8
|
+
operation: EventOperation;
|
|
9
|
+
/** The record's key as its URL has it. */
|
|
10
|
+
key: string;
|
|
11
|
+
/** Grows with every change of a record, so a receiver can drop an event older than one it has. */
|
|
12
|
+
sequence: string;
|
|
13
|
+
after?: Row;
|
|
14
|
+
before?: Row;
|
|
15
|
+
changed: string[];
|
|
16
|
+
tenant?: TenantValue;
|
|
17
|
+
actor: EventActor | null;
|
|
18
|
+
source: 'api' | 'database';
|
|
19
|
+
transaction: string;
|
|
20
|
+
};
|
|
21
|
+
/** One event as it is delivered: the body of a URL or function target, and what a `run` handler gets. */
|
|
22
|
+
export type SubscriptionEvent = {
|
|
23
|
+
/** The same on every retry and resend: the receiver's idempotency key (`webhook-id`). */
|
|
24
|
+
id: string;
|
|
25
|
+
type: string;
|
|
26
|
+
/** When the change's transaction started, ISO 8601. */
|
|
27
|
+
timestamp: string;
|
|
28
|
+
data: EventData;
|
|
29
|
+
/** Sent by Send test event: made up from a current record, not raised by a change. */
|
|
30
|
+
test?: true;
|
|
31
|
+
};
|
|
32
|
+
/** What a `run` handler gets beside the event. */
|
|
33
|
+
export type RunContext = {
|
|
34
|
+
db: Db;
|
|
35
|
+
/** 1 for the first try. */
|
|
36
|
+
attempt: number;
|
|
37
|
+
signal: AbortSignal;
|
|
38
|
+
};
|
|
39
|
+
/** What every subscription may set beside its target. */
|
|
40
|
+
export type SubscriptionCommon = {
|
|
41
|
+
/** Event names, such as `orders.created`, `orders.*` for every event of a resource, or `*` for all of them. */
|
|
42
|
+
events: string[];
|
|
43
|
+
/** A filter on the record, after the change or before a delete, in the language of access rules. */
|
|
44
|
+
when?: string;
|
|
45
|
+
/**
|
|
46
|
+
* Who the subscription delivers as: it gets only the records and fields these roles may read. Without it, every record
|
|
47
|
+
* and every field but the sensitive ones.
|
|
48
|
+
*/
|
|
49
|
+
as?: {
|
|
50
|
+
roles: string[];
|
|
51
|
+
};
|
|
52
|
+
/** Only events of records of this tenant (organization). */
|
|
53
|
+
tenant?: TenantValue;
|
|
54
|
+
/** `thin` leaves the records out: a receiver reads them through the API. Default `full`. */
|
|
55
|
+
payload?: 'full' | 'thin';
|
|
56
|
+
/** One delivery at a time per record, in order; a failing one holds back the record's next. Default false. */
|
|
57
|
+
ordered?: boolean;
|
|
58
|
+
/** Default true. */
|
|
59
|
+
enabled?: boolean;
|
|
60
|
+
};
|
|
61
|
+
/** How a URL or function target is called: a POST of the event as JSON, signed with Standard Webhooks headers. */
|
|
62
|
+
export type SentOptions = {
|
|
63
|
+
/** Signs the requests (`whsec_` and base64, as `verifyStandardWebhook` takes it); unsigned without one. Several sign during a rotation. */
|
|
64
|
+
secret?: string | string[];
|
|
65
|
+
headers?: Record<string, string>;
|
|
66
|
+
/** The body sent instead of the event, such as Slack's `{ text }`. */
|
|
67
|
+
body?: (event: SubscriptionEvent) => unknown;
|
|
68
|
+
};
|
|
69
|
+
export type UrlTarget = {
|
|
70
|
+
url: string | undefined;
|
|
71
|
+
} & SentOptions;
|
|
72
|
+
/** An API function of the app, `/api/functions/<name>`, called in this process. */
|
|
73
|
+
export type FunctionTarget = {
|
|
74
|
+
function: string;
|
|
75
|
+
} & SentOptions;
|
|
76
|
+
/** Code run for each event; it throws to have the event retried. Config only. */
|
|
77
|
+
export type RunTarget = {
|
|
78
|
+
run: (event: SubscriptionEvent, context: RunContext) => Promise<void> | void;
|
|
79
|
+
};
|
|
80
|
+
export type SubscriptionOptions = SubscriptionCommon & (UrlTarget | FunctionTarget | RunTarget);
|
|
81
|
+
export type SubscriptionDefinition = {
|
|
82
|
+
readonly kind: 'subscription';
|
|
83
|
+
readonly name: string;
|
|
84
|
+
readonly options: SubscriptionOptions;
|
|
85
|
+
};
|
|
86
|
+
/**
|
|
87
|
+
* Delivers the events it names, as they happen: export it from the config module like a resource. The URL usually
|
|
88
|
+
* comes from the environment (`url: process.env.SLACK_WEBHOOK`); the server refuses to start while an enabled one is
|
|
89
|
+
* empty.
|
|
90
|
+
*/
|
|
91
|
+
export declare const subscription: (name: string, options: SubscriptionOptions) => SubscriptionDefinition;
|
|
92
|
+
//#endregion
|