@protobase/server 0.67.11 → 0.68.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 (53) hide show
  1. package/dist/batch/run-batch.d.ts +1 -0
  2. package/dist/batch/run-batch.js +6 -4
  3. package/dist/create-admin.js +7 -4
  4. package/dist/idempotency.d.ts +1 -0
  5. package/dist/idempotency.js +12 -0
  6. package/dist/index.d.ts +3 -2
  7. package/dist/index.js +3 -2
  8. package/dist/mcp/mcp-tools.d.ts +4 -2
  9. package/dist/mcp/mcp-tools.js +9 -3
  10. package/dist/package.js +1 -1
  11. package/dist/webhooks/attempt.js +2 -2
  12. package/dist/webhooks/table/address-check.js +1 -1
  13. package/dist/webhooks/table/webhook-table.js +1 -1
  14. package/dist/webhooks/webhooks.js +2 -1
  15. package/dist/workflows/effects-store.js +18 -0
  16. package/dist/workflows/input-limits.js +4 -2
  17. package/dist/workflows/mcp/workflow-instructions.js +1 -1
  18. package/dist/workflows/mcp/workflow-read-tools.d.ts +11 -1
  19. package/dist/workflows/mcp/workflow-read-tools.js +16 -19
  20. package/dist/workflows/mcp/workflow-write-tools.js +12 -3
  21. package/dist/workflows/options.d.ts +6 -0
  22. package/dist/workflows/plugin-fetch.js +40 -0
  23. package/dist/workflows/plugin-records.d.ts +10 -0
  24. package/dist/workflows/plugin-records.js +120 -0
  25. package/dist/workflows/plugin-services.d.ts +13 -0
  26. package/dist/workflows/plugin-services.js +32 -0
  27. package/dist/workflows/ready/ready-check.js +62 -0
  28. package/dist/workflows/ready/ready-start.js +104 -0
  29. package/dist/workflows/ready/ready-when.js +101 -0
  30. package/dist/workflows/rerun.js +36 -0
  31. package/dist/workflows/run-files.js +3 -7
  32. package/dist/workflows/save-hook.js +15 -13
  33. package/dist/workflows/schema-sql.js +72 -4
  34. package/dist/workflows/schema.d.ts +2 -2
  35. package/dist/workflows/schema.js +6 -2
  36. package/dist/workflows/start-hook.js +9 -3
  37. package/dist/workflows/start-workflow.d.ts +3 -1
  38. package/dist/workflows/start-workflow.js +3 -3
  39. package/dist/workflows/triggers/trigger-jobs.js +114 -0
  40. package/dist/workflows/triggers/trigger-row.js +36 -0
  41. package/dist/workflows/triggers/trigger-sync.js +48 -0
  42. package/dist/workflows/workflow-engine.js +17 -5
  43. package/dist/workflows/workflow-types-route.js +22 -0
  44. package/dist/workflows/workflow-types.d.ts +22 -0
  45. package/dist/workflows/workflow-types.js +63 -0
  46. package/dist/workflows/workflows-module.d.ts +27 -1
  47. package/package.json +6 -5
  48. package/dist/workflows/http-step.js +0 -103
  49. package/dist/workflows/mail-step.js +0 -58
  50. package/dist/workflows/mcp/type-catalog.js +0 -144
  51. package/dist/workflows/piece-step.js +0 -11
  52. package/dist/workflows/records-step.js +0 -114
  53. package/dist/workflows/step-types.js +0 -27
@@ -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 3`;
3
+ const workflowsComment = `Protobase workflows 6`;
4
4
  const commentVersion = (comment) => {
5
5
  const match = comment?.match(/^Protobase workflows (\d+)$/);
6
6
  return match ? Number(match[1]) : void 0;
@@ -46,6 +46,7 @@ create table if not exists ${s}."runs" (
46
46
  "cause" text,
47
47
  "resume_at" timestamptz,
48
48
  "started_by" text,
49
+ "rerun_of" uuid references ${s}."runs" ("id") on delete set null,
49
50
  "tenant_id" text,
50
51
  "created_at" timestamptz not null default now(),
51
52
  "finished_at" timestamptz,
@@ -77,6 +78,61 @@ create table if not exists ${s}."steps" (
77
78
  unique ("run_id", "scope", "seq")
78
79
  )`;
79
80
  };
