@protobase/server 0.67.12 → 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.
- package/dist/batch/run-batch.d.ts +1 -0
- package/dist/batch/run-batch.js +6 -4
- package/dist/create-admin.js +7 -4
- package/dist/idempotency.d.ts +1 -0
- package/dist/idempotency.js +12 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +3 -2
- package/dist/mcp/mcp-tools.d.ts +4 -2
- package/dist/mcp/mcp-tools.js +9 -3
- package/dist/package.js +1 -1
- package/dist/webhooks/attempt.js +2 -2
- package/dist/webhooks/table/address-check.js +1 -1
- package/dist/webhooks/table/webhook-table.js +1 -1
- package/dist/webhooks/webhooks.js +2 -1
- package/dist/workflows/effects-store.js +18 -0
- package/dist/workflows/mcp/workflow-instructions.js +1 -1
- package/dist/workflows/mcp/workflow-read-tools.d.ts +11 -1
- package/dist/workflows/mcp/workflow-read-tools.js +14 -19
- package/dist/workflows/options.d.ts +6 -0
- package/dist/workflows/plugin-fetch.js +40 -0
- package/dist/workflows/plugin-records.d.ts +10 -0
- package/dist/workflows/plugin-records.js +120 -0
- package/dist/workflows/plugin-services.d.ts +13 -0
- package/dist/workflows/plugin-services.js +32 -0
- package/dist/workflows/ready/ready-check.js +62 -0
- package/dist/workflows/ready/ready-start.js +104 -0
- package/dist/workflows/ready/ready-when.js +101 -0
- package/dist/workflows/run-files.js +3 -7
- package/dist/workflows/save-hook.js +15 -13
- package/dist/workflows/schema-sql.js +65 -3
- package/dist/workflows/schema.d.ts +2 -2
- package/dist/workflows/schema.js +6 -2
- package/dist/workflows/start-workflow.d.ts +3 -1
- package/dist/workflows/start-workflow.js +3 -3
- package/dist/workflows/triggers/trigger-jobs.js +114 -0
- package/dist/workflows/triggers/trigger-row.js +36 -0
- package/dist/workflows/triggers/trigger-sync.js +48 -0
- package/dist/workflows/workflow-engine.js +17 -5
- package/dist/workflows/workflow-types-route.js +22 -0
- package/dist/workflows/workflow-types.d.ts +22 -0
- package/dist/workflows/workflow-types.js +63 -0
- package/dist/workflows/workflows-module.d.ts +25 -1
- package/package.json +6 -5
- package/dist/workflows/http-step.js +0 -103
- package/dist/workflows/mail-step.js +0 -58
- package/dist/workflows/mcp/type-catalog.js +0 -144
- package/dist/workflows/piece-step.js +0 -11
- package/dist/workflows/records-step.js +0 -114
- package/dist/workflows/step-types.js +0 -27
|
@@ -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 {
|
|
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
|
|
8
|
-
* profile, with the step types the app has, and keeps
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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,
|
|
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
|
-
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
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 };
|
|
@@ -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
|
+
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
|
-
|
|
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
|
package/dist/workflows/schema.js
CHANGED
|
@@ -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) ===
|
|
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")};`;
|