@protobase/server 0.67.12 → 0.68.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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/check-views.js +6 -0
  4. package/dist/create-admin.js +7 -4
  5. package/dist/idempotency.d.ts +1 -0
  6. package/dist/idempotency.js +12 -0
  7. package/dist/index.d.ts +3 -2
  8. package/dist/index.js +3 -2
  9. package/dist/mcp/mcp-tools.d.ts +4 -2
  10. package/dist/mcp/mcp-tools.js +9 -3
  11. package/dist/package.js +1 -1
  12. package/dist/user-tables/admin-user-tables.js +9 -4
  13. package/dist/user-tables/definitions-views.js +93 -0
  14. package/dist/view-access.js +1 -0
  15. package/dist/webhooks/attempt.js +2 -2
  16. package/dist/webhooks/table/address-check.js +1 -1
  17. package/dist/webhooks/table/webhook-table.js +1 -1
  18. package/dist/webhooks/webhooks.js +2 -1
  19. package/dist/workflows/effects-store.js +18 -0
  20. package/dist/workflows/mcp/workflow-instructions.js +1 -1
  21. package/dist/workflows/mcp/workflow-read-tools.d.ts +11 -1
  22. package/dist/workflows/mcp/workflow-read-tools.js +14 -19
  23. package/dist/workflows/options.d.ts +6 -0
  24. package/dist/workflows/plugin-fetch.js +40 -0
  25. package/dist/workflows/plugin-records.d.ts +10 -0
  26. package/dist/workflows/plugin-records.js +120 -0
  27. package/dist/workflows/plugin-services.d.ts +13 -0
  28. package/dist/workflows/plugin-services.js +32 -0
  29. package/dist/workflows/ready/ready-check.js +62 -0
  30. package/dist/workflows/ready/ready-start.js +104 -0
  31. package/dist/workflows/ready/ready-when.js +101 -0
  32. package/dist/workflows/run-files.js +3 -7
  33. package/dist/workflows/save-hook.js +15 -13
  34. package/dist/workflows/schema-sql.js +65 -3
  35. package/dist/workflows/schema.d.ts +2 -2
  36. package/dist/workflows/schema.js +6 -2
  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 +25 -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 4`;
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;
@@ -78,6 +78,61 @@ create table if not exists ${s}."steps" (
78
78
  unique ("run_id", "scope", "seq")
79
79
  )`;
80
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
+ };
81
136
  /** An index for each filter and sort the API takes, and pg_trgm ones for the search over names. */
82
137
  const indexesSql = (schema) => {
83
138
  const s = quoteIdent(schema);
@@ -118,6 +173,10 @@ begin
118
173
  end if;
119
174
  -- The write holds the row, so a save in another transaction numbers its version after this one.
120
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;
121
180
  new.updated_at := now();
122
181
  return new;
123
182
  end
@@ -138,7 +197,10 @@ begin
138
197
  if found.version is null then
139
198
  raise exception 'Workflow % has no saved version', new.workflow_id using errcode = '23503';
140
199
  end if;
141
- 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;
142
204
  if new.rerun_of is not null then
143
205
  select r.input into new.input from ${s}."runs" r where r.id = new.rerun_of and r.workflow_id = new.workflow_id;
144
206
  end if;
@@ -150,4 +212,4 @@ drop trigger if exists "run_version" on ${s}."runs";
150
212
  create trigger "run_version" before insert on ${s}."runs" for each row execute function ${s}."run_version"()`;
151
213
  };
152
214
  //#endregion
153
- 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) === 4 && 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")};`;
@@ -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, schema)],
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 };
@@ -5,13 +5,14 @@ import { WorkflowsOptions } from "./options.js";
5
5
  * over a `workflows` schema that `protobase migration` writes. Who may make, change and run them and read their runs
6
6
  * is the resources' access (`workflows.create`, `workflow_runs.create`, `workflow_steps.read`, ...).
7
7
  */
