@protobase/server 0.68.5 → 0.69.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.
Files changed (81) hide show
  1. package/dist/better-auth/auth-settings.d.ts +155 -28
  2. package/dist/better-auth/auth-settings.js +3 -2
  3. package/dist/better-auth/connections/connection-account-hooks.js +62 -0
  4. package/dist/better-auth/connections/connection-events.d.ts +11 -0
  5. package/dist/better-auth/connections/connection-events.js +16 -0
  6. package/dist/better-auth/connections/connection-oauth-provider.js +129 -0
  7. package/dist/better-auth/connections/connections-plugin.js +98 -0
  8. package/dist/better-auth/connections/declared-connections.d.ts +21 -0
  9. package/dist/better-auth/connections/declared-connections.js +54 -0
  10. package/dist/better-auth/connections/revoke-token.js +34 -0
  11. package/dist/better-auth/create-auth.d.ts +162 -17
  12. package/dist/better-auth/create-auth.js +59 -5
  13. package/dist/better-auth/refresh-refusals.js +43 -0
  14. package/dist/better-auth/staff-checks.js +1 -0
  15. package/dist/create-admin.js +11 -1
  16. package/dist/forms/rate-limit.js +33 -11
  17. package/dist/forms/routes.js +1 -1
  18. package/dist/functions/verify-signature.js +4 -2
  19. package/dist/index.d.ts +2 -1
  20. package/dist/index.js +3 -2
  21. package/dist/locale/en.js +3 -0
  22. package/dist/locale/nl.js +3 -0
  23. package/dist/mcp/mcp-tools.d.ts +2 -1
  24. package/dist/mcp/mcp-tools.js +4 -2
  25. package/dist/notifications/history-routes.js +1 -1
  26. package/dist/package.js +1 -1
  27. package/dist/personal-mail/linked-accounts.js +17 -13
  28. package/dist/types.d.ts +2 -0
  29. package/dist/webhooks/attempt.js +2 -2
  30. package/dist/webhooks/table/webhook-table.js +1 -1
  31. package/dist/workflows/connections/connection-accounts.js +18 -0
  32. package/dist/workflows/connections/connection-consent.js +49 -0
  33. package/dist/workflows/connections/connection-provider.js +10 -0
  34. package/dist/workflows/connections/connection-tokens.js +54 -0
  35. package/dist/workflows/connections/connection-triggers.js +37 -0
  36. package/dist/workflows/connections/connection-wiring.js +73 -0
  37. package/dist/workflows/connections/enabler-authority.js +35 -0
  38. package/dist/workflows/connections/enabler-mailbox.js +15 -0
  39. package/dist/workflows/connections/single-flight.js +17 -0
  40. package/dist/workflows/hooks/hook-auth.js +40 -0
  41. package/dist/workflows/hooks/hook-event.js +51 -0
  42. package/dist/workflows/hooks/hook-paths.d.ts +16 -0
  43. package/dist/workflows/hooks/hook-paths.js +32 -0
  44. package/dist/workflows/hooks/hook-route.js +99 -0
  45. package/dist/workflows/hooks/hook-runs.js +32 -0
  46. package/dist/workflows/hooks/hook-secrets.js +42 -0
  47. package/dist/workflows/hooks/hook-store.js +35 -0
  48. package/dist/workflows/hooks/hooks-sql.js +20 -0
  49. package/dist/workflows/hooks/issue-body.js +21 -0
  50. package/dist/workflows/hooks/issue-route.js +23 -0
  51. package/dist/workflows/hooks/issue-webhook.js +46 -0
  52. package/dist/workflows/hooks/read-body.js +32 -0
  53. package/dist/workflows/may-update-workflow.js +26 -0
  54. package/dist/workflows/mcp/webhook-tools.d.ts +9 -0
  55. package/dist/workflows/mcp/webhook-tools.js +103 -0
  56. package/dist/workflows/mcp/workflow-instructions.js +1 -1
  57. package/dist/workflows/mcp/workflow-read-tools.d.ts +3 -0
  58. package/dist/workflows/plugin-records.js +1 -1
  59. package/dist/workflows/plugin-services.js +1 -1
  60. package/dist/workflows/ready/ready-start.js +1 -1
  61. package/dist/workflows/run-files.js +7 -1
  62. package/dist/workflows/save-hook.js +7 -2
  63. package/dist/workflows/schema-sql.js +17 -3
  64. package/dist/workflows/schema.js +3 -1
  65. package/dist/workflows/start-workflow.d.ts +3 -1
  66. package/dist/workflows/start-workflow.js +10 -4
  67. package/dist/workflows/triggers/delete-hook.js +15 -0
  68. package/dist/workflows/triggers/plugin-call.js +45 -0
  69. package/dist/workflows/triggers/poll-job.js +120 -0
  70. package/dist/workflows/triggers/poll-result.js +32 -0
  71. package/dist/workflows/triggers/poll-schedule.js +32 -0
  72. package/dist/workflows/triggers/trigger-claim.js +81 -0
  73. package/dist/workflows/triggers/trigger-jobs.js +53 -7
  74. package/dist/workflows/triggers/trigger-start.js +112 -0
  75. package/dist/workflows/triggers/trigger-sync.js +15 -10
  76. package/dist/workflows/workflow-engine.js +57 -9
  77. package/dist/workflows/workflow-types.d.ts +11 -4
  78. package/dist/workflows/workflow-types.js +21 -15
  79. package/dist/write-shared.js +1 -0
  80. package/package.json +6 -6
  81. package/dist/better-auth/end-replaced-session.d.ts +0 -17
@@ -3,8 +3,8 @@ import { tenantScope } from "../tenant.js";
3
3
  import { issueTicket } from "../files/ticket.js";
4
4
  import { runBatch } from "../batch/run-batch.js";