81
+ /** The most bytes a trigger's cursor or store holds as JSON, so a plugin cannot grow them without limit. */
82
+ const triggerStateBytes = 65536;
83
+ /**
84
+ * Which version of a workflow is on (`enabled_version`, null when off), and one row per trigger of an enabled version:
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:
87
+ * every statement is idempotent.
88
+ */
89
+ const triggersSql = (schema) => {
90
+ const s = quoteIdent(schema);
91
+ return `alter table ${s}."workflows" add column if not exists "enabled_version" integer;
92
+ do $$ begin
93
+ if not exists (select 1 from pg_constraint where conname = 'workflows_enabled_version' and connamespace = ${`'${schema.replaceAll("'", "''")}'`}::regnamespace) then
94
+ alter table ${s}."workflows" add constraint "workflows_enabled_version" foreign key ("id", "enabled_version") references ${s}."versions" ("workflow_id", "version") deferrable initially deferred;
95
+ end if;
96
+ end $$;
97
+ create table if not exists ${s}."triggers" (
98
+ "id" uuid primary key default gen_random_uuid(),
99
+ "workflow_id" uuid not null references ${s}."workflows" ("id") on delete cascade,
100
+ "version" integer not null,
101
+ "tenant_id" text,
102
+ "type" text not null,
103
+ "input" jsonb not null,
104
+ "connection_id" text,
105
+ "status" text not null check ("status" in ('active', 'failing', 'needs_sign_in', 'stopping')),
106
+ "generation" integer not null,
107
+ "cursor" jsonb check (octet_length("cursor"::text) <= ${triggerStateBytes}),
108
+ "store" jsonb check (octet_length("store"::text) <= ${triggerStateBytes}),
109
+ "poll_job_id" text,
110
+ "consecutive_failures" integer not null,
111
+ "last_error" jsonb,
112
+ "next_poll_at" timestamptz,
113
+ "created_at" timestamptz not null default now(),
114
+ "updated_at" timestamptz not null default now()
115
+ );
116
+ create unique index if not exists "triggers_workflow" on ${s}."triggers" ("workflow_id") where "status" <> 'stopping';
117
+ create index if not exists "triggers_workflow_id" on ${s}."triggers" ("workflow_id");
118
+ create index if not exists "triggers_updated_at" on ${s}."triggers" ("updated_at");
119
+ create index if not exists "triggers_status_type" on ${s}."triggers" ("status", "type");
120
+ create index if not exists "triggers_connection" on ${s}."triggers" ("connection_id");
121
+ create index if not exists "triggers_tenant" on ${s}."triggers" ("tenant_id")`;
122
+ };
123
+ /**
124
+ * What a step that changes records kept under its idempotency key, written with its records, so a retry of the step
125
+ * after they committed writes nothing and returns them. Its rows go with their run.
126
+ */
127
+ const effectsSql = (schema) => {
128
+ const s = quoteIdent(schema);
129
+ return `create table if not exists ${s}."effects" (
130
+ "run_id" uuid not null references ${s}."runs" ("id") on delete cascade,
131
+ "key" text not null,
132
+ "result" jsonb,
133
+ primary key ("run_id", "key")
134
+ )`;
135
+ };
80
136
  /** An index for each filter and sort the API takes, and pg_trgm ones for the search over names. */
81
137
  const indexesSql = (schema) => {
82
138
  const s = quoteIdent(schema);
@@ -88,6 +144,8 @@ create index if not exists "workflows_name_search" on ${s}."workflows" using gin
88
144
  create index if not exists "versions_tenant" on ${s}."versions" ("tenant_id");
89
145
  alter table ${s}."runs" add column if not exists "key" text;
90
146
  create unique index if not exists "runs_key" on ${s}."runs" ("workflow_id", "key");
147
+ alter table ${s}."runs" add column if not exists "rerun_of" uuid references ${s}."runs" ("id") on delete set null;
148
+ create index if not exists "runs_rerun_of" on ${s}."runs" ("rerun_of");
91
149
  create index if not exists "runs_workflow" on ${s}."runs" ("workflow_id", "created_at");
92
150
  create index if not exists "runs_tenant" on ${s}."runs" ("tenant_id");
93
151
  create index if not exists "runs_status" on ${s}."runs" ("status");
@@ -102,7 +160,7 @@ const workflowsTriggers = ["workflow_version", "run_version"];
102
160
  * A workflow numbers its next version as a save changes its definition, in the row the write returns, so its ETag is
103
161
  * the saved row's; the save hook keeps that version's definition and who saved it. A run takes its workflow's current
104
162
  * version as it is created, and refuses a workflow of another tenant as one that is not there, and one that has no
105
- * saved version.
163
+ * saved version. A re-run takes the input of the run it re-runs, which the start hook checks.
106
164
  */
107
165
  const functionsSql = (schema) => {
108
166
  const s = quoteIdent(schema);
@@ -115,6 +173,10 @@ begin
115
173
  end if;
116
174
  -- The write holds the row, so a save in another transaction numbers its version after this one.
117
175
  new.version := case when new.definition is distinct from old.definition then coalesce(old.version, 0) + 1 else old.version end;
176
+ -- An enabled workflow stays enabled on its new version; the save hook moves its trigger along.
177
+ if new.version is distinct from old.version and old.enabled_version is not null and new.enabled_version is not distinct from old.enabled_version then
178
+ new.enabled_version := new.version;
179
+ end if;
118
180
  new.updated_at := now();
119
181
  return new;
120
182
  end
@@ -135,7 +197,13 @@ begin
135
197
  if found.version is null then
136
198
  raise exception 'Workflow % has no saved version', new.workflow_id using errcode = '23503';
137
199
  end if;
138
- new.version := found.version;
200
+ -- A trigger starts a run of the version that is enabled, which it gives; any other start runs the current one.
201
+ if new.version is null then
202
+ new.version := found.version;
203
+ end if;
204
+ if new.rerun_of is not null then
205
+ select r.input into new.input from ${s}."runs" r where r.id = new.rerun_of and r.workflow_id = new.workflow_id;
206
+ end if;
139
207
  return new;
140
208
  end
141
209
  $$;
@@ -144,4 +212,4 @@ drop trigger if exists "run_version" on ${s}."runs";
144
212
  create trigger "run_version" before insert on ${s}."runs" for each row execute function ${s}."run_version"()`;
145
213
  };
