@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.
- package/dist/batch/run-batch.d.ts +1 -0
- package/dist/batch/run-batch.js +6 -4
- package/dist/check-views.js +6 -0
- 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/user-tables/admin-user-tables.js +9 -4
- package/dist/user-tables/definitions-views.js +93 -0
- package/dist/view-access.js +1 -0
- 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
|
@@ -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 {
|
|
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 };
|