@anchrd/intel-api 0.3.1 → 0.3.3
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/adapters/cloudflare/cloudflare.js +11 -12
- package/dist/adapters/db/db-flows.js +367 -193
- package/dist/adapters/db/db-grants.d.ts +47 -0
- package/dist/adapters/db/db-grants.js +125 -0
- package/dist/adapters/db/db-indexing.js +14 -3
- package/dist/adapters/db/db.js +236 -202
- package/dist/flows/flows.d.ts +31 -1
- package/dist/flows/flows.js +939 -72
- package/dist/flows/flows.types.d.ts +92 -28
- package/dist/http/http.js +130 -53
- package/dist/indexing/indexing.js +14 -3
- package/dist/knowledge/document-links/document-links.d.ts +15 -0
- package/dist/knowledge/document-links/document-links.js +52 -0
- package/dist/knowledge/knowledge.js +347 -74
- package/dist/knowledge/knowledge.types.d.ts +58 -27
- package/dist/mcp/mcp.js +153 -71
- package/dist/shared/csv/csv.d.ts +13 -0
- package/dist/shared/csv/csv.js +85 -0
- package/dist/shared/gate-authorization/gate-authorization.d.ts +1 -0
- package/dist/shared/gate-authorization/gate-authorization.js +7 -0
- package/migrations/0003_folder_permissions.sql +211 -0
- package/migrations/0004_subflow_runs.sql +14 -0
- package/migrations/0005_flow_node_cleanup.sql +28 -0
- package/migrations/0005_tables_in_the_knowledge_tree.sql +68 -0
- package/migrations/0006_links_are_written_in_the_text.sql +20 -0
- package/package.json +1 -1
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
import { Flow, FlowGraph, FlowRun, FlowVersion, } from "@anchrd/intel-contract";
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
import { calleeIds } from "../../flows/flows.js";
|
|
3
|
+
import { flowCallable, flowInSubtree, flowInSubtreeBindings, flowVerbBindings, flowVerbQuery, readableOrRunnableCte, subtreeBindings, subtreeCte, } from "./db-grants.js";
|
|
4
|
+
const flowColumnNames = [
|
|
5
|
+
"id",
|
|
6
|
+
"parent_id",
|
|
7
|
+
"title",
|
|
8
|
+
"description",
|
|
9
|
+
"owner_id",
|
|
10
|
+
"current_version_id",
|
|
11
|
+
"published_version_id",
|
|
12
|
+
"created_at",
|
|
13
|
+
"updated_at",
|
|
14
|
+
"archived_at",
|
|
15
|
+
];
|
|
16
|
+
const flowColumns = flowColumnNames.join(", ");
|
|
4
17
|
const runColumnNames = [
|
|
5
18
|
"id",
|
|
6
19
|
"flow_id",
|
|
@@ -11,6 +24,8 @@ const runColumnNames = [
|
|
|
11
24
|
"output_json",
|
|
12
25
|
"error",
|
|
13
26
|
"initiated_by",
|
|
27
|
+
"parent_run_id",
|
|
28
|
+
"parent_node_id",
|
|
14
29
|
"created_at",
|
|
15
30
|
"updated_at",
|
|
16
31
|
"completed_at",
|
|
@@ -19,23 +34,7 @@ const runColumns = runColumnNames.join(", ");
|
|
|
19
34
|
// Joined queries need the columns qualified. Deriving them from the array keeps both forms in step;
|
|
20
35
|
// splitting the rendered string would break the moment it is reformatted.
|
|
21
36
|
const qualifiedRunColumns = (alias) => runColumnNames.map((column) => `${alias}.${column}`).join(", ");
|
|
22
|
-
|
|
23
|
-
const role = roles.length ? `AND grant_row.role IN (${roles.map(() => "?").join(", ")})` : "";
|
|
24
|
-
return `(flow.owner_id = ? OR EXISTS (
|
|
25
|
-
SELECT 1 FROM resource_grants grant_row
|
|
26
|
-
WHERE grant_row.resource_type = 'flow' AND grant_row.resource_id = flow.id
|
|
27
|
-
AND (
|
|
28
|
-
(grant_row.principal_type = 'user' AND grant_row.principal_id = ?)
|
|
29
|
-
OR (grant_row.principal_type = 'email' AND lower(grant_row.principal_id) = lower(?))
|
|
30
|
-
OR (grant_row.principal_type = 'organization' AND grant_row.principal_id = '*')
|
|
31
|
-
)
|
|
32
|
-
${role}
|
|
33
|
-
AND (grant_row.expires_at IS NULL OR grant_row.expires_at > ?)
|
|
34
|
-
))`;
|
|
35
|
-
}
|
|
36
|
-
function accessBindings(actor, now, roles = []) {
|
|
37
|
-
return [actor.id, actor.id, actor.email, ...roles, now];
|
|
38
|
-
}
|
|
37
|
+
const qualifiedFlowColumns = (alias) => flowColumnNames.map((column) => `${alias}.${column}`).join(", ");
|
|
39
38
|
function mapFlow(row) {
|
|
40
39
|
return Flow.parse({
|
|
41
40
|
id: row.id,
|
|
@@ -60,6 +59,25 @@ function mapVersion(row) {
|
|
|
60
59
|
createdAt: row.created_at,
|
|
61
60
|
});
|
|
62
61
|
}
|
|
62
|
+
// ⚠️ The one answer to "may this actor see this run", so a single run and a list of them cannot
|
|
63
|
+
// start disagreeing. A run you started is yours to follow — without that, a run of a library flow,
|
|
64
|
+
// shared with `execute` for everyone and readable by nobody (ADR-0004 §3), would vanish the moment
|
|
65
|
+
// it was started. Everything else is the folder the flow is filed in, read through `flowInSubtree`,
|
|
66
|
+
// the very predicate the flow list itself uses.
|
|
67
|
+
//
|
|
68
|
+
// It expects `flow_runs run` joined to `flows flow`, and its bindings follow the CTE's.
|
|
69
|
+
const runVisible = `(run.initiated_by = ? OR ${flowInSubtree})`;
|
|
70
|
+
const runVisibleBindings = (actor) => [actor.id, ...flowInSubtreeBindings(actor)];
|
|
71
|
+
function mapStep(row) {
|
|
72
|
+
return {
|
|
73
|
+
runId: row.run_id,
|
|
74
|
+
nodeId: row.node_id,
|
|
75
|
+
outcome: row.outcome,
|
|
76
|
+
branch: row.branch,
|
|
77
|
+
error: row.error,
|
|
78
|
+
completedAt: row.completed_at,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
63
81
|
function mapRun(row) {
|
|
64
82
|
return FlowRun.parse({
|
|
65
83
|
id: row.id,
|
|
@@ -71,78 +89,214 @@ function mapRun(row) {
|
|
|
71
89
|
output: row.output_json === null ? null : JSON.parse(row.output_json),
|
|
72
90
|
error: row.error,
|
|
73
91
|
initiatedBy: row.initiated_by,
|
|
92
|
+
parentRunId: row.parent_run_id,
|
|
93
|
+
parentNodeId: row.parent_node_id,
|
|
74
94
|
createdAt: row.created_at,
|
|
75
95
|
updatedAt: row.updated_at,
|
|
76
96
|
completedAt: row.completed_at,
|
|
77
97
|
});
|
|
78
98
|
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
principal = { type: "user", id: row.principal_id };
|
|
84
|
-
break;
|
|
85
|
-
case "email":
|
|
86
|
-
principal = { type: "email", email: row.principal_id };
|
|
87
|
-
break;
|
|
88
|
-
case "organization":
|
|
89
|
-
principal = { type: "organization" };
|
|
90
|
-
break;
|
|
91
|
-
default:
|
|
92
|
-
throw new Error("Unsupported flow grant principal type");
|
|
93
|
-
}
|
|
94
|
-
return {
|
|
95
|
-
id: row.id,
|
|
96
|
-
resourceId: row.resource_id,
|
|
97
|
-
principal,
|
|
98
|
-
role: row.role,
|
|
99
|
-
expiresAt: row.expires_at,
|
|
100
|
-
createdBy: row.created_by,
|
|
101
|
-
createdAt: row.created_at,
|
|
102
|
-
};
|
|
103
|
-
}
|
|
99
|
+
// A list of IDs as one bound value rather than one placeholder each. D1 caps how many parameters a
|
|
100
|
+
// statement may carry, and the lists here are as long as a graph, a call chain or a drawn level —
|
|
101
|
+
// so an `IN (?, ?, …)` built from the input would be a limit waiting to be hit by real data (#30).
|
|
102
|
+
const idList = "(SELECT value FROM json_each(?))";
|
|
104
103
|
export function createFlowRepository(deps) {
|
|
105
|
-
|
|
104
|
+
// A flow holds no grant of its own any more (ADR-0004 §2). What reaches it is the folder it is
|
|
105
|
+
// filed in, read through the same walk Knowledge uses, so the two can never drift apart. The CTE
|
|
106
|
+
// stands in front of the statement, so its bindings come before every other one.
|
|
107
|
+
const readableBindings = (actor) => subtreeBindings(actor, "read", deps.now().toISOString());
|
|
108
|
+
// An absent `parentId` asks for every visible flow; `null` asks for the root of the shared tree.
|
|
109
|
+
// `IS ?` would collapse the two, so the two cases are separate SQL rather than one binding that
|
|
110
|
+
// silently means both.
|
|
111
|
+
const scopeOf = (parentId) => parentId === undefined
|
|
112
|
+
? { clause: "", bindings: [] }
|
|
113
|
+
: parentId === null
|
|
114
|
+
? { clause: "AND flow.parent_id IS NULL", bindings: [] }
|
|
115
|
+
: { clause: "AND flow.parent_id = ?", bindings: [parentId] };
|
|
116
|
+
/**
|
|
117
|
+
* One statement for the list the sidebar reads and for the bounded read the relation graph makes,
|
|
118
|
+
* so the visibility predicate cannot drift between them.
|
|
119
|
+
*
|
|
120
|
+
* ⚠️ The order of the two is the whole point of the bounded form (#30): `LIMIT` follows the
|
|
121
|
+
* `WHERE`, so the cut is made among the rows this actor may see and never before them. For the
|
|
122
|
+
* same reason `COUNT(*) OVER ()` is a count of those rows alone — the number the graph reports
|
|
123
|
+
* about its own size would otherwise disclose that something else is there.
|
|
124
|
+
*/
|
|
125
|
+
const visibleFlows = (scopeClause, bounded) => `${subtreeCte}
|
|
126
|
+
SELECT ${flowColumns}${bounded ? ", COUNT(*) OVER () AS total" : ""} FROM flows flow
|
|
127
|
+
WHERE ${flowInSubtree} AND flow.archived_at IS NULL ${scopeClause}
|
|
128
|
+
ORDER BY lower(flow.title), flow.id${bounded ? " LIMIT ?" : ""}`;
|
|
106
129
|
return {
|
|
107
130
|
async listVisible(actor, input = {}) {
|
|
108
|
-
|
|
109
|
-
// tree. `IS ?` would collapse the two, so the two cases are separate SQL rather than one
|
|
110
|
-
// binding that silently means both.
|
|
111
|
-
const scope = input.parentId === undefined
|
|
112
|
-
? { clause: "", bindings: [] }
|
|
113
|
-
: input.parentId === null
|
|
114
|
-
? { clause: "AND flow.parent_id IS NULL", bindings: [] }
|
|
115
|
-
: { clause: "AND flow.parent_id = ?", bindings: [input.parentId] };
|
|
131
|
+
const scope = scopeOf(input.parentId);
|
|
116
132
|
const result = await deps.db
|
|
117
|
-
.prepare(
|
|
118
|
-
|
|
119
|
-
ORDER BY lower(flow.title), flow.id`)
|
|
120
|
-
.bind(...accessBindings(actor, deps.now().toISOString()), ...scope.bindings)
|
|
133
|
+
.prepare(visibleFlows(scope.clause, false))
|
|
134
|
+
.bind(...readableBindings(actor), ...flowInSubtreeBindings(actor), ...scope.bindings)
|
|
121
135
|
.all();
|
|
122
136
|
return (result.results ?? []).map(mapFlow);
|
|
123
137
|
},
|
|
138
|
+
async listVisibleBounded(actor, folderId, limit) {
|
|
139
|
+
const scope = scopeOf(folderId);
|
|
140
|
+
const result = await deps.db
|
|
141
|
+
.prepare(visibleFlows(scope.clause, true))
|
|
142
|
+
.bind(...readableBindings(actor), ...flowInSubtreeBindings(actor), ...scope.bindings, limit)
|
|
143
|
+
.all();
|
|
144
|
+
const rows = result.results ?? [];
|
|
145
|
+
return { items: rows.map(mapFlow), total: rows[0]?.total ?? 0 };
|
|
146
|
+
},
|
|
124
147
|
async getVisible(actor, flowId) {
|
|
125
148
|
const row = await deps.db
|
|
126
|
-
.prepare(
|
|
127
|
-
|
|
149
|
+
.prepare(`${subtreeCte}
|
|
150
|
+
SELECT ${flowColumns} FROM flows flow
|
|
151
|
+
WHERE flow.id = ? AND ${flowInSubtree}`)
|
|
152
|
+
.bind(...readableBindings(actor), flowId, ...flowInSubtreeBindings(actor))
|
|
128
153
|
.first();
|
|
129
154
|
return row ? mapFlow(row) : null;
|
|
130
155
|
},
|
|
131
|
-
async
|
|
156
|
+
async can(actor, flowId, verb) {
|
|
132
157
|
const row = await deps.db
|
|
133
|
-
.prepare(
|
|
134
|
-
|
|
135
|
-
.bind(flowId, ...accessBindings(actor, deps.now().toISOString(), ["editor", "manager"]))
|
|
158
|
+
.prepare(flowVerbQuery)
|
|
159
|
+
.bind(...flowVerbBindings(flowId, actor, verb, deps.now().toISOString()))
|
|
136
160
|
.first();
|
|
137
161
|
return row?.allowed === 1;
|
|
138
162
|
},
|
|
139
|
-
|
|
163
|
+
// A library flow is run by people who may not open the folder it lives in, so `read` alone would
|
|
164
|
+
// make the library unwirable. The two grants stay independent (ADR-0004 §2) — they are two
|
|
165
|
+
// walks of the tree — but they are asked in one breath rather than one after the other, because
|
|
166
|
+
// a graph names as many callees as it likes and each of them used to cost its own round trip
|
|
167
|
+
// (#30). Order is the graph's, restored by the caller: SQL sorts, a call list does not.
|
|
168
|
+
async listCallable(actor, flowIds) {
|
|
169
|
+
if (flowIds.length === 0)
|
|
170
|
+
return [];
|
|
171
|
+
const occurredAt = deps.now().toISOString();
|
|
172
|
+
const result = await deps.db
|
|
173
|
+
.prepare(`${readableOrRunnableCte}
|
|
174
|
+
SELECT ${flowColumns} FROM flows flow
|
|
175
|
+
WHERE flow.id IN ${idList} AND ${flowCallable}
|
|
176
|
+
ORDER BY lower(flow.title), flow.id`)
|
|
177
|
+
.bind(...subtreeBindings(actor, "read", occurredAt), ...subtreeBindings(actor, "execute", occurredAt), JSON.stringify(flowIds), ...flowInSubtreeBindings(actor))
|
|
178
|
+
.all();
|
|
179
|
+
return (result.results ?? []).map(mapFlow);
|
|
180
|
+
},
|
|
181
|
+
async getCallable(actor, flowId) {
|
|
182
|
+
return (await this.listCallable(actor, [flowId]))[0] ?? null;
|
|
183
|
+
},
|
|
184
|
+
// ⚠️ `UNION`, never `UNION ALL`, for the same reason as every other walk of this tree: a ring in
|
|
185
|
+
// `parent_id` must end the recursion rather than the database.
|
|
186
|
+
async callReach(callerFolderId, calleeFolderId) {
|
|
187
|
+
// A flow at the root of the tree stands in the root folder, whose subtree is the whole tree.
|
|
188
|
+
if (callerFolderId === null)
|
|
189
|
+
return "subtree";
|
|
190
|
+
// A callee at the root has no folder, so nothing can carry an `execute` for it: not a library.
|
|
191
|
+
if (calleeFolderId === null)
|
|
192
|
+
return "out-of-reach";
|
|
140
193
|
const row = await deps.db
|
|
141
|
-
.prepare(`
|
|
142
|
-
|
|
143
|
-
|
|
194
|
+
.prepare(`WITH RECURSIVE ancestors(id, parent_id) AS (
|
|
195
|
+
SELECT id, parent_id FROM knowledge_nodes WHERE id = ?
|
|
196
|
+
UNION
|
|
197
|
+
SELECT parent.id, parent.parent_id
|
|
198
|
+
FROM knowledge_nodes parent
|
|
199
|
+
JOIN ancestors child ON child.parent_id = parent.id
|
|
200
|
+
)
|
|
201
|
+
SELECT
|
|
202
|
+
EXISTS (SELECT 1 FROM ancestors WHERE id = ?) AS in_subtree,
|
|
203
|
+
EXISTS (
|
|
204
|
+
SELECT 1 FROM tree_grants grant_row
|
|
205
|
+
JOIN ancestors ON ancestors.id = grant_row.node_id
|
|
206
|
+
WHERE grant_row.principal_type = 'organization'
|
|
207
|
+
AND grant_row.verb = 'execute'
|
|
208
|
+
AND (grant_row.expires_at IS NULL OR grant_row.expires_at > ?)
|
|
209
|
+
) AS library`)
|
|
210
|
+
.bind(calleeFolderId, callerFolderId, deps.now().toISOString())
|
|
144
211
|
.first();
|
|
145
|
-
|
|
212
|
+
if (row?.in_subtree === 1)
|
|
213
|
+
return "subtree";
|
|
214
|
+
// The ADR allows a folder whose `execute` reaches at least as far as the caller's own. The
|
|
215
|
+
// only such folder that can be answered without comparing two principal sets is the one whose
|
|
216
|
+
// `execute` reaches everyone, and the ADR names that as the case in practice.
|
|
217
|
+
return row?.library === 1 ? "library" : "out-of-reach";
|
|
218
|
+
},
|
|
219
|
+
// ⚠️ Two answers out of one statement: the callers this actor may see, by title, and how many
|
|
220
|
+
// more there are. The predicate is `flowInSubtree` — the very one `listVisible` uses — so a
|
|
221
|
+
// caller is named here exactly when it would be listed anywhere else. A second, kinder rule for
|
|
222
|
+
// error messages is how a refusal turns into a way of reading the tree.
|
|
223
|
+
async externalCallers(actor, folderId) {
|
|
224
|
+
const result = await deps.db
|
|
225
|
+
.prepare(`${subtreeCte},
|
|
226
|
+
scope(id) AS (
|
|
227
|
+
SELECT id FROM knowledge_nodes WHERE id = ?
|
|
228
|
+
UNION
|
|
229
|
+
SELECT child.id FROM knowledge_nodes child JOIN scope ON child.parent_id = scope.id
|
|
230
|
+
)
|
|
231
|
+
SELECT DISTINCT ${qualifiedFlowColumns("flow")},
|
|
232
|
+
CASE WHEN ${flowInSubtree} THEN 1 ELSE 0 END AS visible
|
|
233
|
+
FROM flows flow
|
|
234
|
+
JOIN flow_versions version ON version.id = flow.published_version_id
|
|
235
|
+
JOIN json_each(version.graph_json, '$.nodes') node
|
|
236
|
+
JOIN flows callee ON callee.id = json_extract(node.value, '$.configuration.flowId')
|
|
237
|
+
WHERE json_extract(node.value, '$.kind') = 'subflow'
|
|
238
|
+
AND flow.archived_at IS NULL
|
|
239
|
+
AND callee.parent_id IN (SELECT id FROM scope)
|
|
240
|
+
AND (flow.parent_id IS NULL OR flow.parent_id NOT IN (SELECT id FROM scope))
|
|
241
|
+
ORDER BY lower(flow.title), flow.id`)
|
|
242
|
+
.bind(...subtreeBindings(actor, "read", deps.now().toISOString()), folderId, ...flowInSubtreeBindings(actor))
|
|
243
|
+
.all();
|
|
244
|
+
const rows = result.results ?? [];
|
|
245
|
+
return {
|
|
246
|
+
visible: rows.filter((row) => row.visible === 1).map((row) => mapFlow(row).title),
|
|
247
|
+
hidden: rows.filter((row) => row.visible !== 1).length,
|
|
248
|
+
};
|
|
249
|
+
},
|
|
250
|
+
// The Knowledge documents the flows in this folder's subtree read, so whoever shares the folder
|
|
251
|
+
// can be told what the grant does not cover (ADR-0004 §4).
|
|
252
|
+
//
|
|
253
|
+
// ⚠️ `flowInSubtree` again, the very predicate `listVisible` uses: a flow this actor may not see
|
|
254
|
+
// must not reach the answer even as a number, because what it reads would then be attributed to
|
|
255
|
+
// a folder they administer. Whether any of the returned IDs may be *named* is Knowledge's
|
|
256
|
+
// question — this one hands back IDs and no titles.
|
|
257
|
+
//
|
|
258
|
+
// The knowledge nodes are collected first and expanded afterwards, so `json_each` is only ever
|
|
259
|
+
// handed the `resourceIds` array of a node that has one.
|
|
260
|
+
async knowledgeReferences(actor, folderId) {
|
|
261
|
+
const result = await deps.db
|
|
262
|
+
.prepare(`${subtreeCte},
|
|
263
|
+
scope(id) AS (
|
|
264
|
+
SELECT id FROM knowledge_nodes WHERE id = ?
|
|
265
|
+
UNION
|
|
266
|
+
SELECT child.id FROM knowledge_nodes child JOIN scope ON child.parent_id = scope.id
|
|
267
|
+
),
|
|
268
|
+
referencing(resource_ids) AS (
|
|
269
|
+
SELECT json_extract(node.value, '$.configuration.resourceIds')
|
|
270
|
+
FROM flows flow
|
|
271
|
+
JOIN flow_versions version ON version.id = flow.published_version_id
|
|
272
|
+
JOIN json_each(version.graph_json, '$.nodes') node
|
|
273
|
+
WHERE json_extract(node.value, '$.kind') = 'knowledge'
|
|
274
|
+
AND flow.archived_at IS NULL
|
|
275
|
+
AND flow.parent_id IN (SELECT id FROM scope)
|
|
276
|
+
AND ${flowInSubtree}
|
|
277
|
+
)
|
|
278
|
+
SELECT DISTINCT reference.value AS resource_id
|
|
279
|
+
FROM referencing
|
|
280
|
+
JOIN json_each(referencing.resource_ids) reference
|
|
281
|
+
ORDER BY reference.value`)
|
|
282
|
+
.bind(...subtreeBindings(actor, "read", deps.now().toISOString()), folderId, ...flowInSubtreeBindings(actor))
|
|
283
|
+
.all();
|
|
284
|
+
return (result.results ?? []).map((row) => row.resource_id);
|
|
285
|
+
},
|
|
286
|
+
async publishedCallees(flowId) {
|
|
287
|
+
const row = await deps.db
|
|
288
|
+
.prepare(`SELECT flow.title AS title, version.graph_json AS graph_json
|
|
289
|
+
FROM flows flow
|
|
290
|
+
JOIN flow_versions version ON version.id = flow.published_version_id
|
|
291
|
+
WHERE flow.id = ? AND flow.archived_at IS NULL`)
|
|
292
|
+
.bind(flowId)
|
|
293
|
+
.first();
|
|
294
|
+
if (!row)
|
|
295
|
+
return null;
|
|
296
|
+
return {
|
|
297
|
+
title: row.title,
|
|
298
|
+
calleeIds: calleeIds(FlowGraph.parse(JSON.parse(row.graph_json))),
|
|
299
|
+
};
|
|
146
300
|
},
|
|
147
301
|
async findIdempotent(actorId, operation, key) {
|
|
148
302
|
const row = await deps.db
|
|
@@ -152,16 +306,6 @@ export function createFlowRepository(deps) {
|
|
|
152
306
|
.first();
|
|
153
307
|
return row?.resource_id ?? null;
|
|
154
308
|
},
|
|
155
|
-
async findIdempotentRevocation(actorId, key) {
|
|
156
|
-
const row = await deps.db
|
|
157
|
-
.prepare(`SELECT resource_id FROM idempotency_keys
|
|
158
|
-
WHERE actor_id = ? AND operation = 'flows.revoke' AND idempotency_key = ?`)
|
|
159
|
-
.bind(actorId, key)
|
|
160
|
-
.first();
|
|
161
|
-
if (!row)
|
|
162
|
-
return null;
|
|
163
|
-
return row.resource_id.startsWith("1:");
|
|
164
|
-
},
|
|
165
309
|
async insertFlow(input) {
|
|
166
310
|
const flow = input.flow;
|
|
167
311
|
try {
|
|
@@ -244,13 +388,21 @@ export function createFlowRepository(deps) {
|
|
|
244
388
|
.first();
|
|
245
389
|
return row ? mapFlow(row) : "conflict";
|
|
246
390
|
},
|
|
247
|
-
|
|
248
|
-
|
|
391
|
+
// Versions are immutable, so a set of them is a single read by definition: the trail of a nested
|
|
392
|
+
// run needs one per level and the relation graph one per flow it draws, and both used to ask
|
|
393
|
+
// level by level (#30). Missing IDs are simply absent from the answer.
|
|
394
|
+
async getVersions(versionIds) {
|
|
395
|
+
if (versionIds.length === 0)
|
|
396
|
+
return [];
|
|
397
|
+
const result = await deps.db
|
|
249
398
|
.prepare(`SELECT id, flow_id, sequence, graph_json, created_by, created_at
|
|
250
|
-
FROM flow_versions WHERE id
|
|
251
|
-
.bind(
|
|
252
|
-
.
|
|
253
|
-
return
|
|
399
|
+
FROM flow_versions WHERE id IN ${idList}`)
|
|
400
|
+
.bind(JSON.stringify(versionIds))
|
|
401
|
+
.all();
|
|
402
|
+
return (result.results ?? []).map(mapVersion);
|
|
403
|
+
},
|
|
404
|
+
async getVersion(versionId) {
|
|
405
|
+
return (await this.getVersions([versionId]))[0] ?? null;
|
|
254
406
|
},
|
|
255
407
|
async insertVersion(input) {
|
|
256
408
|
const version = input.version;
|
|
@@ -329,109 +481,6 @@ export function createFlowRepository(deps) {
|
|
|
329
481
|
}
|
|
330
482
|
return ((await this.findIdempotent(input.actorId, "flows.publish", input.idempotencyKey)) !== null);
|
|
331
483
|
},
|
|
332
|
-
async listGrants(flowId) {
|
|
333
|
-
const result = await deps.db
|
|
334
|
-
.prepare(`SELECT id, resource_id, principal_type, principal_id, role, expires_at,
|
|
335
|
-
created_by, created_at FROM resource_grants
|
|
336
|
-
WHERE resource_type = 'flow' AND resource_id = ?
|
|
337
|
-
ORDER BY created_at, id`)
|
|
338
|
-
.bind(flowId)
|
|
339
|
-
.all();
|
|
340
|
-
return (result.results ?? []).map(mapGrant);
|
|
341
|
-
},
|
|
342
|
-
async setGrant(input) {
|
|
343
|
-
const grant = input.grant;
|
|
344
|
-
const principalType = grant.principal.type;
|
|
345
|
-
const principalId = grant.principal.type === "user"
|
|
346
|
-
? grant.principal.id
|
|
347
|
-
: grant.principal.type === "email"
|
|
348
|
-
? grant.principal.email.toLowerCase()
|
|
349
|
-
: "*";
|
|
350
|
-
try {
|
|
351
|
-
await deps.db.batch([
|
|
352
|
-
deps.db
|
|
353
|
-
.prepare(`INSERT INTO resource_grants (
|
|
354
|
-
id, resource_type, resource_id, principal_type, principal_id, role,
|
|
355
|
-
expires_at, created_by, created_at
|
|
356
|
-
) VALUES (?, 'flow', ?, ?, ?, ?, ?, ?, ?)
|
|
357
|
-
ON CONFLICT (resource_type, resource_id, principal_type, principal_id)
|
|
358
|
-
DO UPDATE SET role = excluded.role, expires_at = excluded.expires_at`)
|
|
359
|
-
.bind(grant.id, grant.resourceId, principalType, principalId, grant.role, grant.expiresAt, grant.createdBy, grant.createdAt),
|
|
360
|
-
deps.db
|
|
361
|
-
.prepare(`INSERT INTO idempotency_keys (
|
|
362
|
-
actor_id, operation, idempotency_key, resource_id, created_at
|
|
363
|
-
) SELECT ?, 'flows.share', ?, id, ? FROM resource_grants
|
|
364
|
-
WHERE resource_type = 'flow' AND resource_id = ?
|
|
365
|
-
AND principal_type = ? AND principal_id = ?`)
|
|
366
|
-
.bind(input.actorId, input.idempotencyKey, grant.createdAt, grant.resourceId, principalType, principalId),
|
|
367
|
-
deps.db
|
|
368
|
-
.prepare(`INSERT INTO audit_events (
|
|
369
|
-
id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
|
|
370
|
-
) VALUES (?, ?, 'flows.share', 'flow', ?, ?, ?)`)
|
|
371
|
-
.bind(input.auditId, input.actorId, grant.resourceId, JSON.stringify({ principalType, role: grant.role }), grant.createdAt),
|
|
372
|
-
]);
|
|
373
|
-
const stored = await deps.db
|
|
374
|
-
.prepare(`SELECT id, resource_id, principal_type, principal_id, role, expires_at,
|
|
375
|
-
created_by, created_at FROM resource_grants
|
|
376
|
-
WHERE resource_type = 'flow' AND resource_id = ?
|
|
377
|
-
AND principal_type = ? AND principal_id = ?`)
|
|
378
|
-
.bind(grant.resourceId, principalType, principalId)
|
|
379
|
-
.first();
|
|
380
|
-
if (!stored)
|
|
381
|
-
throw new Error("Flow grant disappeared after upsert");
|
|
382
|
-
return mapGrant(stored);
|
|
383
|
-
}
|
|
384
|
-
catch (error) {
|
|
385
|
-
const replayed = await this.findIdempotent(input.actorId, "flows.share", input.idempotencyKey);
|
|
386
|
-
const row = replayed
|
|
387
|
-
? await deps.db
|
|
388
|
-
.prepare(`SELECT id, resource_id, principal_type, principal_id, role, expires_at,
|
|
389
|
-
created_by, created_at FROM resource_grants WHERE id = ?`)
|
|
390
|
-
.bind(replayed)
|
|
391
|
-
.first()
|
|
392
|
-
: null;
|
|
393
|
-
if (row)
|
|
394
|
-
return mapGrant(row);
|
|
395
|
-
throw error;
|
|
396
|
-
}
|
|
397
|
-
},
|
|
398
|
-
async revokeGrant(input) {
|
|
399
|
-
try {
|
|
400
|
-
await deps.db.batch([
|
|
401
|
-
deps.db
|
|
402
|
-
.prepare(`INSERT INTO idempotency_keys (
|
|
403
|
-
actor_id, operation, idempotency_key, resource_id, created_at
|
|
404
|
-
) SELECT ?, 'flows.revoke', ?,
|
|
405
|
-
(CASE WHEN EXISTS (
|
|
406
|
-
SELECT 1 FROM resource_grants
|
|
407
|
-
WHERE id = ? AND resource_type = 'flow' AND resource_id = ?
|
|
408
|
-
) THEN '1:' ELSE '0:' END) || ?, ?`)
|
|
409
|
-
.bind(input.actorId, input.idempotencyKey, input.grantId, input.flowId, input.grantId, input.occurredAt),
|
|
410
|
-
deps.db
|
|
411
|
-
.prepare(`DELETE FROM resource_grants
|
|
412
|
-
WHERE id = ? AND resource_type = 'flow' AND resource_id = ?`)
|
|
413
|
-
.bind(input.grantId, input.flowId),
|
|
414
|
-
deps.db
|
|
415
|
-
.prepare(`INSERT INTO audit_events (
|
|
416
|
-
id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
|
|
417
|
-
) SELECT ?, ?, 'flows.revoke', 'flow', ?,
|
|
418
|
-
json_object(
|
|
419
|
-
'grantId', ?,
|
|
420
|
-
'revoked', json(CASE WHEN substr(resource_id, 1, 2) = '1:' THEN 'true' ELSE 'false' END)
|
|
421
|
-
), ?
|
|
422
|
-
FROM idempotency_keys
|
|
423
|
-
WHERE actor_id = ? AND operation = 'flows.revoke' AND idempotency_key = ?`)
|
|
424
|
-
.bind(input.auditId, input.actorId, input.flowId, input.grantId, input.occurredAt, input.actorId, input.idempotencyKey),
|
|
425
|
-
]);
|
|
426
|
-
}
|
|
427
|
-
catch (error) {
|
|
428
|
-
const replayed = await this.findIdempotentRevocation(input.actorId, input.idempotencyKey);
|
|
429
|
-
if (replayed === null)
|
|
430
|
-
throw error;
|
|
431
|
-
return replayed;
|
|
432
|
-
}
|
|
433
|
-
return (await this.findIdempotentRevocation(input.actorId, input.idempotencyKey)) ?? false;
|
|
434
|
-
},
|
|
435
484
|
async insertRun(input) {
|
|
436
485
|
const run = input.run;
|
|
437
486
|
try {
|
|
@@ -439,9 +488,9 @@ export function createFlowRepository(deps) {
|
|
|
439
488
|
deps.db
|
|
440
489
|
.prepare(`INSERT INTO flow_runs (
|
|
441
490
|
id, flow_id, version_id, status, current_node_id, input_json, output_json,
|
|
442
|
-
error, initiated_by, created_at, updated_at, completed_at
|
|
443
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
444
|
-
.bind(run.id, run.flowId, run.versionId, run.status, run.currentNodeId, JSON.stringify(run.input), null, run.error, run.initiatedBy, run.createdAt, run.updatedAt, run.completedAt),
|
|
491
|
+
error, initiated_by, parent_run_id, parent_node_id, created_at, updated_at, completed_at
|
|
492
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
493
|
+
.bind(run.id, run.flowId, run.versionId, run.status, run.currentNodeId, JSON.stringify(run.input), null, run.error, run.initiatedBy, run.parentRunId, run.parentNodeId, run.createdAt, run.updatedAt, run.completedAt),
|
|
445
494
|
deps.db
|
|
446
495
|
.prepare(`INSERT INTO idempotency_keys (
|
|
447
496
|
actor_id, operation, idempotency_key, resource_id, created_at
|
|
@@ -470,13 +519,138 @@ export function createFlowRepository(deps) {
|
|
|
470
519
|
},
|
|
471
520
|
async getRunVisible(actor, runId) {
|
|
472
521
|
const row = await deps.db
|
|
473
|
-
.prepare(
|
|
522
|
+
.prepare(`${subtreeCte}
|
|
523
|
+
SELECT ${qualifiedRunColumns("run")} FROM flow_runs run
|
|
524
|
+
JOIN flows flow ON flow.id = run.flow_id
|
|
525
|
+
WHERE run.id = ? AND ${runVisible}`)
|
|
526
|
+
.bind(...readableBindings(actor), runId, ...runVisibleBindings(actor))
|
|
527
|
+
.first();
|
|
528
|
+
return row ? mapRun(row) : null;
|
|
529
|
+
},
|
|
530
|
+
// Newest first, keyed on `(created_at, id)` so a page holds still while runs keep arriving —
|
|
531
|
+
// and so `flow_runs_flow_idx (flow_id, created_at DESC)` is the index that serves it.
|
|
532
|
+
//
|
|
533
|
+
// ⚠️ `runVisible`, the same predicate `getRunVisible` uses one function up. Seeing the flow is
|
|
534
|
+
// not the same as seeing its runs: a library flow is executable for everyone and readable by
|
|
535
|
+
// nobody, and the people running it must still find their own runs back.
|
|
536
|
+
async listRunsVisible(actor, input) {
|
|
537
|
+
const page = input.cursor
|
|
538
|
+
? {
|
|
539
|
+
clause: "AND (run.created_at < ? OR (run.created_at = ? AND run.id < ?))",
|
|
540
|
+
bindings: [input.cursor.createdAt, input.cursor.createdAt, input.cursor.id],
|
|
541
|
+
}
|
|
542
|
+
: { clause: "", bindings: [] };
|
|
543
|
+
const result = await deps.db
|
|
544
|
+
.prepare(`${subtreeCte}
|
|
545
|
+
SELECT ${qualifiedRunColumns("run")} FROM flow_runs run
|
|
546
|
+
JOIN flows flow ON flow.id = run.flow_id
|
|
547
|
+
WHERE run.flow_id = ? AND ${runVisible}
|
|
548
|
+
${input.failedOnly ? "AND run.status = 'failed'" : ""}
|
|
549
|
+
${page.clause}
|
|
550
|
+
ORDER BY run.created_at DESC, run.id DESC
|
|
551
|
+
LIMIT ?`)
|
|
552
|
+
.bind(...readableBindings(actor), input.flowId, ...runVisibleBindings(actor), ...page.bindings, input.limit)
|
|
553
|
+
.all();
|
|
554
|
+
return (result.results ?? []).map(mapRun);
|
|
555
|
+
},
|
|
556
|
+
// `idList` rather than a placeholder per run: a page of runs is as long as the page size, and
|
|
557
|
+
// a statement built from the input would carry its length into D1's parameter cap (#30).
|
|
558
|
+
async failedSteps(runIds) {
|
|
559
|
+
if (runIds.length === 0)
|
|
560
|
+
return [];
|
|
561
|
+
const result = await deps.db
|
|
562
|
+
.prepare(`SELECT run_id, node_id, outcome, branch, error, completed_at
|
|
563
|
+
FROM flow_run_steps
|
|
564
|
+
WHERE outcome = 'failed' AND run_id IN ${idList}`)
|
|
565
|
+
.bind(JSON.stringify(runIds))
|
|
566
|
+
.all();
|
|
567
|
+
return (result.results ?? []).map(mapStep);
|
|
568
|
+
},
|
|
569
|
+
async runSteps(runId) {
|
|
570
|
+
const result = await deps.db
|
|
571
|
+
.prepare(`SELECT run_id, node_id, outcome, branch, error, completed_at
|
|
572
|
+
FROM flow_run_steps WHERE run_id = ?
|
|
573
|
+
ORDER BY completed_at, id`)
|
|
574
|
+
.bind(runId)
|
|
575
|
+
.all();
|
|
576
|
+
return (result.results ?? []).map(mapStep);
|
|
577
|
+
},
|
|
578
|
+
// ⚠️ `runVisible` once more, for many call sites at once. The reason a call failed lives in the
|
|
579
|
+
// called run, and whether it may be repeated is that run's own question — asking it per row is
|
|
580
|
+
// what would put a query on every line of the list, and answering it with a second, cheaper rule
|
|
581
|
+
// is what #17, #19 and #20 were each sent back for.
|
|
582
|
+
//
|
|
583
|
+
// The pairs travel as one JSON value, like every other list here: a call site is two columns, so
|
|
584
|
+
// `IN` cannot express it and a placeholder pair per row would carry the page size into D1's
|
|
585
|
+
// parameter cap.
|
|
586
|
+
async visibleCallRuns(actor, sites) {
|
|
587
|
+
if (sites.length === 0)
|
|
588
|
+
return [];
|
|
589
|
+
const result = await deps.db
|
|
590
|
+
.prepare(`${subtreeCte},
|
|
591
|
+
sites(caller_id, caller_node) AS (
|
|
592
|
+
SELECT json_extract(site.value, '$.r'), json_extract(site.value, '$.n')
|
|
593
|
+
FROM json_each(?) site
|
|
594
|
+
)
|
|
595
|
+
SELECT run.parent_run_id AS caller_id, run.parent_node_id AS caller_node,
|
|
596
|
+
run.id AS called_id
|
|
597
|
+
FROM flow_runs run
|
|
474
598
|
JOIN flows flow ON flow.id = run.flow_id
|
|
475
|
-
|
|
476
|
-
|
|
599
|
+
JOIN sites ON sites.caller_id = run.parent_run_id
|
|
600
|
+
AND sites.caller_node = run.parent_node_id
|
|
601
|
+
WHERE ${runVisible}`)
|
|
602
|
+
.bind(...readableBindings(actor), JSON.stringify(sites.map((site) => ({ r: site.runId, n: site.nodeId }))), ...runVisibleBindings(actor))
|
|
603
|
+
.all();
|
|
604
|
+
return (result.results ?? []).map((row) => ({
|
|
605
|
+
runId: row.caller_id,
|
|
606
|
+
nodeId: row.caller_node,
|
|
607
|
+
calledRunId: row.called_id,
|
|
608
|
+
}));
|
|
609
|
+
},
|
|
610
|
+
// No ACL here on purpose: the caller has already been answered for the outer run, and the result
|
|
611
|
+
// of a call belongs to the step that made it. What it is used for is the step's output, never a
|
|
612
|
+
// route into the called flow — that goes through `getRunVisible` like everything else.
|
|
613
|
+
async findChildRun(parentRunId, parentNodeId) {
|
|
614
|
+
const row = await deps.db
|
|
615
|
+
.prepare(`SELECT ${runColumns} FROM flow_runs
|
|
616
|
+
WHERE parent_run_id = ? AND parent_node_id = ?`)
|
|
617
|
+
.bind(parentRunId, parentNodeId)
|
|
477
618
|
.first();
|
|
478
619
|
return row ? mapRun(row) : null;
|
|
479
620
|
},
|
|
621
|
+
// Both directions in one statement: every caller above the run, and the run each active subflow
|
|
622
|
+
// step below it started. `UNION` ends both walks even if a repair ever wrote a ring.
|
|
623
|
+
async runChain(runId) {
|
|
624
|
+
const result = await deps.db
|
|
625
|
+
.prepare(`WITH RECURSIVE up(id, parent_run_id) AS (
|
|
626
|
+
SELECT id, parent_run_id FROM flow_runs WHERE id = ?
|
|
627
|
+
UNION
|
|
628
|
+
SELECT parent.id, parent.parent_run_id
|
|
629
|
+
FROM flow_runs parent JOIN up ON up.parent_run_id = parent.id
|
|
630
|
+
),
|
|
631
|
+
down(id, current_node_id) AS (
|
|
632
|
+
SELECT id, current_node_id FROM flow_runs WHERE id = ?
|
|
633
|
+
UNION
|
|
634
|
+
SELECT child.id, child.current_node_id
|
|
635
|
+
FROM flow_runs child
|
|
636
|
+
JOIN down ON child.parent_run_id = down.id AND child.parent_node_id = down.current_node_id
|
|
637
|
+
)
|
|
638
|
+
SELECT run.id AS run_id, run.flow_id, flow.title AS flow_title, run.version_id,
|
|
639
|
+
run.parent_run_id, run.current_node_id
|
|
640
|
+
FROM flow_runs run
|
|
641
|
+
JOIN flows flow ON flow.id = run.flow_id
|
|
642
|
+
WHERE run.id IN (SELECT id FROM up) OR run.id IN (SELECT id FROM down)`)
|
|
643
|
+
.bind(runId, runId)
|
|
644
|
+
.all();
|
|
645
|
+
return (result.results ?? []).map((row) => ({
|
|
646
|
+
runId: row.run_id,
|
|
647
|
+
flowId: row.flow_id,
|
|
648
|
+
flowTitle: row.flow_title,
|
|
649
|
+
versionId: row.version_id,
|
|
650
|
+
parentRunId: row.parent_run_id,
|
|
651
|
+
currentNodeId: row.current_node_id,
|
|
652
|
+
}));
|
|
653
|
+
},
|
|
480
654
|
async advanceRun(input) {
|
|
481
655
|
const run = input.run;
|
|
482
656
|
try {
|