146
214
  //#endregion
147
- export { commentVersion, functionsSql, indexesSql, tablesSql, workflowsComment, workflowsTriggers };
215
+ export { commentVersion, effectsSql, functionsSql, indexesSql, tablesSql, triggerStateBytes, triggersSql, workflowsComment, workflowsTriggers };
@@ -5,7 +5,7 @@ import { Db } from "@protobase/query";
5
5
  * The SQL that brings the `workflows` schema to what `workflows` needs: its tables, indexes and the triggers that number a
6
6
  * workflow's versions and pin a run's; `undefined` when current. Tables are never dropped.
7
7
  */
8
- export declare const workflowsMigration: (db: Db, options?: WorkflowsOptions) => Promise<string | undefined>;
8
+ export declare const workflowsMigration: (db: Db, options?: Pick<WorkflowsOptions, "schema">) => Promise<string | undefined>;
9
9
  /** Refuses to go on while the `workflows` schema lacks what `workflows` needs; the project's migrations add it. */
10
- export declare const checkWorkflowsSchema: (db: Db, options?: WorkflowsOptions) => Promise<void>;
10
+ export declare const checkWorkflowsSchema: (db: Db, options?: Pick<WorkflowsOptions, "schema">) => Promise<void>;
11
11
  //#endregion
@@ -1,19 +1,21 @@
1
1
  import { quoteIdent } from "../changes/capture-sql.js";
2
2
  import { migrationCommand } from "../changes/capture-schema.js";
3
3
  import { workflowsSchema } from "./options.js";
4
- import { commentVersion, functionsSql, indexesSql, tablesSql, workflowsTriggers } from "./schema-sql.js";
4
+ import { commentVersion, effectsSql, functionsSql, indexesSql, tablesSql, triggersSql, workflowsTriggers } from "./schema-sql.js";
5
5
  import { sql } from "kysely";
6
6
  //#region src/workflows/schema.ts
7
7
  const readWorkflowsSchema = async (db, schema) => {
8
8
  const steps = `${quoteIdent(schema)}."steps"`;
9
+ const triggerRows = `${quoteIdent(schema)}."triggers"`;
9
10
  const { rows } = await sql`select to_regclass(${steps}) is not null as found,
11
+ to_regclass(${triggerRows}) is not null as trigger_rows,
10
12
  (select obj_description(p.oid, 'pg_proc') from pg_proc p join pg_namespace n on n.oid = p.pronamespace where n.nspname = ${schema} and p.proname = 'run_version') as comment,
11
13
  (select count(*)::int from pg_trigger t join pg_class c on c.oid = t.tgrelid join pg_namespace n on n.oid = c.relnamespace
12
14
  where n.nspname = ${schema} and not t.tgisinternal and t.tgname = any(${workflowsTriggers})) as triggers`.execute(db);
13
15
  const row = rows[0];
14
16
  return {
15
17
  tables: row.found,
16
- current: row.found && commentVersion(row.comment) === 3 && row.triggers === workflowsTriggers.length
18
+ current: row.found && row.trigger_rows && commentVersion(row.comment) === 6 && row.triggers === workflowsTriggers.length
17
19
  };
18
20
  };