8
- export declare const workflowsModule: (options?: WorkflowsOptions) => {
8
+ export declare const workflowsModule: (options?: Pick<WorkflowsOptions, "schema">) => {
9
9
  workflows: import("@protobase/schema").ResourceBuilder<{
10
10
  readonly id: import("@protobase/schema").Field<string>;
11
11
  readonly name: import("@protobase/schema").Field<string>;
12
12
  readonly description: import("@protobase/schema").Field<string | null>;
13
13
  readonly definition: import("@protobase/schema").Field<import("zod").JSONType>;
14
14
  readonly version: import("@protobase/schema").Field<number | null>;
15
+ readonly enabled_version: import("@protobase/schema").Field<number | null>;
15
16
  readonly tenant_id: import("@protobase/schema").Field<string | null>;
16
17
  readonly created_at: import("@protobase/schema").Field<string>;
17
18
  readonly updated_at: import("@protobase/schema").Field<string>;
@@ -22,6 +23,7 @@ export declare const workflowsModule: (options?: WorkflowsOptions) => {
22
23
  readonly description: import("@protobase/schema").Field<string | null>;
23
24
  readonly definition: import("@protobase/schema").Field<import("zod").JSONType>;
24
25
  readonly version: import("@protobase/schema").Field<number | null>;
26
+ readonly enabled_version: import("@protobase/schema").Field<number | null>;
25
27
  readonly tenant_id: import("@protobase/schema").Field<string | null>;
26
28
  readonly created_at: import("@protobase/schema").Field<string>;
27
29
  readonly updated_at: import("@protobase/schema").Field<string>;
@@ -122,5 +124,27 @@ export declare const workflowsModule: (options?: WorkflowsOptions) => {
122
124
  readonly finished_at: import("@protobase/schema").Field<string>;
123
125
  readonly duration_ms: import("@protobase/schema").Field<string | null>;
124
126
  }, import("@protobase/schema").Ref<"id", string>>>;
127
+ workflowTriggers: import("@protobase/schema").ResourceBuilder<{
128
+ readonly id: import("@protobase/schema").Field<string>;
129
+ readonly workflow_id: import("@protobase/schema").Field<string | number>;
130
+ readonly version: import("@protobase/schema").Field<number>;
131
+ readonly type: import("@protobase/schema").Field<string>;
132
+ readonly status: import("@protobase/schema").Field<"active" | "failing" | "needs_sign_in" | "stopping">;
133
+ readonly last_error: import("@protobase/schema").Field<import("zod").JSONType>;
134
+ readonly next_poll_at: import("@protobase/schema").Field<string | null>;
135
+ readonly tenant_id: import("@protobase/schema").Field<string | null>;
136
+ readonly updated_at: import("@protobase/schema").Field<string>;
137
+ }, import("@protobase/schema").Ref<"id", string>>;
138
+ workflowTriggersView: import("@protobase/schema").ViewBuilder<import("@protobase/schema").ResourceBuilder<{
139
+ readonly id: import("@protobase/schema").Field<string>;
140
+ readonly workflow_id: import("@protobase/schema").Field<string | number>;
141
+ readonly version: import("@protobase/schema").Field<number>;
142
+ readonly type: import("@protobase/schema").Field<string>;
143
+ readonly status: import("@protobase/schema").Field<"active" | "failing" | "needs_sign_in" | "stopping">;
144
+ readonly last_error: import("@protobase/schema").Field<import("zod").JSONType>;
145
+ readonly next_poll_at: import("@protobase/schema").Field<string | null>;
146
+ readonly tenant_id: import("@protobase/schema").Field<string | null>;
147
+ readonly updated_at: import("@protobase/schema").Field<string>;
148
+ }, import("@protobase/schema").Ref<"id", string>>>;
125
149
  };
126
150
  //#endregion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@protobase/server",
3
- "version": "0.67.12",
3
+ "version": "0.68.1",
4
4
  "description": "The Protobase REST API as a Hono app, with Better Auth sign-in.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -44,10 +44,11 @@
44
44
  "@better-auth/sso": "^1.7.12",
45
45
  "@livekit/protocol": "1.53.1",
46
46
  "@modelcontextprotocol/server": "^2.3.1",
47
- "@protobase/layout": "0.67.12",
48
- "@protobase/query": "0.67.12",
49
- "@protobase/schema": "0.67.12",
50
- "@protobase/workflows": "0.67.12",
47
+ "@protobase/layout": "0.68.1",
48
+ "@protobase/query": "0.68.1",
49
+ "@protobase/schema": "0.68.1",
50
+ "@protobase/workflow-plugins": "0.68.1",
51
+ "@protobase/workflows": "0.68.1",
51
52
  "@react-email/render": "^2.1.0",
52
53
  "@react-pdf/primitives": "^4.4.0",
53
54
  "@react-pdf/renderer": "^4.9.0",