@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,4 +1,5 @@
1
1
  import { TenantValue } from "../types.js";
2
+ import { LoadedPlugin } from "@protobase/workflows";
2
3
  //#region src/workflows/options.d.ts
3
4
  /** Workflows made in the admin app (`workflows: {}`), kept with their runs in a schema of the app's database. */
4
5
  export type WorkflowsOptions = {
@@ -23,5 +24,10 @@ export type WorkflowsOptions = {
23
24
  * false: a URL whose host resolves to one fails the step, so a workflow cannot reach the app's own network.
24
25
  */
25
26
  allowPrivateAddresses?: boolean;
27
+ /**
28
+ * The app's workflow plugins, run beside the built-in types (`app:*`) through the same interface; the CLI loads them
29
+ * from `workflows.plugins` and `workflows/plugins/<name>.ts`. Two of one name stop startup, naming both sources.
30
+ */
31
+ plugins?: LoadedPlugin[];
26
32
  };
27
33
  //#endregion
@@ -0,0 +1,40 @@
1
+ import { AddressRefusedError as AddressRefusedError$1, pinnedRequest } from "../webhooks/table/pinned-request.js";
2
+ import { readLimited } from "../webhooks/table/read-limited.js";
3
+ import { AddressRefusedError, ResponseTooLargeError, fetchMaxBytes } from "@protobase/workflows/plugin";
4
+ //#region src/workflows/plugin-fetch.ts
5
+ const nullBodyStatuses = /* @__PURE__ */ new Set([
6
+ 204,
7
+ 205,
8
+ 304
9
+ ]);
10
+ /**
11
+ * The plugin context's `fetch`: the pinned request, which resolves the host once, refuses a non-public address and
12
+ * connects to the address it checked, following no redirect; then at most `maxBytes` of the body as it came off the
13
+ * wire, since the client decodes no `Content-Encoding`. Past that it throws `ResponseTooLargeError`.
14
+ */
15
+ const pluginFetch = ({ allowPrivateAddresses = false, resolve, ca } = {}) => async (url, init = {}) => {
16
+ if (init.maxBytes !== void 0 && !(Number.isFinite(init.maxBytes) && init.maxBytes >= 0)) throw new RangeError(`maxBytes is ${init.maxBytes}; give a number of bytes, 0 or more`);
17
+ const maxBytes = Math.min(init.maxBytes ?? fetchMaxBytes, fetchMaxBytes);
18
+ const { method = "GET", headers, body, signal } = init;
19
+ const answer = await pinnedRequest(url, {
20
+ method,
21
+ ...headers && { headers },
22
+ ...body !== void 0 && { body },
23
+ ...signal && { signal }
24
+ }, {
25
+ allowPrivateAddresses,
26
+ ...resolve && { resolve },
27
+ ...ca && { ca }
28
+ }).catch((error) => {
29
+ if (error instanceof AddressRefusedError$1) throw new AddressRefusedError(error.message);
30
+ throw error;
31
+ });
32
+ const bytes = await readLimited(answer, maxBytes + 1);
33
+ if (bytes.length > maxBytes) throw new ResponseTooLargeError(maxBytes);
34
+ return new Response(nullBodyStatuses.has(answer.status) ? null : bytes, {
35
+ status: answer.status,
36
+ headers: answer.headers
37
+ });
38
+ };
39
+ //#endregion
40
+ export { pluginFetch };
@@ -0,0 +1,10 @@
1
+ import { TenantValue } from "../types.js";
2
+ import "../deps.js";
3
+ import "@protobase/workflows/plugin";
4
+ //#region src/workflows/plugin-records.d.ts
5
+ /** The roles a user acts with now in a tenant: none for one who is banned, gone or holds no role. */
6
+ export type SaverRoles = (user: {
7
+ id: string;
8
+ tenant?: TenantValue;
9
+ }) => Promise<string[]>;
10
+ //#endregion
@@ -0,0 +1,120 @@
1
+ import { HttpProblem } from "../problem.js";
2
+ import { tenantScope } from "../tenant.js";
3
+ import { issueTicket } from "../files/ticket.js";
4
+ import { runBatch } from "../batch/run-batch.js";
5
+ import { assistantRecords, toolEntry } from "../assistant/records.js";
6
+ import { effectsStore } from "./effects-store.js";
7
+ import { runFileRef } from "./run-files.js";
8
+ import { sql } from "kysely";
9
+ import { StepError } from "@protobase/workflows";
10
+ //#region src/workflows/plugin-records.ts
11
+ const ticketSeconds = 60;
12
+ /**
13
+ * The session of the user who saved a version, with the roles they hold now in its tenant, so a workflow never does
14
+ * more than its last editor may do today.
15
+ */
16
+ const saverSession = async (roles, savedBy, tenant) => {
17
+ if (!savedBy) throw new StepError("Records.Refused", "The run's version was not saved by a user, so its steps act on no records");
18
+ const held = await roles({
19
+ id: savedBy,
20
+ ...tenant !== void 0 && { tenant }
21
+ });
22
+ if (held.length === 0) throw new StepError("Records.Refused", "The workflow's last editor holds no role here any more, so its steps act on no records");
23
+ return {
24
+ user: {
25
+ id: savedBy,
26
+ roles: held
27
+ },
28
+ ...tenant !== void 0 && {
29
+ tenant,
30
+ organization: { id: tenant }
31
+ }
32
+ };
33
+ };
34
+ /** The session a run's records steps act in: that of the user who saved the version the run pinned, in the run's tenant. */
35
+ const runSession = async ({ deps, schema, roles }, runId) => {
36
+ const { rows } = await sql`select v.saved_by, r.tenant_id from ${sql.id(schema, "runs")} r
37
+ join ${sql.id(schema, "versions")} v on v.workflow_id = r.workflow_id and v.version = r.version where r.id = ${runId}`.execute(deps().db);
38
+ return saverSession(roles, rows[0]?.saved_by ?? null, rows[0]?.tenant_id ?? void 0);
39
+ };
40
+ const withTickets = async (deps, session, resource, data, runId) => {
41
+ const entry = toolEntry(deps, resource);
42
+ const result = { ...data };
43
+ for (const [name, value] of Object.entries(data)) {
44
+ const field = entry.model.fields[name];
45
+ if (field?.type === "decimal" && typeof value === "number") result[name] = String(value);
46
+ if (!field?.file || value === null || typeof value !== "object") continue;
47
+ if (!deps.files) throw new StepError("States.TaskFailed", "The app keeps no files");
48
+ const ref = runFileRef(runId, value, `data.${name}`);
49
+ const scope = {
50
+ tenant: String(tenantScope(entry, session)?.tenantValue ?? ""),
51
+ resource: entry.name,
52
+ field: name,
53
+ user: String(session.user.id)
54
+ };
55
+ result[name] = await issueTicket(deps.files.signer, {
56
+ provider: ref.provider,
57
+ path: ref.path,
58
+ name: ref.name,
59
+ ...ref.size !== void 0 && { size: ref.size }
60
+ }, {}, scope, Math.floor(Date.now() / 1e3) + ticketSeconds);
61
+ }
62
+ return result;
63
+ };
64
+ const refusedAs = (error) => {
65
+ if (error instanceof HttpProblem) throw new StepError("Records.Refused", `${error.status} ${error.title}: ${error.detail}`);
66
+ throw error;
67
+ };
68
+ /**
69
+ * The plugin context's `records` for one try of a step: the app's API as the user who saved the version the run runs, so
70
+ * its access rules, tenant scope, validation and write hooks apply as they do to that user's own requests. A file value
71
+ * of the run's own in a file field is written as the upload ticket the write takes. Each create keeps the step's
72
+ * idempotency key and its place among the step's creates with its records, so a retry of the step after they committed,
73
+ * such as after a crash before the step was saved, creates none again and returns them.
74
+ */
75
+ const pluginRecords = (input, runId, idempotencyKey) => {
76
+ const open = async () => {
77
+ const deps = input.deps();
78
+ const session = await runSession(input, runId);
79
+ return {
80
+ deps,
81
+ session,
82
+ records: assistantRecords(deps, session)
83
+ };
84
+ };
85
+ const guarded = (work) => work().catch(refusedAs);
86
+ let creates = 0;
87
+ const createAll = async (resource, data) => {
88
+ const key = `${idempotencyKey}:${creates++}`;
89
+ const deps = input.deps();
90
+ const session = await runSession(input, runId);
91
+ const operations = [];
92
+ for (const item of data) operations.push({
93
+ op: "create",
94
+ resource,
95
+ data: await withTickets(deps, session, resource, item, runId)
96
+ });
97
+ return (await runBatch(deps, session, operations, {
98
+ key,
99
+ store: effectsStore(input.schema, runId)
100
+ })).flatMap((result) => result.op === "create" ? [result.record] : []);
101
+ };
102
+ return {
103
+ create: (resource, data) => guarded(async () => (await createAll(resource, [data]))[0]),
104
+ createMany: (resource, data) => guarded(() => createAll(resource, data)),
105
+ update: (resource, key, data) => guarded(async () => {
106
+ const { deps, session, records } = await open();
107
+ return (await records.update(resource, key, await withTickets(deps, session, resource, data, runId))).record;
108
+ }),
109
+ get: (resource, key) => guarded(async () => (await (await open()).records.get(resource, key)).record),
110
+ list: (resource, query) => guarded(async () => {
111
+ return (await (await open()).records.list(resource, {
112
+ ...query.filter !== void 0 && { filter: query.filter },
113
+ ...query.order_by !== void 0 && { order_by: query.order_by },
114
+ ...query.limit !== void 0 && { page_size: query.limit }
115
+ })).items.map(({ etag: _, permissions: __, ...record }) => record);
116
+ })
117
+ };
118
+ };
119
+ //#endregion
120
+ export { pluginRecords, saverSession };
@@ -0,0 +1,13 @@
1
+ import "../files/runtime.js";
2
+ import { Deps } from "../deps.js";
3
+ import { SaverRoles } from "./plugin-records.js";
4
+ import "@protobase/workflows";
5
+ import "@protobase/workflows/plugin";
6
+ //#region src/workflows/plugin-services.d.ts
7
+ export type ServicesInput = {
8
+ deps: () => Deps;
9
+ schema: string;
10
+ roles: SaverRoles;
11
+ allowPrivateAddresses: boolean;
12
+ };
13
+ //#endregion
@@ -0,0 +1,32 @@
1
+ import { readRunFile, storeRunFile } from "./run-files.js";
2
+ import { pluginRecords } from "./plugin-records.js";
3
+ import { pluginFetch } from "./plugin-fetch.js";
4
+ //#region src/workflows/plugin-services.ts
5
+ /** The plugin context's `files`: those of the run alone, in the app's `private` file provider. */
6
+ const runFiles = (files, runId) => ({
7
+ keep: (bytes, name, type) => storeRunFile(files(), runId, bytes, name, type),
8
+ read: async (value, field) => {
9
+ const { ref, bytes } = await readRunFile(files(), runId, value, field);
10
+ return {
11
+ name: ref.name,
12
+ ...ref.type && { type: ref.type },
13
+ bytes
14
+ };
15
+ }
16
+ });
17
+ /** What the app gives a plugin's step in a run: the guarded fetch, records as the saver, the run's files and the mailer. */
18
+ const pluginServices = ({ deps, schema, roles, allowPrivateAddresses }, mail) => {
19
+ const fetch = pluginFetch({ allowPrivateAddresses });
20
+ return (task) => ({
21
+ fetch,
22
+ records: pluginRecords({
23
+ deps,
24
+ schema,
25
+ roles
26
+ }, task.runId, task.idempotencyKey),
27
+ files: runFiles(() => deps().files, task.runId),
28
+ mail
29
+ });
30
+ };
31
+ //#endregion
32
+ export { pluginServices, runFiles };
@@ -0,0 +1,62 @@
1
+ import { isCaptured } from "../../changes/capture-sql.js";
2
+ import { readyWhen, timeField } from "./ready-when.js";
3
+ import { checkFilter, parseFilter } from "@protobase/schema";
4
+ //#region src/workflows/ready/ready-check.ts
5
+ const timeTypes = /* @__PURE__ */ new Set(["timestamp", "date"]);
6
+ const usesSearch = (node) => {
7
+ if (typeof node !== "object" || node === null) return false;
8
+ const kind = node.kind;
9
+ if (kind === "search" || kind === "similar") return true;
10
+ return Object.values(node).some(usesSearch);
11
+ };
12
+ /**
13
+ * Why a ready trigger cannot run on the app's resources, checked as a workflow is saved: a resource that records no
14
+ * changes (so no write reaches the trigger), a `when` that does not check against it (a field that is not indexed for
15
+ * filtering among them, as for list filters), `now()` outside a time term, and key fields it does not have.
16
+ */
17
+ const readyProblems = (models, spec) => {
18
+ const model = models(spec.resource);
19
+ if (!model) return [{
20
+ field: "resource",
21
+ message: `"${spec.resource}" is not a resource of this app`
22
+ }];
23
+ const problems = [];
24
+ if (!isCaptured(model)) problems.push({
25
+ field: "resource",
26
+ message: `${spec.resource} records no changes, so no write reaches a trigger: declare events on it`
27
+ });
28
+ for (const name of spec.key ?? []) {
29
+ const field = model.fields[name];
30
+ if (!field) problems.push({
31
+ field: "key",
32
+ message: `${spec.resource} has no field "${name}"`
33
+ });
34
+ else if (field.sensitive) problems.push({
35
+ field: "key",
36
+ message: `${name} is sensitive, so a record read never carries it`
37
+ });
38
+ }
39
+ if (spec.when === void 0) return problems;
40
+ const split = readyWhen(spec.when);
41
+ if (!split.ok) return [...problems, ...split.problems.map((message) => ({
42
+ field: "when",
43
+ message
44
+ }))];
45
+ for (const { field } of split.when.timeTerms) if (!timeTypes.has(timeField(model, field)?.type ?? "")) problems.push({
46
+ field: "when",
47
+ message: `${field} is not a timestamp or date field of ${spec.resource}`
48
+ });
49
+ const parsed = parseFilter(spec.when);
50
+ if (usesSearch(parsed.ast)) problems.push({
51
+ field: "when",
52
+ message: "cannot use search() or similar()"
53
+ });
54
+ const checked = parsed.ast && checkFilter(model, parsed.ast);
55
+ if (checked && !checked.ok) problems.push(...checked.errors.map((error) => ({
56
+ field: "when",
57
+ message: error.message
58
+ })));
59
+ return problems;
60
+ };
61
+ //#endregion
62
+ export { readyProblems };
@@ -0,0 +1,104 @@
1
+ import { HttpProblem } from "../../problem.js";
2
+ import { assistantRecords } from "../../assistant/records.js";
3
+ import { saverSession } from "../plugin-records.js";
4
+ import { startWorkflow } from "../start-workflow.js";
5
+ import { triggerFailed, triggerRecovered } from "../triggers/trigger-row.js";
6
+ import { encodeKey } from "@protobase/schema/internal";
7
+ import { checkFilter, evaluateFilter, parseFilter } from "@protobase/schema";
8
+ import { sql } from "kysely";
9
+ import { createHash } from "node:crypto";
10
+ import { StepError } from "@protobase/workflows";
11
+ //#region src/workflows/ready/ready-start.ts
12
+ /** The most characters of a run's key; a longer ready key is kept as its SHA-256. */
13
+ const keyLength = 200;
14
+ /**
15
+ * The resource, the record key and the values of the `key` fields: the key a record starts one run under, so a trigger
16
+ * moved to another resource starts its records afresh.
17
+ */
18
+ const readyKey = (resource, model, record, keyFields = []) => {
19
+ const key = `record:${JSON.stringify(resource)}:${encodeKey(model.primaryKey.map((name) => record[name]))}${keyFields.map((name) => `:${JSON.stringify(record[name] ?? null)}`).join("")}`;
20
+ return key.length <= keyLength ? key : `record:sha256:${createHash("sha256").update(key).digest("hex")}`;
21
+ };
22
+ const holds = (model, when, record) => {
23
+ if (when === void 0) return true;
24
+ const parsed = parseFilter(when);
25
+ const checked = parsed.ast && checkFilter(model, parsed.ast);
26
+ if (!checked) return true;
27
+ if (!checked.ok) return false;
28
+ return Boolean(evaluateFilter(checked.filter, record));
29
+ };
30
+ /**
31
+ * Why a trigger may not go on reading as its saver: they hold no role any more or are gone, or the API refuses them
32
+ * (401, 403); `undefined` for any other error, such as a busy database, which the job retries.
33
+ */
34
+ const refusal = (error) => {
35
+ if (error instanceof StepError) return error.reason;
36
+ if (error instanceof HttpProblem && (error.status === 401 || error.status === 403)) return `${error.status} ${error.title}`;
37
+ };
38
+ /** The work's value, or the refusal that stopped it; any other error is thrown on. */
39
+ const orRefusal = (work) => work.then((value) => ({ value }), (error) => {
40
+ const reason = refusal(error);
41
+ if (reason === void 0) throw error;
42
+ return { refused: reason };
43
+ });
44
+ /** What a ready trigger works on: its declaration, resource, and the records API as the user who saved its version. */
45
+ const readyContext = async ({ deps, roles, declared }, trigger) => {
46
+ const spec = declared(trigger.type)?.ready(trigger.input);
47
+ const model = spec && deps().registry.find(spec.resource)?.model;
48
+ if (!spec || !model) return;
49
+ const session = await saverSession(roles, trigger.saved_by, trigger.tenant_id ?? void 0);
50
+ return {
51
+ spec,
52
+ model,
53
+ records: assistantRecords(deps(), session)
54
+ };
55
+ };
56
+ /**
57
+ * Starts a run of the record if it is ready now: one read of it through the API as the user who saved the enabled
58
+ * version, so their access rules, tenant scope and field access decide what the run sees, `when` evaluated on what they
59
+ * read, then one keyed insert, so a record starts once per ready key. A record that is gone, or that they may not read,
60
+ * starts nothing; a refused saver, or a key field they cannot read, marks the trigger failing, and a failing trigger
61
+ * that reads a record is active again. Answers whether this check brought the trigger back, so its records are passed
62
+ * over again for what it missed.
63
+ */
64
+ const startIfReady = async (input, db, trigger, key) => {
65
+ const failed = (message) => triggerFailed(db, input.schema, trigger, "refused", message).then(() => false);
66
+ const found = await orRefusal(readyContext(input, trigger));
67
+ if ("refused" in found) return failed(found.refused);
68
+ const context = found.value;
69
+ if (!context) return false;
70
+ const { spec, model, records } = context;
71
+ const keyValues = model.primaryKey.map((name) => key[name]);
72
+ const got = await orRefusal(records.get(spec.resource, keyValues.length === 1 ? keyValues[0] : keyValues).catch((error) => {
73
+ if (error instanceof HttpProblem && error.status === 404) return;
74
+ throw error;
75
+ }));
76
+ if ("refused" in got) return failed(got.refused);
77
+ const read = got.value;
78
+ if (!read) return false;
79
+ const { etag: _, permissions: __, ...record } = read.record;
80
+ const hidden = (spec.key ?? []).find((name) => !(name in record));
81
+ if (hidden) return failed(`the key field ${hidden} is not readable by the user who saved the workflow`);
82
+ const recovered = trigger.status === "failing" && await triggerRecovered(db, input.schema, trigger);
83
+ if (!holds(model, spec.when, record)) return recovered;
84
+ await db.transaction().execute(async (trx) => {
85
+ const { rows } = await sql`select enabled_version from ${sql.id(input.schema, "workflows")} where id = ${trigger.workflow_id} for share`.execute(trx);
86
+ if (rows[0]?.enabled_version !== trigger.version) return;
87
+ await startWorkflow(trx, {
88
+ workflow: trigger.workflow_id,
89
+ version: trigger.version,
90
+ input: {
91
+ resource: spec.resource,
92
+ key: encodeKey(model.primaryKey.map((name) => record[name])),
93
+ record
94
+ },
95
+ ...trigger.tenant_id !== null && { tenant: trigger.tenant_id },
96
+ startedBy: `trigger:${trigger.id}`,
97
+ key: readyKey(spec.resource, model, record, spec.key),
98
+ schema: input.schema
99
+ });
100
+ });
101
+ return recovered;
102
+ };
103
+ //#endregion
104
+ export { orRefusal, readyContext, readyKey, refusal, startIfReady };
@@ -0,0 +1,101 @@
1
+ import { parseFilter, printFilter } from "@protobase/schema";
2
+ //#region src/workflows/ready/ready-when.ts
3
+ const unitMs = {
4
+ s: 1e3,
5
+ m: 6e4,
6
+ h: 36e5,
7
+ d: 864e5,
8
+ w: 6048e5
9
+ };
10
+ /** The longest wait a time term may say, 100 years, so a ready time stays a date JavaScript can hold. */
11
+ const maxAfterMs = 31536e8;
12
+ const usesNow = (node) => {
13
+ if (typeof node !== "object" || node === null) return false;
14
+ if (node.kind === "now") return true;
15
+ return Object.values(node).some(usesNow);
16
+ };
17
+ const timeTerm = (term) => {
18
+ if (term.kind !== "compare" || term.op !== "<=" && term.op !== "<" || term.value.kind !== "now" || term.field.path.length !== 1) return;
19
+ const offset = term.value.offset;
20
+ if (offset && offset.sign !== "-") return;
21
+ const afterMs = (offset ? offset.duration.amount * unitMs[offset.duration.unit] : 0) + (term.op === "<" ? 1 : 0);
22
+ return {
23
+ field: term.field.path[0],
24
+ afterMs
25
+ };
26
+ };
27
+ /**
28
+ * Splits a ready trigger's `when`: `now()` may stand only in top-level AND terms of the form `<timestamp field> <=
29
+ * now() - <duration>` (or `<`), which say when a record becomes ready, so the start waits until then and nothing sweeps.
30
+ * Any other use of `now()` is refused, naming the term.
31
+ */
32
+ const readyWhen = (when) => {
33
+ if (when === void 0) return {
34
+ ok: true,
35
+ when: {
36
+ filter: void 0,
37
+ timeTerms: []
38
+ }
39
+ };
40
+ const parsed = parseFilter(when);
41
+ if (!parsed.ok) return {
42
+ ok: false,
43
+ problems: parsed.errors.map((error) => error.message)
44
+ };
45
+ if (!parsed.ast) return {
46
+ ok: true,
47
+ when: {
48
+ filter: void 0,
49
+ timeTerms: []
50
+ }
51
+ };
52
+ const terms = parsed.ast.kind === "and" ? parsed.ast.args : [parsed.ast];
53
+ const timeTerms = [];
54
+ const rest = [];
55
+ const problems = [];
56
+ for (const term of terms) {
57
+ const time = timeTerm(term);
58
+ if (time && time.afterMs > 31536e8) problems.push(`"${printFilter(term)}" waits longer than 100 years`);
59
+ else if (time) timeTerms.push(time);
60
+ else if (usesNow(term)) problems.push(`"${printFilter(term)}" uses now() other than as <timestamp field> <= now() - <duration> joined by AND`);
61
+ else rest.push(term);
62
+ }
63
+ if (problems.length > 0) return {
64
+ ok: false,
65
+ problems
66
+ };
67
+ return {
68
+ ok: true,
69
+ when: {
70
+ filter: rest.length === 0 ? void 0 : rest.length === 1 ? rest[0] : {
71
+ kind: "and",
72
+ args: rest,
73
+ span: parsed.ast.span
74
+ },
75
+ timeTerms
76
+ }
77
+ };
78
+ };
79
+ /** When a record becomes ready by its time terms: the latest of them, now when it has none, undefined when a time is unset. */
80
+ const readyAt = (timeTerms, record, now) => {
81
+ let at = now.getTime();
82
+ for (const { field, afterMs } of timeTerms) {
83
+ const value = record[field];
84
+ if (value === null || value === void 0) return;
85
+ const time = new Date(value).getTime();
86
+ if (Number.isNaN(time)) return;
87
+ at = Math.max(at, time + afterMs);
88
+ }
89
+ const ready = new Date(at);
90
+ if (Number.isNaN(ready.getTime())) return;
91
+ return ready;
92
+ };
93
+ /** The field a time term names, by its name or one of its aliases, as the record read carries it. */
94
+ const timeField = (model, name) => Object.values(model.fields).find((field) => field.name === name || field.aliases.includes(name));
95
+ /** The time terms with each field named as the record carries it; a term on no field of the model keeps its name. */
96
+ const resolveTimeTerms = (model, terms) => terms.map((term) => ({
97
+ ...term,
98
+ field: timeField(model, term.field)?.name ?? term.field
99
+ }));
100
+ //#endregion
101
+ export { maxAfterMs, readyAt, readyWhen, resolveTimeTerms, timeField };
@@ -1,6 +1,7 @@
1
1
  import { cleanName, formatUri, parseUri } from "../files/uri.js";
2
2
  import { readLimited } from "../webhooks/table/read-limited.js";
3
3
  import { StepError } from "@protobase/workflows";
4
+ import { isFileValue } from "@protobase/workflows/plugin";
4
5
  //#region src/workflows/run-files.ts
5
6
  /** The most bytes of one file a workflow stores or reads, as the `http` step reads at most. */
6
7
  const runFileLimit = 10485760;
@@ -15,10 +16,11 @@ const runStore = (files) => {
15
16
  };
16
17
  /**
17
18
  * Stores bytes a step of the run got, such as an answer's body, as a file of the run's own, under a name of the
18
- * runner's choosing: no part of its path comes from the workflow or the answer.
19
+ * runner's choosing: no part of its path comes from the workflow or the answer. At most `runFileLimit` bytes.
19
20
  */
20
21
  const storeRunFile = async (files, runId, bytes, name, type) => {
21
22
  const store = runStore(files);
23
+ if (bytes.length > 10485760) throw new StepError("States.TaskFailed", `${cleanName(name)} is larger than ${runFileLimit / 1024 / 1024} MiB, the most a run keeps of one file`);
22
24
  const path = `${prefix(runId)}${crypto.randomUUID()}`;
23
25
  await store.put(path, new Blob([bytes]).stream());
24
26
  const clean = cleanName(name);
@@ -34,12 +36,6 @@ const storeRunFile = async (files, runId, bytes, name, type) => {
34
36
  size: bytes.length
35
37
  };
36
38
  };
37
- /** Whether a value has a file value's shape, all four fields; an object with only a `uri` is other data. */
38
- const isFileValue = (value) => {
39
- if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
40
- const { uri, name, type, size } = value;
41
- return typeof uri === "string" && typeof name === "string" && typeof type === "string" && typeof size === "number";
42
- };
43
39
  /**
44
40
  * The file a file value names, when it is one the run stored itself; a step fails on any other, so a workflow cannot
45
41
  * mail, send or attach a file of the app's by naming its URI.
@@ -1,22 +1,23 @@
1
1
  import { badRequest } from "../problem.js";
2
- import { requiredStepInputs } from "@protobase/schema/internal";
2
+ import { syncTrigger } from "./triggers/trigger-sync.js";
3
3
  import { sql } from "kysely";
4
4
  import { checkDefinition, problemText } from "@protobase/workflows";
5
5
  //#region src/workflows/save-hook.ts
6
6
  /**
7
- * Checks a workflow's definition on every save that changes it, against the language's schema and the named-reads
8
- * profile, with the step types the app has, and keeps it as the version the table's trigger numbered, saved by the
9
- * caller. A refusal is a 400 problem naming each state and field, also listed as `problems`, and as the `definition`
10
- * field's `errors` with the state and field in their `params`, for a form to show each on its step; the write rolls back.
7
+ * Checks a workflow's definition on every save that changes it, and as it is turned on (not off), against the language's schema
8
+ * and the named-reads profile, with the trigger and step types the app has, and keeps a changed definition as the
9
+ * version the table's trigger numbered, saved by the caller. Then it keeps the workflow's trigger in step with its
10
+ * enabled version. A refusal is a 400 problem naming each state and field, also listed as `problems`, and as the
11
+ * `definition` field's `errors` with the state and field in their `params`, for a form to show each on its step; the
12
+ * write rolls back.
11
13
  */
12
- const workflowSaveHook = (schema, stepTypes) => async (event, trx) => {
14
+ const workflowSaveHook = (schema, types, jobs) => async (event, trx) => {
13
15
  if (event.resource.name !== "workflows" || !event.after) return;
14
16
  if (event.operation !== "create" && event.operation !== "update") return;
15
- if (event.operation === "update" && event.after.version === event.before?.version) return;
16
- const problems = checkDefinition(event.after.definition, {
17
- resources: stepTypes,
18
- required: requiredStepInputs
19
- });
17
+ const changed = event.operation === "create" || event.after.version !== event.before?.version;
18
+ const enabling = event.after.enabled_version !== (event.before?.enabled_version ?? null);
19
+ if (!changed && !enabling) return;
20
+ const problems = changed || event.after.enabled_version !== null ? checkDefinition(event.after.definition, types) : [];
20
21
  if (problems.length > 0) {
21
22
  const errors = problems.map((problem) => ({
22
23
  code: "invalid-workflow",
@@ -32,8 +33,9 @@ const workflowSaveHook = (schema, stepTypes) => async (event, trx) => {
32
33
  errors
33
34
  });
34
35
  }
35
- await sql`insert into ${sql.id(schema, "versions")} (workflow_id, version, definition, saved_by, tenant_id)
36
- 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);
36
+ if (changed) await sql`insert into ${sql.id(schema, "versions")} (workflow_id, version, definition, saved_by, tenant_id)
37
+ 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);
37
39
  };
38
40
  //#endregion
39
41
  export { workflowSaveHook };