19
21
  /**
@@ -27,6 +29,8 @@ const workflowsMigration = async (db, options = {}) => {
27
29
  return `${[
28
30
  `create schema if not exists ${quoteIdent(schema)}`,
29
31
  ...found.tables ? [] : [tablesSql(schema)],
32
+ triggersSql(schema),
33
+ effectsSql(schema),
30
34
  indexesSql(schema),
31
35
  functionsSql(schema)
32
36
  ].join(";\n\n")};`;
@@ -3,13 +3,15 @@ import { requestAccess, requireOperation } from "../request-access.js";
3
3
  import { deletedRows } from "../params.js";
4
4
  import { fetchReadable } from "../fetch-readable.js";
5
5
  import { checkRunInput } from "./input-limits.js";
6
+ import { keepEarlierSteps } from "./rerun.js";
6
7
  //#region src/workflows/start-hook.ts
7
8
  /**
8
9
  * Starts a run as it is created through the API, in the write's transaction: it refuses an input over its limits and
9
- * a workflow the caller cannot read in their tenant (404, as the API answers for it), and queues the run's first
10
- * segment, which runs once the write commits. The resource records who started it, as the run's author.
10
+ * a workflow the caller cannot read in their tenant (404, as the API answers for it), takes a re-run up after the steps
11
+ * it keeps of the run it re-runs, and queues the run's first segment, which runs once the write commits. The resource
12
+ * records who started it, as the run's author.
11
13
  */