5
5
  import { assistantRecords, toolEntry } from "../assistant/records.js";
6
- import { effectsStore } from "./effects-store.js";
7
6
  import { runFileRef } from "./run-files.js";
7
+ import { effectsStore } from "./effects-store.js";
8
8
  import { sql } from "kysely";
9
9
  import { StepError } from "@protobase/workflows";
10
10
  //#region src/workflows/plugin-records.ts
@@ -1,6 +1,6 @@
1
+ import { pluginFetch } from "./plugin-fetch.js";
1
2
  import { readRunFile, storeRunFile } from "./run-files.js";
2
3
  import { pluginRecords } from "./plugin-records.js";
3
- import { pluginFetch } from "./plugin-fetch.js";
4
4
  //#region src/workflows/plugin-services.ts
5
5
  /** The plugin context's `files`: those of the run alone, in the app's `private` file provider. */
6
6
  const runFiles = (files, runId) => ({
@@ -1,7 +1,7 @@
1
1
  import { HttpProblem } from "../../problem.js";
2
2
  import { assistantRecords } from "../../assistant/records.js";
3
- import { saverSession } from "../plugin-records.js";
4
3
  import { startWorkflow } from "../start-workflow.js";
4
+ import { saverSession } from "../plugin-records.js";
5
5
  import { triggerFailed, triggerRecovered } from "../triggers/trigger-row.js";
6
6
  import { encodeKey } from "@protobase/schema/internal";
7
7
  import { checkFilter, evaluateFilter, parseFilter } from "@protobase/schema";
@@ -62,5 +62,11 @@ const readRunFile = async (files, runId, value, field) => {
62
62
  bytes
63
63
  };
64
64
  };
65
+ /** Removes a file a run stored, as when the run it was kept for turned out to be started already. */
66
+ const removeRunFile = async (files, value) => {
67
+ const ref = parseUri(value.uri);
68
+ if (!ref) throw new Error(`${value.uri} is not a file URI`);
69
+ await runStore(files).delete(ref.path);
70
+ };
65
71
  //#endregion
66
- export { isFileValue, readRunFile, runFileLimit, runFileRef, storeRunFile };
72
+ export { isFileValue, readRunFile, removeRunFile, runFileLimit, runFileRef, storeRunFile };
@@ -1,5 +1,6 @@
1
1
  import { badRequest } from "../problem.js";
2
2
  import { syncTrigger } from "./triggers/trigger-sync.js";
3
+ import { saveConsent } from "./connections/connection-consent.js";
3
4
  import { sql } from "kysely";
4
5
  import { checkDefinition, problemText } from "@protobase/workflows";
5
6
  //#region src/workflows/save-hook.ts
@@ -11,7 +12,7 @@ import { checkDefinition, problemText } from "@protobase/workflows";
11
12
  * `definition` field's `errors` with the state and field in their `params`, for a form to show each on its step; the
12
13
  * write rolls back.
13
14
  */
14
- const workflowSaveHook = (schema, types, jobs) => async (event, trx) => {
15
+ const workflowSaveHook = (schema, types, jobs, consent) => async (event, trx) => {
15
16
  if (event.resource.name !== "workflows" || !event.after) return;
16
17
  if (event.operation !== "create" && event.operation !== "update") return;
17
18
  const changed = event.operation === "create" || event.after.version !== event.before?.version;
@@ -35,7 +36,11 @@ const workflowSaveHook = (schema, types, jobs) => async (event, trx) => {
35
36
  }
36
37
  if (changed) await sql`insert into ${sql.id(schema, "versions")} (workflow_id, version, definition, saved_by, tenant_id)
37
38
  select w.id, w.version, w.definition, ${String(event.user.id)}, w.tenant_id from ${sql.id(schema, "workflows")} w where w.id = ${String(event.after.id)}`.execute(trx);
38
- await syncTrigger(trx, schema, jobs, event.after);
39
+ const id = String(event.after.id);
40
+ const enabled = event.after.enabled_version;
41
+ const enabledBy = enabled === null ? null : await consent(event.session, event.after.definition);
42
+ await saveConsent(trx, schema, id, enabled, enabledBy);
43
+ await syncTrigger(trx, schema, jobs, event.after, types.connections);
39
44
  };
40
45
  //#endregion
41
46
  export { workflowSaveHook };
@@ -1,6 +1,6 @@
1
1
  import { quoteIdent } from "../changes/capture-sql.js";
2
2
  /** The comment the schema's function carries, which the startup check reads. */
3
- const workflowsComment = `Protobase workflows 6`;
3
+ const workflowsComment = `Protobase workflows 8`;
4
4
  const commentVersion = (comment) => {
5
5
  const match = comment?.match(/^Protobase workflows (\d+)$/);
6
6
  return match ? Number(match[1]) : void 0;
@@ -83,7 +83,9 @@ const triggerStateBytes = 65536;
83
83
  /**
84
84
  * Which version of a workflow is on (`enabled_version`, null when off), and one row per trigger of an enabled version:
85
85
  * its type and input, and the state its kind keeps. A change raises `generation`, so a job queued for an older one
86
- * ends without acting. A trigger being stopped stays `stopping` until its stop job removes it. Written whole each time:
86
+ * ends without acting. A poll or renewal acts only as the job `poll_job_id` names, on the try `poll_attempt` claimed,
87
+ * so a try that ran past its timeout writes nothing once its retry claimed the trigger. A deleted workflow leaves its
88
+ * trigger without a workflow, which its delete hook stops. A trigger being stopped stays `stopping` until its stop job removes it. Written whole each time:
87
89
  * every statement is idempotent.
88
90
  */
89
91
  const triggersSql = (schema) => {
@@ -94,9 +96,11 @@ do $$ begin
94
96
  alter table ${s}."workflows" add constraint "workflows_enabled_version" foreign key ("id", "enabled_version") references ${s}."versions" ("workflow_id", "version") deferrable initially deferred;
95
97
  end if;
96
98
  end $$;
99
+ -- Who turned the version on, signed in as themselves: the consent its runs sign in through their connections with.
100
+ alter table ${s}."versions" add column if not exists "enabled_by" text;
97
101
  create table if not exists ${s}."triggers" (
98
102
  "id" uuid primary key default gen_random_uuid(),
99
- "workflow_id" uuid not null references ${s}."workflows" ("id") on delete cascade,
103
+ "workflow_id" uuid references ${s}."workflows" ("id") on delete set null,
100
104
  "version" integer not null,
101
105
  "tenant_id" text,
102
106
  "type" text not null,
@@ -107,12 +111,22 @@ create table if not exists ${s}."triggers" (
107
111
  "cursor" jsonb check (octet_length("cursor"::text) <= ${triggerStateBytes}),
108
112
  "store" jsonb check (octet_length("store"::text) <= ${triggerStateBytes}),
109
113
  "poll_job_id" text,
114
+ "poll_attempt" integer,
110
115
  "consecutive_failures" integer not null,
111
116
  "last_error" jsonb,
112
117
  "next_poll_at" timestamptz,
113
118
  "created_at" timestamptz not null default now(),
114
119
  "updated_at" timestamptz not null default now()
115
120
  );
121
+ alter table ${s}."triggers" add column if not exists "poll_attempt" integer;
122
+ -- A deleted workflow's trigger stays, without its workflow, until its stop job unsubscribed it at its provider.
123
+ alter table ${s}."triggers" alter column "workflow_id" drop not null;
124
+ do $$ begin
125
+ if exists (select 1 from pg_constraint where conname = 'triggers_workflow_id_fkey' and confdeltype = 'c' and connamespace = ${`'${schema.replaceAll("'", "''")}'`}::regnamespace) then
126
+ alter table ${s}."triggers" drop constraint "triggers_workflow_id_fkey";
127
+ alter table ${s}."triggers" add constraint "triggers_workflow_id_fkey" foreign key ("workflow_id") references ${s}."workflows" ("id") on delete set null;
128
+ end if;
129
+ end $$;
116
130
  create unique index if not exists "triggers_workflow" on ${s}."triggers" ("workflow_id") where "status" <> 'stopping';
117
131
  create index if not exists "triggers_workflow_id" on ${s}."triggers" ("workflow_id");
118
132
  create index if not exists "triggers_updated_at" on ${s}."triggers" ("updated_at");
@@ -2,6 +2,7 @@ import { quoteIdent } from "../changes/capture-sql.js";
2
2
  import { migrationCommand } from "../changes/capture-schema.js";
3
3
  import { workflowsSchema } from "./options.js";
4
4
  import { commentVersion, effectsSql, functionsSql, indexesSql, tablesSql, triggersSql, workflowsTriggers } from "./schema-sql.js";
5
+ import { hooksSql } from "./hooks/hooks-sql.js";
5
6
  import { sql } from "kysely";
6
7
  //#region src/workflows/schema.ts
7
8
  const readWorkflowsSchema = async (db, schema) => {
@@ -15,7 +16,7 @@ const readWorkflowsSchema = async (db, schema) => {
15
16
  const row = rows[0];
16
17
  return {
17
18
  tables: row.found,
18
- current: row.found && row.trigger_rows && commentVersion(row.comment) === 6 && row.triggers === workflowsTriggers.length
19
+ current: row.found && row.trigger_rows && commentVersion(row.comment) === 8 && row.triggers === workflowsTriggers.length
19
20
  };
20
21
  };
21
22
  /**
@@ -31,6 +32,7 @@ const workflowsMigration = async (db, options = {}) => {
31
32
  ...found.tables ? [] : [tablesSql(schema)],
32
33
  triggersSql(schema),
33
34
  effectsSql(schema),
35
+ hooksSql(schema),
34
36
  indexesSql(schema),
35
37
  functionsSql(schema)
36
38
  ].join(";\n\n")};`;
@@ -19,10 +19,12 @@ export type StartWorkflowOptions = {
19
19
  schema?: string;
20
20
  /** The version to run, as a trigger runs the enabled one; default the workflow's current version. */
21
21
  version?: number;
22
+ /** The run's id, when the caller kept something under it first, such as a webhook's large body; default a new one. */
23
+ id?: string;
22
24
  };
23
25
  /**
24
26
  * Starts a run of a workflow from the app's own code, such as a job or a write hook, and returns the run's id. In
25
27
  * `db`'s own transaction when it is one, so the run starts when the caller's writes commit, or not at all.
26
28
  */
27
- export declare const startWorkflow: (db: Db, { workflow, input, tenant, startedBy, key, schema, version }: StartWorkflowOptions) => Promise<string>;
29
+ export declare const startWorkflow: (db: Db, { workflow, input, tenant, startedBy, key, schema, version, id: runId }: StartWorkflowOptions) => Promise<string>;
28
30
  //#endregion
@@ -7,13 +7,13 @@ import { sql } from "kysely";
7
7
  * Starts a run of a workflow from the app's own code, such as a job or a write hook, and returns the run's id. In
8
8
  * `db`'s own transaction when it is one, so the run starts when the caller's writes commit, or not at all.
9
9
  */
10
- const startWorkflow = async (db, { workflow, input, tenant, startedBy, key, schema = "workflows", version }) => {
10
+ const startWorkflow = async (db, { workflow, input, tenant, startedBy, key, schema = "workflows", version, id: runId }) => {
11
11
  checkRunInput(input);
12
12
  const job = currentJobs().definition(segmentJobName);
13
13
  if (!job) throw new Error("Workflows are not running in this app: add `workflows: {}` to protobase.config.ts");
14
14
  const runs = sql.id(schema, "runs");
15
- const { rows } = await sql`insert into ${runs} (workflow_id, version, input, tenant_id, started_by, key)
16
- values (${workflow}, ${version ?? null}, ${input === void 0 ? null : JSON.stringify(input)}::jsonb, ${tenant === void 0 ? null : String(tenant)}, ${startedBy ?? null}, ${key ?? null})
15
+ const { rows } = await sql`insert into ${runs} (id, workflow_id, version, input, tenant_id, started_by, key)
16
+ values (${runId === void 0 ? sql`gen_random_uuid()` : sql`${runId}::uuid`}, ${workflow}, ${version ?? null}, ${input === void 0 ? null : JSON.stringify(input)}::jsonb, ${tenant === void 0 ? null : String(tenant)}, ${startedBy ?? null}, ${key ?? null})
17
17
  on conflict (workflow_id, key) do nothing returning id`.execute(db);
18
18
  if (rows.length === 0) {
19
19
  const existing = (await sql`select id, status from ${runs} where workflow_id = ${workflow} and key = ${key}`.execute(db)).rows[0];
@@ -24,5 +24,11 @@ const startWorkflow = async (db, { workflow, input, tenant, startedBy, key, sche
24
24
  await job.enqueue(db, { run: id }, { key: id });
25
25
  return id;
26
26
  };
27
+ /** Queues the first segment of runs a trigger inserted itself, in `db`'s transaction. */
28
+ const queueRuns = async (db, ids) => {
29
+ const job = currentJobs().definition(segmentJobName);
30
+ if (!job) throw new Error("Workflows are not running in this app: add `workflows: {}` to protobase.config.ts");
31
+ for (const id of ids) await job.enqueue(db, { run: id }, { key: id });
32
+ };
27
33
  //#endregion
28
- export { startWorkflow };
34
+ export { queueRuns, startWorkflow };
@@ -0,0 +1,15 @@
1
+ import { sql } from "kysely";
2
+ //#region src/workflows/triggers/delete-hook.ts
3
+ /**
4
+ * As a workflow is deleted, in the delete's transaction: its trigger, which the delete left without a workflow, stops at
5
+ * once, so no poll or call starts a run of it, and its stop job, queued in the same write, ends any subscription at its
6
+ * provider and then removes it. The delete removed its webhook URL already.
7
+ */
8
+ const workflowDeleteHook = (schema, jobs) => async (event, trx) => {
9
+ if (event.resource.name !== "workflows" || event.operation !== "delete") return;
10
+ const { rows } = await sql`update ${sql.id(schema, "triggers")} set status = 'stopping', generation = generation + 1, updated_at = now()
11
+ where workflow_id is null and status <> 'stopping' returning id`.execute(trx);
12
+ for (const { id } of rows) await jobs.stop.enqueue(trx, { trigger: id });
13
+ };
14
+ //#endregion
15
+ export { workflowDeleteHook };
@@ -0,0 +1,45 @@
1
+ import { pluginFetch } from "../plugin-fetch.js";
2
+ import { ConnectionError } from "@protobase/workflows/plugin";
3
+ //#region src/workflows/triggers/plugin-call.ts
4
+ /** The longest a plugin's error message is kept on its trigger, which the workflow's readers see. */
5
+ const messageLength = 1e3;
6
+ /**
7
+ * Runs a plugin's trigger code: any error it throws is the plugin's, which the trigger records rather than the job
8
+ * retrying it; a `ConnectionError('link_expired')` is a sign-in to renew. The message goes through `redact` and is cut
9
+ * to 1 000 characters, since the trigger's readers see it.
10
+ */
11
+ const callPlugin = (work, redact) => Promise.resolve().then(work).then((value) => ({ value }), (error) => {
12
+ const message = redact(error instanceof Error ? error.message : String(error)).slice(0, messageLength);
13
+ if (error instanceof ConnectionError && error.code === "link_expired") return { expired: message };
14
+ if (error instanceof ConnectionError && error.code === "not_connected") return {
15
+ failed: message,
16
+ unconnected: true
17
+ };
18
+ return { failed: message };
19
+ });
20
+ /**
21
+ * `redact` after blanking out `values` that only this call knew, such as a webhook's URL key and secret, as written and
22
+ * URL-encoded, so a message that quotes them keeps neither.
23
+ */
24
+ const redactAlso = (values, redact) => {
25
+ const blanked = [...new Set(values.flatMap((value) => [value, encodeURIComponent(value)]))].filter((value) => value.length > 0);
26
+ return (text) => redact(blanked.reduce((kept, value) => kept.replaceAll(value, "[redacted]"), text));
27
+ };
28
+ /**
29
+ * What one call of a trigger's hook gets: its input, its connection, the guarded `fetch`, and the job's signal, which
30
+ * aborts at its timeout; and `redact` for its outcome, which also blanks out each token its connection gave this call.
31
+ */
32
+ const triggerCall = ({ allowPrivateAddresses, connect }, trigger, signal, redact) => {
33
+ const issued = [];
34
+ return {
35
+ context: {
36
+ input: trigger.input,
37
+ connection: connect?.(trigger, (token) => void issued.push(token)),
38
+ fetch: pluginFetch({ allowPrivateAddresses }),
39
+ signal
40
+ },
41
+ redact: (text) => redactAlso(issued, redact)(text)
42
+ };
43
+ };
44
+ //#endregion
45
+ export { callPlugin, redactAlso, triggerCall };
@@ -0,0 +1,120 @@
1
+ import { defineJob } from "../../jobs/define-job.js";
2
+ import { callPlugin, triggerCall } from "./plugin-call.js";
3
+ import { pollProblem, stateProblem } from "./poll-result.js";
4
+ import { queueRuns } from "../start-workflow.js";
5
+ import { backoffPollAt, nextPollAt, pollEvery } from "./poll-schedule.js";
6
+ import { claimTrigger, saveNeedsSignIn, savePluginFailure, savePoll, saveRenewal, setNextJob } from "./trigger-claim.js";
7
+ //#region src/workflows/triggers/poll-job.ts
8
+ /** The plugin's most items a poll, as it declares them, never more than 100. */
9
+ const pollLimit = (type) => Math.min(type.limit ?? 100, 100);
10
+ /** How often a webhook trigger renews its subscription at its provider. */
11
+ const renewEvery = (type) => type.renewEvery ?? "1 day";
12
+ /**
13
+ * The jobs that poll a polling trigger and renew a webhook trigger's subscription at its provider, each on the trigger's
14
+ * claim (`claimTrigger`): only the job the trigger names runs it, so a stale or duplicate job does nothing. The plugin's
15
+ * error marks the trigger failing and backs off; an expired sign-in stops it, with every trigger on that connection,
16
+ * until its owner signs in again; any other error is thrown, and the job retries under its own id.
17
+ */
18
+ const pollJobs = (input) => {
19
+ const { schema, redact } = input;
20
+ const queueNext = async (trx, job, claim, due) => {
21
+ const id = await job.enqueue(trx, {
22
+ trigger: claim.trigger,
23
+ generation: claim.generation,
24
+ due: due.toISOString()
25
+ }, { runAt: due });
26
+ await setNextJob(trx, schema, claim.trigger, id);
27
+ };
28
+ const failed = (db, job, claim, message, nextAt) => db.transaction().execute(async (trx) => {
29
+ if (await savePluginFailure(trx, schema, claim, message, nextAt)) await queueNext(trx, job, claim, nextAt);
30
+ });
31
+ const poll = defineJob({
32
+ name: "workflows.poll",
33
+ timeout: "5 minutes",
34
+ retryDelay: "5 seconds",
35
+ run: async ({ trigger, generation, due }, { db, job, signal }) => {
36
+ const claim = {
37
+ trigger,
38
+ generation,
39
+ job: job.id,
40
+ attempt: job.attempt
41
+ };
42
+ const claimed = await claimTrigger(db, schema, claim);
43
+ const type = claimed && input.polling(claimed.type);
44
+ if (!claimed || !type) return;
45
+ const interval = await callPlugin(async () => pollEvery(type, claimed.input), redact);
46
+ if (!("value" in interval)) return failed(db, poll, claim, "expired" in interval ? interval.expired : interval.failed, backoffPollAt("1 minute", claimed.consecutive_failures + 1, /* @__PURE__ */ new Date()));
47
+ const every = interval.value;
48
+ const limit = pollLimit(type);
49
+ const call = triggerCall(input, claimed, signal, redact);
50
+ const outcome = await callPlugin(() => type.poll({
51
+ ...call.context,
52
+ cursor: claimed.cursor,
53
+ store: claimed.store,
54
+ limit,
55
+ due: new Date(due)
56
+ }), call.redact);
57
+ const now = /* @__PURE__ */ new Date();
58
+ if ("expired" in outcome) return saveNeedsSignIn(db, schema, claim, claimed, outcome.expired);
59
+ const backoff = backoffPollAt(every, claimed.consecutive_failures + 1, now);
60
+ if ("failed" in outcome) return failed(db, poll, claim, outcome.failed, backoff);
61
+ const problem = pollProblem(outcome.value, limit);
62
+ if (problem) return failed(db, poll, claim, `The poll ${problem}`, backoff);
63
+ const { items, cursor, store } = outcome.value;
64
+ const dueAt = new Date(due);
65
+ const nextAt = nextPollAt(every, dueAt.getTime() > now.getTime() - 1e3 ? dueAt : now);
66
+ await db.transaction().execute(async (trx) => {
67
+ const runs = await savePoll(trx, schema, claim, items, {
68
+ cursor,
69
+ store: store === void 0 ? claimed.store : store,
70
+ nextAt
71
+ });
72
+ if (!runs) return;
73
+ await queueRuns(trx, runs);
74
+ await queueNext(trx, poll, claim, nextAt);
75
+ });
76
+ }
77
+ });
78
+ const renew = defineJob({
79
+ name: "workflows.renew",
80
+ timeout: "5 minutes",
81
+ retryDelay: "5 seconds",
82
+ run: async ({ trigger, generation }, { db, job, signal }) => {
83
+ const claim = {
84
+ trigger,
85
+ generation,
86
+ job: job.id,
87
+ attempt: job.attempt
88
+ };
89
+ const claimed = await claimTrigger(db, schema, claim);
90
+ const type = claimed && input.webhooks(claimed.type);
91
+ if (!claimed || !type?.renew) return;
92
+ const renewal = type.renew;
93
+ const call = triggerCall(input, claimed, signal, redact);
94
+ const outcome = await callPlugin(() => renewal({
95
+ ...call.context,
96
+ state: claimed.store
97
+ }), call.redact);
98
+ const now = /* @__PURE__ */ new Date();
99
+ if ("expired" in outcome) return saveNeedsSignIn(db, schema, claim, claimed, outcome.expired);
100
+ const backoff = backoffPollAt("1 minute", claimed.consecutive_failures + 1, now);
101
+ if ("failed" in outcome) return failed(db, renew, claim, outcome.failed, backoff);
102
+ const problem = stateProblem(outcome.value);
103
+ if (problem) return failed(db, renew, claim, `The renewal ${problem}`, backoff);
104
+ const nextAt = nextPollAt(renewEvery(type), now);
105
+ await db.transaction().execute(async (trx) => {
106
+ if (await saveRenewal(trx, schema, claim, {
107
+ store: outcome.value,
108
+ nextAt
109
+ })) await queueNext(trx, renew, claim, nextAt);
110
+ });
111
+ }
112
+ });
113
+ return {
114
+ poll,
115
+ renew,
116
+ jobs: [poll, renew]
117
+ };
118
+ };
119
+ //#endregion
120
+ export { pollJobs, pollLimit, renewEvery };
@@ -0,0 +1,32 @@
1
+ import { triggerStateBytes } from "../schema-sql.js";
2
+ import { limits, nestingDepth } from "@protobase/workflows";
3
+ const jsonBytes = (value) => new TextEncoder().encode(JSON.stringify(value ?? null)).length;
4
+ /** What is wrong with one item's data as a run's input, as the run's own limits say. */
5
+ const inputProblem = (data) => {
6
+ if (nestingDepth(data, limits.inputDepth) > limits.inputDepth) return `nests deeper than ${limits.inputDepth} levels`;
7
+ if (jsonBytes(data) > limits.inputBytes) return `is over ${limits.inputBytes / 1024 / 1024} MiB as JSON`;
8
+ };
9
+ /** What is wrong with the items a plugin gave, each needing a key when `keyed`; `undefined` when they all fit. */
10
+ const itemsProblem = (items, most, keyed) => {
11
+ if (!Array.isArray(items)) return "gave no items array";
12
+ if (items.length > most) return `gave ${items.length} items, more than its limit of ${most}`;
13
+ for (const [index, item] of items.entries()) {
14
+ const { key, data } = item ?? {};
15
+ if ((keyed || key !== void 0) && (typeof key !== "string" || key.length === 0 || key.length > 200)) return `item ${index} has no key of 1 to 200 characters`;
16
+ const problem = inputProblem(data);
17
+ if (problem) return `item ${index}'s data ${problem}`;
18
+ }
19
+ };
20
+ /** What is wrong with a subscription's state a webhook plugin gave: over 64 KiB as JSON. */
21
+ const stateProblem = (state) => jsonBytes(state) > 65536 ? `gave a state over ${triggerStateBytes / 1024} KiB as JSON` : void 0;
22
+ /** What is wrong with a poll's result: its items, and its cursor and store over 64 KiB as JSON. */
23
+ const pollProblem = (result, limit) => {
24
+ if (typeof result !== "object" || result === null) return "gave no result";
25
+ const { items, cursor, store } = result;
26
+ const problem = itemsProblem(items, limit, true);
27
+ if (problem) return problem;
28
+ if (jsonBytes(cursor) > 65536) return `gave a cursor over ${triggerStateBytes / 1024} KiB as JSON`;
29
+ if (store !== void 0 && jsonBytes(store) > 65536) return `gave a store over ${triggerStateBytes / 1024} KiB as JSON`;
30
+ };
31
+ //#endregion
32
+ export { itemsProblem, pollProblem, stateProblem };
@@ -0,0 +1,32 @@
1
+ import { nextRun } from "../../jobs/schedule.js";
2
+ import { parseDuration } from "../../files/duration.js";
3
+ //#region src/workflows/triggers/poll-schedule.ts
4
+ /** The shortest time between two polls, whatever a plugin's `every` says. */
5
+ const shortestMs = 1e3;
6
+ /** The longest wait a failing trigger's backoff reaches. */
7
+ const longestBackoffMs = 36e5;
8
+ /** The base of a cron trigger's backoff, which has no interval of its own. */
9
+ const cronBackoffMs = 6e4;
10
+ /** How often a polling trigger polls for this input. */
11
+ const pollEvery = (type, input) => typeof type.every === "function" ? type.every(input) : type.every;
12
+ /** The time of the poll after one due at `after`: an interval later, or the cron's next time. */
13
+ const nextPollAt = (every, after) => {
14
+ if (typeof every === "string") return new Date(after.getTime() + Math.max(parseDuration(every, "every"), shortestMs));
15
+ return nextRun(every.cron, every.timezone ?? "UTC", after);
16
+ };
17
+ /** The first poll of a trigger that starts at `now`: at once on an interval, at the cron's next time otherwise. */
18
+ const firstPollAt = (every, now) => typeof every === "string" ? now : nextRun(every.cron, every.timezone ?? "UTC", now);
19
+ /**
20
+ * When a trigger that failed `failures` times in a row polls again: `every × 2^failures`, at most an hour and never
21
+ * sooner than its interval; a cron trigger at its first time after a minute × 2^failures, at most an hour, from now.
22
+ */
23
+ const backoffPollAt = (every, failures, now) => {
24
+ const factor = 2 ** Math.min(failures, 20);
25
+ if (typeof every === "string") {
26
+ const interval = Math.max(parseDuration(every, "every"), shortestMs);
27
+ return new Date(now.getTime() + Math.max(interval, Math.min(interval * factor, longestBackoffMs)));
28
+ }
29
+ return nextRun(every.cron, every.timezone ?? "UTC", new Date(now.getTime() + Math.min(cronBackoffMs * factor, longestBackoffMs)));
30
+ };
31
+ //#endregion
32
+ export { backoffPollAt, firstPollAt, nextPollAt, pollEvery };
@@ -0,0 +1,81 @@
1
+ import { sql } from "kysely";
2
+ //#region src/workflows/triggers/trigger-claim.ts
3
+ /**
4
+ * Claims the trigger for this try of the job its row names, in one statement: only while it runs on this generation,
5
+ * and only for a later try than the last that claimed it, so a retry of a job whose worker died takes its claim back,
6
+ * and a try that ran past its timeout no longer holds it. `undefined` when the trigger was stopped, changed or moved
7
+ * to another job.
8
+ */
9
+ const claimTrigger = async (db, schema, { trigger, generation, job, attempt }) => {
10
+ const { rows } = await sql`update ${sql.id(schema, "triggers")} set poll_attempt = ${attempt}
11
+ where id = ${trigger} and generation = ${generation} and poll_job_id = ${job} and status in ('active', 'failing')
12
+ and (poll_attempt is null or poll_attempt < ${attempt})
13
+ returning id, workflow_id, version, tenant_id, type, input, cursor, store, connection_id, consecutive_failures`.execute(db);
14
+ return rows[0];
15
+ };
16
+ const held = (schema, { trigger, generation, job, attempt }) => sql`select id, workflow_id, version, tenant_id from ${sql.id(schema, "triggers")}
17
+ where id = ${trigger} and generation = ${generation} and poll_job_id = ${job} and poll_attempt = ${attempt} and status in ('active', 'failing') for update`;
18
+ /**
19
+ * Saves a successful poll in one statement that locks the trigger row while this try holds its claim: the runs of its
20
+ * items, each once per workflow and key, and the new cursor and store, the trigger active again. The ids of the runs it
21
+ * started, or `undefined` when the claim was gone and nothing was written.
22
+ */
23
+ const savePoll = async (trx, schema, claim, items, { cursor, store, nextAt }) => {
24
+ const triggers = sql.id(schema, "triggers");
25
+ const { rows } = await sql`with t as (${held(schema, claim)}),
26
+ started as (insert into ${sql.id(schema, "runs")} (workflow_id, version, input, key, tenant_id, started_by)
27
+ select t.workflow_id, t.version, i.data, i.key, t.tenant_id, 'trigger:' || t.id from t cross join jsonb_to_recordset(${JSON.stringify(items)}::jsonb) as i(key text, data jsonb)
28
+ on conflict (workflow_id, key) do nothing returning id),
29
+ saved as (update ${triggers} set cursor = ${JSON.stringify(cursor ?? null)}::jsonb, store = ${JSON.stringify(store ?? null)}::jsonb, status = 'active',
30
+ consecutive_failures = 0, last_error = null, poll_attempt = null, next_poll_at = ${nextAt}, updated_at = now() from t where ${triggers}.id = t.id returning 1)
31
+ select (select count(*) from saved)::int as saved, coalesce((select json_agg(id) from started), '[]'::json) as runs`.execute(trx);
32
+ const row = rows[0];
33
+ return row.saved > 0 ? row.runs : void 0;
34
+ };
35
+ /** Saves a renewal's state while this try holds its claim; whether it did. */
36
+ const saveRenewal = async (trx, schema, claim, { store, nextAt }) => {
37
+ const triggers = sql.id(schema, "triggers");
38
+ const { rows } = await sql`with t as (${held(schema, claim)})
39
+ update ${triggers} set store = ${JSON.stringify(store ?? null)}::jsonb, status = 'active', consecutive_failures = 0, last_error = null, poll_attempt = null,
40
+ next_poll_at = ${nextAt}, updated_at = now() from t where ${triggers}.id = t.id returning 1`.execute(trx);
41
+ return rows.length > 0;
42
+ };
43
+ /** Marks the trigger failing with the plugin's error, while this try holds its claim; whether it did. */
44
+ const savePluginFailure = async (trx, schema, claim, message, nextAt) => {
45
+ const triggers = sql.id(schema, "triggers");
46
+ const error = JSON.stringify({
47
+ code: "plugin_error",
48
+ message,
49
+ at: (/* @__PURE__ */ new Date()).toISOString()
50
+ });
51
+ const { rows } = await sql`with t as (${held(schema, claim)})
52
+ update ${triggers} set status = 'failing', consecutive_failures = consecutive_failures + 1, last_error = ${error}::jsonb, poll_attempt = null,
53
+ next_poll_at = ${nextAt}, updated_at = now() from t where ${triggers}.id = t.id returning 1`.execute(trx);
54
+ return rows.length > 0;
55
+ };
56
+ /**
57
+ * Names the job that polls or renews the trigger next, on the row the transaction holds locked. The new job's tries
58
+ * count from one, so a claim an older job's try still holds is dropped with it.
59
+ */
60
+ const setNextJob = async (trx, schema, trigger, job) => {
61
+ await sql`update ${sql.id(schema, "triggers")} set poll_job_id = ${job}, poll_attempt = null where id = ${trigger}`.execute(trx);
62
+ };
63
+ /**
64
+ * The connection's sign-in expired: this trigger, while this try holds its claim, and every running trigger of the same
65
+ * tenant on the same connection wait for a new sign-in, with no next poll. A trigger without a connection is the only
66
+ * one it moves.
67
+ */
68
+ const saveNeedsSignIn = async (db, schema, claim, trigger, message) => {
69
+ const error = JSON.stringify({
70
+ code: "link_expired",
71
+ message,
72
+ at: (/* @__PURE__ */ new Date()).toISOString()
73
+ });
74
+ const { trigger: id, generation, job, attempt } = claim;
75
+ await sql`update ${sql.id(schema, "triggers")} set status = 'needs_sign_in', last_error = ${error}::jsonb, poll_job_id = null, poll_attempt = null, next_poll_at = null, updated_at = now()
76
+ where status in ('active', 'failing') and (
77
+ (id = ${id} and generation = ${generation} and poll_job_id = ${job} and poll_attempt = ${attempt})
78
+ or (${trigger.connection_id}::text is not null and connection_id = ${trigger.connection_id} and tenant_id is not distinct from ${trigger.tenant_id}))`.execute(db);
79
+ };
80
+ //#endregion
81
+ export { claimTrigger, saveNeedsSignIn, savePluginFailure, savePoll, saveRenewal, setNextJob };
@@ -1,7 +1,10 @@
1
1
  import { defineJob } from "../../jobs/define-job.js";
2
+ import { callPlugin, triggerCall } from "./plugin-call.js";
2
3
  import { readyAt, readyWhen, resolveTimeTerms } from "../ready/ready-when.js";
3
4
  import { activeTriggers, currentTrigger, triggerFailed } from "./trigger-row.js";
4
5
  import { orRefusal, readyContext, startIfReady } from "../ready/ready-start.js";
6
+ import { pollJobs } from "./poll-job.js";
7
+ import { triggerStarts } from "./trigger-start.js";
5
8
  import { encodeKey } from "@protobase/schema/internal";
6
9
  import { printFilter } from "@protobase/schema";
7
10
  import { sql } from "kysely";
@@ -10,12 +13,15 @@ import { sql } from "kysely";
10
13
  const passSize = 100;
11
14
  /**
12
15
  * The jobs that run triggers on every server process, so turning a workflow on, changing or turning it off needs no
13
- * restart: `workflows.trigger-start` (for a ready trigger, one pass over the records that already match, 100 per job),
14
- * `workflows.trigger-stop` (removes a stopped trigger), and `workflows.ready-check` (starts a run of one record when it
15
- * is ready, at the time its time terms say). Each acts only on the generation it was queued for.
16
+ * restart: `workflows.trigger-start` (for a ready trigger, one pass over the records that already match, 100 per job;
17
+ * for a polling one its first poll; for a subscribing webhook one its subscription), `workflows.trigger-stop`
18
+ * (unsubscribes a stopped webhook trigger at its provider and removes it), `workflows.ready-check` (starts a run of one
19
+ * record when it is ready, at the time its time terms say), and the poll and renewal jobs. Each acts only on the
20
+ * generation it was queued for.
16
21
  */
17
22
  const triggerJobs = (input) => {
18
23
  const { schema } = input;
24
+ const polls = pollJobs(input);
19
25
  const readyCheck = defineJob({
20
26
  name: "workflows.ready-check",
21
27
  timeout: "5 minutes",
@@ -42,12 +48,20 @@ const triggerJobs = (input) => {
42
48
  key: `ready:${trigger.id}:${trigger.generation}:${recordKey}:${at.toISOString()}`
43
49
  });
44
50
  };
51
+ const starts = triggerStarts(input, polls);
45
52
  const start = defineJob({
46
53
  name: "workflows.trigger-start",
47
54
  timeout: "5 minutes",
48
- run: async ({ trigger: id, generation, page_token }, { db }) => {
55
+ run: async ({ trigger: id, generation, page_token }, { db, signal }) => {
49
56
  const trigger = await currentTrigger(db, schema, id, generation);
50
- if (!trigger || !input.declared(trigger.type)) return;
57
+ if (!trigger) return;
58
+ const restart = async (later, at) => void await start.enqueue(later, {
59
+ trigger: id,
60
+ generation
61
+ }, { runAt: at });
62
+ if (input.polling(trigger.type)) return starts.startPolling(db, trigger, signal, restart);
63
+ if (input.webhooks(trigger.type)) return starts.startWebhook(db, trigger, signal, restart);
64
+ if (!input.declared(trigger.type)) return;
51
65
  const found = await orRefusal(readyContext(input, trigger));
52
66
  if ("refused" in found) return triggerFailed(db, schema, trigger, "refused", found.refused);
53
67
  if (!found.value) return;
@@ -71,9 +85,40 @@ const triggerJobs = (input) => {
71
85
  });
72
86
  }
73
87
  });
88
+ /**
89
+ * Ends a stopped webhook trigger's subscription at its provider: whether nothing is left there, so the trigger can go.
90
+ * Any error is tried again, as the job's, until the provider ends it. A sign-in the unsubscribe can no longer use, as
91
+ * once the workflow was turned off and so its connection with it, or once the provider refused it, cannot end it:
92
+ * the trigger then stays `stopping` with that error, as the record of a subscription the provider may still hold.
93
+ */
94
+ const unsubscribe = async (db, trigger, signal) => {
95
+ const { rows } = await sql`select type, input, workflow_id, version, store, store is not null as subscribed
96
+ from ${sql.id(schema, "triggers")} where id = ${trigger} and status = 'stopping'`.execute(db);
97
+ const row = rows[0];
98
+ const type = row?.subscribed ? input.webhooks(row.type) : void 0;
99
+ if (!row || !type?.unsubscribe) return true;
100
+ const end = type.unsubscribe;
101
+ const call = triggerCall(input, row, signal, input.redact);
102
+ const outcome = await callPlugin(() => end({
103
+ ...call.context,
104
+ state: row.store
105
+ }), call.redact);
106
+ if ("value" in outcome) return true;
107
+ if ("failed" in outcome && !outcome.unconnected) throw new Error(`The trigger's unsubscribe failed: ${outcome.failed}`);
108
+ const reason = "expired" in outcome ? outcome.expired : outcome.failed;
109
+ const error = JSON.stringify({
110
+ code: "expired" in outcome ? "link_expired" : "not_connected",
111
+ message: `The subscription could not be ended at the provider: ${reason}`,
112
+ at: (/* @__PURE__ */ new Date()).toISOString()
113
+ });
114
+ await sql`update ${sql.id(schema, "triggers")} set last_error = ${error}::jsonb, updated_at = now() where id = ${trigger} and status = 'stopping'`.execute(db);
115
+ return false;
116
+ };
74
117
  const stop = defineJob({
75
118
  name: "workflows.trigger-stop",
76
- run: async ({ trigger }, { db }) => {
119
+ timeout: "5 minutes",
120
+ run: async ({ trigger }, { db, signal }) => {
121
+ if (!await unsubscribe(db, trigger, signal)) return;
77
122
  await sql`delete from ${sql.id(schema, "triggers")} where id = ${trigger} and status = 'stopping'`.execute(db);
78
123
  }
79
124
  });
@@ -103,7 +148,8 @@ const triggerJobs = (input) => {
103
148
  jobs: [
104
149
  start,
105
150
  stop,
106
- readyCheck
151
+ readyCheck,
152
+ ...polls.jobs
107
153
  ],
108
154
  start,
109
155
  stop,