@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.
@@ -1,6 +1,19 @@
1
1
  import { Flow, FlowGraph, FlowRun, FlowVersion, } from "@anchrd/intel-contract";
2
- const flowColumns = `id, parent_id, title, description, owner_id, current_version_id,
3
- published_version_id, created_at, updated_at, archived_at`;
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
- function accessPredicate(roles = []) {
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
- function mapGrant(row) {
80
- let principal;
81
- switch (row.principal_type) {
82
- case "user":
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
- const visible = accessPredicate();
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
- // An absent `parentId` asks for every visible flow; `null` asks for the root of the shared
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(`SELECT ${flowColumns} FROM flows flow
118
- WHERE ${visible} AND flow.archived_at IS NULL ${scope.clause}
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(`SELECT ${flowColumns} FROM flows flow WHERE flow.id = ? AND ${visible}`)
127
- .bind(flowId, ...accessBindings(actor, deps.now().toISOString()))
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 canEdit(actor, flowId) {
156
+ async can(actor, flowId, verb) {
132
157
  const row = await deps.db
133
- .prepare(`SELECT 1 AS allowed FROM flows flow
134
- WHERE flow.id = ? AND ${accessPredicate(["editor", "manager"])}`)
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
- async canManage(actor, flowId) {
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(`SELECT 1 AS allowed FROM flows flow
142
- WHERE flow.id = ? AND ${accessPredicate(["manager"])}`)
143
- .bind(flowId, ...accessBindings(actor, deps.now().toISOString(), ["manager"]))
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
- return row?.allowed === 1;
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
- async getVersion(versionId) {
248
- const row = await deps.db
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(versionId)
252
- .first();
253
- return row ? mapVersion(row) : null;
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(`SELECT ${qualifiedRunColumns("run")} FROM flow_runs run
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
- WHERE run.id = ? AND ${visible}`)
476
- .bind(runId, ...accessBindings(actor, deps.now().toISOString()))
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 {