12
- const runStartHook = (runner, deps) => async (event, trx) => {
14
+ const runStartHook = (runner, deps, schema) => async (event, trx) => {
13
15
  if (event.resource.name !== "workflow_runs" || event.operation !== "create" || !event.after) return;
14
16
  checkRunInput(event.after.input);
15
17
  const entry = deps().registry.find("workflows");
@@ -20,6 +22,10 @@ const runStartHook = (runner, deps) => async (event, trx) => {
20
22
  requireOperation(access, "read");
21
23
  const key = access.full.fields[access.full.primaryKey[0]];
22
24
  if (!await fetchReadable(trx, access, [String(event.after.workflow_id)], deletedRows(access.full, false), { fields: [key] })) throw notFound(`No ${entry.name} with this key`);
25
+ if (event.after.rerun_of) await keepEarlierSteps(trx, deps(), schema, {
26
+ ...event,
27
+ after: event.after
28
+ });
23
29
  await runner.start(trx, String(event.after.id));
24
30
  };
25
31
  //#endregion
@@ -17,10 +17,12 @@ export type StartWorkflowOptions = {
17
17
  key?: string;
18
18
  /** The schema of `workflows`, when the config names another. Default `workflows`. */
19
19
  schema?: string;
20
+ /** The version to run, as a trigger runs the enabled one; default the workflow's current version. */
21
+ version?: number;
20
22
  };
21
23
  /**
22
24
  * 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
23
25
  * `db`'s own transaction when it is one, so the run starts when the caller's writes commit, or not at all.
24
26
  */
25
- export declare const startWorkflow: (db: Db, { workflow, input, tenant, startedBy, key, schema }: StartWorkflowOptions) => Promise<string>;
27
+ export declare const startWorkflow: (db: Db, { workflow, input, tenant, startedBy, key, schema, version }: StartWorkflowOptions) => Promise<string>;
26
28
  //#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" }) => {
10
+ const startWorkflow = async (db, { workflow, input, tenant, startedBy, key, schema = "workflows", version }) => {
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, input, tenant_id, started_by, key)
16
- values (${workflow}, ${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} (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})
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];
@@ -0,0 +1,114 @@
1
+ import { defineJob } from "../../jobs/define-job.js";
2
+ import { readyAt, readyWhen, resolveTimeTerms } from "../ready/ready-when.js";
3
+ import { activeTriggers, currentTrigger, triggerFailed } from "./trigger-row.js";
4
+ import { orRefusal, readyContext, startIfReady } from "../ready/ready-start.js";
5
+ import { encodeKey } from "@protobase/schema/internal";
6
+ import { printFilter } from "@protobase/schema";
7
+ import { sql } from "kysely";
8
+ //#region src/workflows/triggers/trigger-jobs.ts
9
+ /** Records one enable pass reads per job. */
10
+ const passSize = 100;
11
+ /**
12
+ * 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
+ */
17
+ const triggerJobs = (input) => {
18
+ const { schema } = input;
19
+ const readyCheck = defineJob({
20
+ name: "workflows.ready-check",
21
+ timeout: "5 minutes",
22
+ run: async ({ trigger: id, generation, key }, { db }) => {
23
+ const trigger = await currentTrigger(db, schema, id, generation);
24
+ if (!trigger) return;
25
+ if (await startIfReady(input, db, trigger, key)) await start.enqueue(db, {
26
+ trigger: id,
27
+ generation
28
+ });
29
+ }
30
+ });
31
+ const queueCheck = async (db, trigger, primaryKey, timeTerms, record) => {
32
+ const at = readyAt(timeTerms, record, /* @__PURE__ */ new Date());
33
+ if (!at) return;
34
+ const key = Object.fromEntries(primaryKey.map((name) => [name, record[name]]));
35
+ const recordKey = encodeKey(primaryKey.map((name) => record[name]));
36
+ await readyCheck.enqueue(db, {
37
+ trigger: trigger.id,
38
+ generation: trigger.generation,
39
+ key
40
+ }, {
41
+ runAt: at,
42
+ key: `ready:${trigger.id}:${trigger.generation}:${recordKey}:${at.toISOString()}`
43
+ });
44
+ };
45
+ const start = defineJob({
46
+ name: "workflows.trigger-start",
47
+ timeout: "5 minutes",
48
+ run: async ({ trigger: id, generation, page_token }, { db }) => {
49
+ const trigger = await currentTrigger(db, schema, id, generation);
50
+ if (!trigger || !input.declared(trigger.type)) return;
51
+ const found = await orRefusal(readyContext(input, trigger));
52
+ if ("refused" in found) return triggerFailed(db, schema, trigger, "refused", found.refused);
53
+ if (!found.value) return;
54
+ const { spec, model, records } = found.value;
55
+ const split = readyWhen(spec.when);
56
+ if (!split.ok) return;
57
+ const filter = split.when.filter && printFilter(split.when.filter);
58
+ const listed = await orRefusal(records.list(spec.resource, {
59
+ ...filter && { filter },
60
+ page_size: passSize,
61
+ ...page_token && { page_token }
62
+ }));
63
+ if ("refused" in listed) return triggerFailed(db, schema, trigger, "refused", listed.refused);
64
+ const page = listed.value;
65
+ const timeTerms = resolveTimeTerms(model, split.when.timeTerms);
66
+ for (const record of page.items) await queueCheck(db, trigger, model.primaryKey, timeTerms, record);
67
+ if (page.next_page_token) await start.enqueue(db, {
68
+ trigger: id,
69
+ generation,
70
+ page_token: page.next_page_token
71
+ });
72
+ }
73
+ });
74
+ const stop = defineJob({
75
+ name: "workflows.trigger-stop",
76
+ run: async ({ trigger }, { db }) => {
77
+ await sql`delete from ${sql.id(schema, "triggers")} where id = ${trigger} and status = 'stopping'`.execute(db);
78
+ }
79
+ });
80
+ /**
81
+ * Routes each dispatched change to the ready triggers on its resource and tenant: it queues the check of the record at
82
+ * the time it becomes ready, in the dispatch's transaction. No sweep runs: a record becomes ready by a write, or at a
83
+ * time a write set.
84
+ */
85
+ const onChanges = async (db, changes) => {
86
+ const types = [...input.readyTypes];
87
+ if (types.length === 0) return;
88
+ const triggers = await activeTriggers(db, schema, types);
89
+ for (const trigger of triggers) {
90
+ const spec = input.declared(trigger.type)?.ready(trigger.input);
91
+ const model = spec && input.deps().registry.find(spec.resource)?.model;
92
+ const split = spec && readyWhen(spec.when);
93
+ if (!spec || !model || !split?.ok) continue;
94
+ const timeTerms = resolveTimeTerms(model, split.when.timeTerms);
95
+ for (const change of changes) {
96
+ if (change.resource !== spec.resource || !change.after) continue;
97
+ if (String(change.tenant ?? "") !== String(trigger.tenant_id ?? "")) continue;
98
+ await queueCheck(db, trigger, model.primaryKey, timeTerms, change.after);
99
+ }
100
+ }
101
+ };
102
+ return {
103
+ jobs: [
104
+ start,
105
+ stop,
106
+ readyCheck
107
+ ],
108
+ start,
109
+ stop,
110
+ onChanges
111
+ };
112
+ };
113
+ //#endregion
114
+ export { triggerJobs };
@@ -0,0 +1,36 @@
1
+ import { sql } from "kysely";
2
+ //#region src/workflows/triggers/trigger-row.ts
3
+ /**
4
+ * The trigger with this id while it is running on this generation, with who saved its version: `undefined` once it
5
+ * was changed, stopped or removed, so a job queued for it before then does nothing.
6
+ */
7
+ const currentTrigger = async (db, schema, id, generation) => {
8
+ const { rows } = await sql`select t.id, t.workflow_id, t.version, t.tenant_id, t.type, t.input, t.generation, t.status, v.saved_by
9
+ from ${sql.id(schema, "triggers")} t join ${sql.id(schema, "versions")} v on v.workflow_id = t.workflow_id and v.version = t.version
10
+ where t.id = ${id} and t.generation = ${generation} and t.status in ('active', 'failing')`.execute(db);
11
+ return rows[0];
12
+ };
13
+ /**
14
+ * The running triggers of these types, failing ones included so a write can show they work again; each dispatch of
15
+ * changes reads them, and the `(status, type)` index serves it.
16
+ */
17
+ const activeTriggers = async (db, schema, types) => (await sql`select id, workflow_id, version, tenant_id, type, input, generation, status from ${sql.id(schema, "triggers")}
18
+ where status in ('active', 'failing') and type = any(${types})`.execute(db)).rows;
19
+ /** Marks a trigger failing with an error its owner sees; the message names no secret. */
20
+ const triggerFailed = async (db, schema, { id, generation, version }, code, message) => {
21
+ await sql`update ${sql.id(schema, "triggers")} set status = 'failing', consecutive_failures = consecutive_failures + 1,
22
+ last_error = ${JSON.stringify({
23
+ code,
24
+ message,
25
+ at: (/* @__PURE__ */ new Date()).toISOString()
26
+ })}::jsonb, updated_at = now()
27
+ where id = ${id} and generation = ${generation} and version = ${version}`.execute(db);
28
+ };
29
+ /** Marks a failing trigger active again once it read a record; whether this call was the one that did. */
30
+ const triggerRecovered = async (db, schema, { id, generation, version }) => {
31
+ const { rows } = await sql`update ${sql.id(schema, "triggers")} set status = 'active', consecutive_failures = 0, last_error = null, updated_at = now()
32
+ where id = ${id} and generation = ${generation} and version = ${version} and status = 'failing' returning id`.execute(db);
33
+ return rows.length > 0;
34
+ };
35
+ //#endregion
36
+ export { activeTriggers, currentTrigger, triggerFailed, triggerRecovered };
@@ -0,0 +1,48 @@
1
+ import { badRequest } from "../../problem.js";
2
+ import { sql } from "kysely";
3
+ //#region src/workflows/triggers/trigger-sync.ts
4
+ const triggerOf = (definition) => {
5
+ const [type, input] = Object.entries(definition.trigger)[0];
6
+ return type === "manual" ? void 0 : {
7
+ type,
8
+ input
9
+ };
10
+ };
11
+ /**
12
+ * Keeps a workflow's trigger row in step with its enabled version, in the write's transaction, queueing its jobs in that
13
+ * same write: turning it on inserts a running trigger and queues its start; a new version with the same type and input
14
+ * keeps the row, so its state carries over, and passes over the records again; any other change, or turning it off, stops the old row and starts a new one.
15
+ * Only the current version can be turned on.
16
+ */
17
+ const syncTrigger = async (trx, schema, jobs, workflow) => {
18
+ const enabled = workflow.enabled_version;
19
+ if (enabled !== null && enabled !== workflow.version) throw badRequest("invalid-workflow", `Only the current version can be enabled: set enabled_version to ${String(workflow.version)}, or clear it to turn the workflow off`);
20
+ const wanted = enabled === null ? void 0 : triggerOf(workflow.definition);
21
+ const triggers = sql.id(schema, "triggers");
22
+ const id = String(workflow.id);
23
+ const { rows } = await sql`select id, generation, version, (type = ${wanted?.type ?? null} and input = ${JSON.stringify(wanted?.input ?? null)}::jsonb and connection_id is null) as same
24
+ from ${triggers} where workflow_id = ${id} and status <> 'stopping'`.execute(trx);
25
+ const existing = rows[0];
26
+ if (existing?.same) {
27
+ if (existing.version === enabled) return;
28
+ await sql`update ${triggers} set version = ${enabled}, status = 'active', consecutive_failures = 0, last_error = null, updated_at = now() where id = ${existing.id}`.execute(trx);
29
+ await jobs.start.enqueue(trx, {
30
+ trigger: existing.id,
31
+ generation: existing.generation
32
+ });
33
+ return;
34
+ }
35
+ if (existing) {
36
+ await sql`update ${triggers} set status = 'stopping', generation = generation + 1, updated_at = now() where id = ${existing.id}`.execute(trx);
37
+ await jobs.stop.enqueue(trx, { trigger: existing.id });
38
+ }
39
+ if (!wanted) return;
40
+ const trigger = (await sql`insert into ${triggers} (workflow_id, version, tenant_id, type, input, status, generation, consecutive_failures)
41
+ values (${id}, ${enabled}, ${workflow.tenant_id ?? null}, ${wanted.type}, ${JSON.stringify(wanted.input)}::jsonb, 'active', 1, 0) returning id`.execute(trx)).rows[0].id;
42
+ await jobs.start.enqueue(trx, {
43
+ trigger,
44
+ generation: 1
45
+ }, { key: `trigger:${trigger}` });
46
+ };
47
+ //#endregion
48
+ export { syncTrigger };
@@ -1,14 +1,16 @@
1
- import { stepTypeNames, stepTypes } from "./step-types.js";
2
1
  import { betterAuthOwnerRoles } from "../webhooks/table/owner-roles.js";
3
2
  import { runInputCheck } from "./input-limits.js";
4
3
  import { workflowSecrets, workflowsSchema } from "./options.js";
5
4
  import { workflowSaveHook } from "./save-hook.js";
6
5
  import { workflowsRunner } from "./segment-job.js";
7
6
  import { runStartHook } from "./start-hook.js";
7
+ import { triggerJobs } from "./triggers/trigger-jobs.js";
8
+ import { workflowTypes } from "./workflow-types.js";
8
9
  //#region src/workflows/workflow-engine.ts
9
10
  /**
10
11
  * What `createAdmin` needs for `workflows`: the hooks that check saves and start runs, the check of a run's input before
11
- * it is validated, and the segment job, whose step
12
+ * it is validated, the app's trigger and step types, the segment and trigger jobs, the routing of changes to
13
+ * ready triggers, and the segment job, whose step
12
14
  * types act on records as each workflow's last editor (from Better Auth unless the options say otherwise).
13
15
  */
14
16
  const workflowEngine = (options, deps, auth) => {
@@ -22,15 +24,25 @@ const workflowEngine = (options, deps, auth) => {
22
24
  allowPrivateAddresses: Boolean(options.allowPrivateAddresses)
23
25
  };
24
26
  const secrets = workflowSecrets(options, globalThis.process?.env ?? {});
27
+ const types = workflowTypes(options.plugins, (name) => deps().registry.find(name)?.model);
25
28
  const { runner, job } = workflowsRunner({
26
29
  schema,
27
- tasks: (context) => stepTypes(input, context.mail),
30
+ tasks: (context) => types.tasks(input, context.mail),
28
31
  secrets
29
32
  });
33
+ const triggers = triggerJobs({
34
+ deps,
35
+ schema,
36
+ roles,
37
+ declared: (type) => types.ready.get(type),
38
+ readyTypes: [...types.ready.keys()].filter((id) => types.check.triggers[id] !== void 0)
39
+ });
30
40
  return {
31
- hooks: [workflowSaveHook(schema, stepTypeNames), runStartHook(runner, deps)],
41
+ hooks: [workflowSaveHook(schema, types.check, triggers), runStartHook(runner, deps, schema)],
32
42
  writeChecks: [runInputCheck],
33
- job
43
+ jobs: [job, ...triggers.jobs],
44
+ onChanges: triggers.onChanges,
45
+ types
34
46
  };
35
47
  };
36
48
  //#endregion
@@ -0,0 +1,22 @@
1
+ import { requestAccess, requireOperation } from "../request-access.js";
2
+ import { Hono } from "hono";
3
+ import { typeCatalog } from "@protobase/workflows";
4
+ //#region src/workflows/workflow-types-route.ts
5
+ /**
6
+ * The app's trigger and step types, `GET /` at `workflowTypesPath`, as `list_workflow_types` lists them: the editor
7
+ * draws its menus, forms and check from them. Only for a caller who may read the app's workflows, refused as reading a
8
+ * workflow is.
9
+ */
10
+ const workflowTypesRoute = (deps, types) => {
11
+ const catalog = typeCatalog(types);
12
+ const app = new Hono();
13
+ app.get("/", async (c) => {
14
+ const entry = deps.registry.find("workflows");
15
+ if (!entry) throw new Error("The workflow types are served only in an app with workflows");
16
+ requireOperation(await requestAccess(deps, entry, c.get("session")), "read");
17
+ return c.json(catalog);
18
+ });
19
+ return app;
20
+ };
21
+ //#endregion
22
+ export { workflowTypesRoute };
@@ -0,0 +1,22 @@
1
+ import { Mailer } from "../mail/mail-message.js";
2
+ import { ServicesInput } from "./plugin-services.js";
3
+ import { ResourceModel } from "@protobase/schema";
4
+ import { CheckOptions, LoadedPlugin, TaskHandlers } from "@protobase/workflows";
5
+ import { Plugin, ReadyTrigger } from "@protobase/workflows/plugin";
6
+ //#region src/workflows/workflow-types.d.ts
7
+ type Models = (name: string) => ResourceModel | undefined;
8
+ /**
9
+ * The trigger and step types of an app: the built-in plugin, `app`, then the app's own, all through the same loader and
10
+ * interface. `types` is the list the MCP tools and the editor read; `check` is what a save checks a definition against,
11
+ * the available types only, and `checkWith` the same check against other resources, such as those a caller may read.
12
+ */
13
+ export declare const workflowTypes: (loaded?: readonly LoadedPlugin[], models?: Models) => {
14
+ plugins: Plugin[];
15
+ types: import("@protobase/workflows").TypeEntry[];
16
+ check: CheckOptions;
17
+ checkWith: (against: Models) => CheckOptions;
18
+ tasks: (input: ServicesInput, mail: Mailer | undefined) => TaskHandlers;
19
+ ready: Map<string, ReadyTrigger>;
20
+ };
21
+ export type WorkflowTypes = ReturnType<typeof workflowTypes>;
22
+ //#endregion
@@ -0,0 +1,63 @@
1
+ import { pluginServices } from "./plugin-services.js";
2
+ import { readyProblems } from "./ready/ready-check.js";
3
+ import { loadPlugins, stepHandlers, triggerChecks, typeList } from "@protobase/workflows";
4
+ import { ConnectionError } from "@protobase/workflows/plugin";
5
+ import { appPlugin } from "@protobase/workflow-plugins";
6
+ //#region src/workflows/workflow-types.ts
7
+ const unavailable = (type) => {
8
+ if (type.connection) return "It signs in through a connection, which this app cannot make yet";
9
+ if (type.kind === "polling" || type.kind === "webhook") return `This app does not run ${type.kind} triggers yet`;
10
+ };
11
+ const noConnection = (stepId, name) => ({ accessToken: () => Promise.reject(new ConnectionError("not_connected", `${stepId} signs in through "${name}", and this workflow has no sign-in for it`)) });
12
+ const requiredInputs = (input) => Array.isArray(input.required) ? input.required : [];
13
+ /** The ready triggers of `plugins`, by id. */
14
+ const readyTriggers = (plugins) => new Map(plugins.flatMap((plugin) => Object.entries(plugin.triggers ?? {}).flatMap(([key, type]) => type.kind === "ready" ? [[`${plugin.name}:${key}`, type]] : [])));
15
+ /**
16
+ * The trigger and step types of an app: the built-in plugin, `app`, then the app's own, all through the same loader and
17
+ * interface. `types` is the list the MCP tools and the editor read; `check` is what a save checks a definition against,
18
+ * the available types only, and `checkWith` the same check against other resources, such as those a caller may read.
19
+ */
20
+ const workflowTypes = (loaded = [], models = () => void 0) => {
21
+ const plugins = loadPlugins([{
22
+ plugin: appPlugin,
23
+ source: "@protobase/workflow-plugins"
24
+ }, ...loaded]);
25
+ const types = typeList(plugins, unavailable);
26
+ const available = new Set(types.filter((type) => type.kind !== "step" && type.available).map((type) => type.id));
27
+ const ready = readyTriggers(plugins);
28
+ const steps = types.filter((type) => type.kind === "step" && type.available);
29
+ const reasons = Object.fromEntries(types.flatMap((type) => type.reason === void 0 ? [] : [[type.id, type.reason]]));
30
+ const own = triggerChecks(plugins);
31
+ const checkWith = (against) => {
32
+ const checks = Object.entries(own).map(([id, check]) => {
33
+ const declared = ready.get(id);
34
+ if (!declared) return [id, check];
35
+ return [id, (input) => {
36
+ const problems = check(input);
37
+ return problems.length > 0 ? problems : readyProblems(against, declared.ready(input));
38
+ }];
39
+ });
40
+ return {
41
+ resources: steps.map((type) => type.id),
42
+ required: Object.fromEntries(steps.map((type) => [type.id, requiredInputs(type.input)])),
43
+ triggers: Object.fromEntries(checks.filter(([id]) => available.has(id))),
44
+ unavailable: reasons
45
+ };
46
+ };
47
+ const check = checkWith(models);
48
+ /** The step types of one segment, which sends mail with the mailer of the job that runs it. */
49
+ const tasks = (input, mail) => stepHandlers(plugins, {
50
+ services: pluginServices(input, mail),
51
+ connection: noConnection
52
+ });
53
+ return {
54
+ plugins,
55
+ types,
56
+ check,
57
+ checkWith,
58
+ tasks,
59
+ ready
60
+ };
61
+ };
62
+ //#endregion
63
+ export { readyTriggers, workflowTypes };