@anchrd/intel-api 0.3.0 → 0.3.2

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, 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,26 +34,11 @@ 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,
41
+ parentId: row.parent_id,
42
42
  title: row.title,
43
43
  description: row.description,
44
44
  ownerId: row.owner_id,
@@ -59,6 +59,25 @@ function mapVersion(row) {
59
59
  createdAt: row.created_at,
60
60
  });
61
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
+ }
62
81
  function mapRun(row) {
63
82
  return FlowRun.parse({
64
83
  id: row.id,
@@ -70,70 +89,214 @@ function mapRun(row) {
70
89
  output: row.output_json === null ? null : JSON.parse(row.output_json),
71
90
  error: row.error,
72
91
  initiatedBy: row.initiated_by,
92
+ parentRunId: row.parent_run_id,
93
+ parentNodeId: row.parent_node_id,
73
94
  createdAt: row.created_at,
74
95
  updatedAt: row.updated_at,
75
96
  completedAt: row.completed_at,
76
97
  });
77
98
  }
78
- function mapGrant(row) {
79
- let principal;
80
- switch (row.principal_type) {
81
- case "user":
82
- principal = { type: "user", id: row.principal_id };
83
- break;
84
- case "email":
85
- principal = { type: "email", email: row.principal_id };
86
- break;
87
- case "organization":
88
- principal = { type: "organization" };
89
- break;
90
- default:
91
- throw new Error("Unsupported flow grant principal type");
92
- }
93
- return {
94
- id: row.id,
95
- resourceId: row.resource_id,
96
- principal,
97
- role: row.role,
98
- expiresAt: row.expires_at,
99
- createdBy: row.created_by,
100
- createdAt: row.created_at,
101
- };
102
- }
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(?))";
103
103
  export function createFlowRepository(deps) {
104
- 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 ?" : ""}`;
105
129
  return {
106
- async listVisible(actor) {
130
+ async listVisible(actor, input = {}) {
131
+ const scope = scopeOf(input.parentId);
107
132
  const result = await deps.db
108
- .prepare(`SELECT ${flowColumns} FROM flows flow
109
- WHERE ${visible} AND flow.archived_at IS NULL
110
- ORDER BY lower(flow.title), flow.id`)
111
- .bind(...accessBindings(actor, deps.now().toISOString()))
133
+ .prepare(visibleFlows(scope.clause, false))
134
+ .bind(...readableBindings(actor), ...flowInSubtreeBindings(actor), ...scope.bindings)
112
135
  .all();
113
136
  return (result.results ?? []).map(mapFlow);
114
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
+ },
115
147
  async getVisible(actor, flowId) {
116
148
  const row = await deps.db
117
- .prepare(`SELECT ${flowColumns} FROM flows flow WHERE flow.id = ? AND ${visible}`)
118
- .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))
119
153
  .first();
120
154
  return row ? mapFlow(row) : null;
121
155
  },
122
- async canEdit(actor, flowId) {
156
+ async can(actor, flowId, verb) {
123
157
  const row = await deps.db
124
- .prepare(`SELECT 1 AS allowed FROM flows flow
125
- WHERE flow.id = ? AND ${accessPredicate(["editor", "manager"])}`)
126
- .bind(flowId, ...accessBindings(actor, deps.now().toISOString(), ["editor", "manager"]))
158
+ .prepare(flowVerbQuery)
159
+ .bind(...flowVerbBindings(flowId, actor, verb, deps.now().toISOString()))
127
160
  .first();
128
161
  return row?.allowed === 1;
129
162
  },
130
- 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";
131
193
  const row = await deps.db
132
- .prepare(`SELECT 1 AS allowed FROM flows flow
133
- WHERE flow.id = ? AND ${accessPredicate(["manager"])}`)
134
- .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())
135
211
  .first();
136
- 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
+ };
137
300
  },
138
301
  async findIdempotent(actorId, operation, key) {
139
302
  const row = await deps.db
@@ -143,26 +306,16 @@ export function createFlowRepository(deps) {
143
306
  .first();
144
307
  return row?.resource_id ?? null;
145
308
  },
146
- async findIdempotentRevocation(actorId, key) {
147
- const row = await deps.db
148
- .prepare(`SELECT resource_id FROM idempotency_keys
149
- WHERE actor_id = ? AND operation = 'flows.revoke' AND idempotency_key = ?`)
150
- .bind(actorId, key)
151
- .first();
152
- if (!row)
153
- return null;
154
- return row.resource_id.startsWith("1:");
155
- },
156
309
  async insertFlow(input) {
157
310
  const flow = input.flow;
158
311
  try {
159
312
  await deps.db.batch([
160
313
  deps.db
161
314
  .prepare(`INSERT INTO flows (
162
- id, title, description, owner_id, current_version_id, published_version_id,
315
+ id, parent_id, title, description, owner_id, current_version_id, published_version_id,
163
316
  created_at, updated_at, archived_at
164
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
165
- .bind(flow.id, flow.title, flow.description, flow.ownerId, flow.currentVersionId, flow.publishedVersionId, flow.createdAt, flow.updatedAt, flow.archivedAt),
317
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
318
+ .bind(flow.id, flow.parentId, flow.title, flow.description, flow.ownerId, flow.currentVersionId, flow.publishedVersionId, flow.createdAt, flow.updatedAt, flow.archivedAt),
166
319
  deps.db
167
320
  .prepare(`INSERT INTO idempotency_keys (
168
321
  actor_id, operation, idempotency_key, resource_id, created_at
@@ -189,13 +342,67 @@ export function createFlowRepository(deps) {
189
342
  throw error;
190
343
  }
191
344
  },
192
- async getVersion(versionId) {
345
+ // Renaming and moving touch the flow row and nothing else. `current_version_id` and
346
+ // `published_version_id` are deliberately absent from the SET list: organization must not be
347
+ // able to change what a flow does (ADR-0004).
348
+ async updateFlow(input) {
349
+ const flow = input.flow;
350
+ try {
351
+ await deps.db.batch([
352
+ deps.db
353
+ .prepare(`UPDATE flows SET parent_id = ?, title = ?, description = ?, updated_at = ?
354
+ WHERE id = ? AND updated_at = ?`)
355
+ .bind(flow.parentId, flow.title, flow.description, flow.updatedAt, flow.id, input.baseUpdatedAt),
356
+ deps.db
357
+ .prepare(`INSERT INTO idempotency_keys (
358
+ actor_id, operation, idempotency_key, resource_id, created_at
359
+ ) SELECT ?, 'flows.update', ?, ?, ?
360
+ WHERE EXISTS (
361
+ SELECT 1 FROM flows
362
+ WHERE id = ? AND parent_id IS ? AND title = ? AND description IS ? AND updated_at = ?
363
+ )`)
364
+ .bind(input.actorId, input.idempotencyKey, flow.id, flow.updatedAt, flow.id, flow.parentId, flow.title, flow.description, flow.updatedAt),
365
+ deps.db
366
+ .prepare(`INSERT INTO audit_events (
367
+ id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
368
+ ) SELECT ?, ?, 'flows.update', 'flow', ?, ?, ?
369
+ WHERE EXISTS (
370
+ SELECT 1 FROM idempotency_keys
371
+ WHERE actor_id = ? AND operation = 'flows.update'
372
+ AND idempotency_key = ? AND resource_id = ?
373
+ )`)
374
+ .bind(input.auditId, input.actorId, flow.id, JSON.stringify({ parentId: flow.parentId, title: flow.title }), flow.updatedAt, input.actorId, input.idempotencyKey, flow.id),
375
+ ]);
376
+ }
377
+ catch (error) {
378
+ if (!(await this.findIdempotent(input.actorId, "flows.update", input.idempotencyKey))) {
379
+ throw error;
380
+ }
381
+ }
382
+ const replayed = await this.findIdempotent(input.actorId, "flows.update", input.idempotencyKey);
383
+ if (!replayed)
384
+ return "conflict";
193
385
  const row = await deps.db
194
- .prepare(`SELECT id, flow_id, sequence, graph_json, created_by, created_at
195
- FROM flow_versions WHERE id = ?`)
196
- .bind(versionId)
386
+ .prepare(`SELECT ${flowColumns} FROM flows WHERE id = ?`)
387
+ .bind(replayed)
197
388
  .first();
198
- return row ? mapVersion(row) : null;
389
+ return row ? mapFlow(row) : "conflict";
390
+ },
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
398
+ .prepare(`SELECT id, flow_id, sequence, graph_json, created_by, created_at
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;
199
406
  },
200
407
  async insertVersion(input) {
201
408
  const version = input.version;
@@ -274,109 +481,6 @@ export function createFlowRepository(deps) {
274
481
  }
275
482
  return ((await this.findIdempotent(input.actorId, "flows.publish", input.idempotencyKey)) !== null);
276
483
  },
277
- async listGrants(flowId) {
278
- const result = await deps.db
279
- .prepare(`SELECT id, resource_id, principal_type, principal_id, role, expires_at,
280
- created_by, created_at FROM resource_grants
281
- WHERE resource_type = 'flow' AND resource_id = ?
282
- ORDER BY created_at, id`)
283
- .bind(flowId)
284
- .all();
285
- return (result.results ?? []).map(mapGrant);
286
- },
287
- async setGrant(input) {
288
- const grant = input.grant;
289
- const principalType = grant.principal.type;
290
- const principalId = grant.principal.type === "user"
291
- ? grant.principal.id
292
- : grant.principal.type === "email"
293
- ? grant.principal.email.toLowerCase()
294
- : "*";
295
- try {
296
- await deps.db.batch([
297
- deps.db
298
- .prepare(`INSERT INTO resource_grants (
299
- id, resource_type, resource_id, principal_type, principal_id, role,
300
- expires_at, created_by, created_at
301
- ) VALUES (?, 'flow', ?, ?, ?, ?, ?, ?, ?)
302
- ON CONFLICT (resource_type, resource_id, principal_type, principal_id)
303
- DO UPDATE SET role = excluded.role, expires_at = excluded.expires_at`)
304
- .bind(grant.id, grant.resourceId, principalType, principalId, grant.role, grant.expiresAt, grant.createdBy, grant.createdAt),
305
- deps.db
306
- .prepare(`INSERT INTO idempotency_keys (
307
- actor_id, operation, idempotency_key, resource_id, created_at
308
- ) SELECT ?, 'flows.share', ?, id, ? FROM resource_grants
309
- WHERE resource_type = 'flow' AND resource_id = ?
310
- AND principal_type = ? AND principal_id = ?`)
311
- .bind(input.actorId, input.idempotencyKey, grant.createdAt, grant.resourceId, principalType, principalId),
312
- deps.db
313
- .prepare(`INSERT INTO audit_events (
314
- id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
315
- ) VALUES (?, ?, 'flows.share', 'flow', ?, ?, ?)`)
316
- .bind(input.auditId, input.actorId, grant.resourceId, JSON.stringify({ principalType, role: grant.role }), grant.createdAt),
317
- ]);
318
- const stored = await deps.db
319
- .prepare(`SELECT id, resource_id, principal_type, principal_id, role, expires_at,
320
- created_by, created_at FROM resource_grants
321
- WHERE resource_type = 'flow' AND resource_id = ?
322
- AND principal_type = ? AND principal_id = ?`)
323
- .bind(grant.resourceId, principalType, principalId)
324
- .first();
325
- if (!stored)
326
- throw new Error("Flow grant disappeared after upsert");
327
- return mapGrant(stored);
328
- }
329
- catch (error) {
330
- const replayed = await this.findIdempotent(input.actorId, "flows.share", input.idempotencyKey);
331
- const row = replayed
332
- ? await deps.db
333
- .prepare(`SELECT id, resource_id, principal_type, principal_id, role, expires_at,
334
- created_by, created_at FROM resource_grants WHERE id = ?`)
335
- .bind(replayed)
336
- .first()
337
- : null;
338
- if (row)
339
- return mapGrant(row);
340
- throw error;
341
- }
342
- },
343
- async revokeGrant(input) {
344
- try {
345
- await deps.db.batch([
346
- deps.db
347
- .prepare(`INSERT INTO idempotency_keys (
348
- actor_id, operation, idempotency_key, resource_id, created_at
349
- ) SELECT ?, 'flows.revoke', ?,
350
- (CASE WHEN EXISTS (
351
- SELECT 1 FROM resource_grants
352
- WHERE id = ? AND resource_type = 'flow' AND resource_id = ?
353
- ) THEN '1:' ELSE '0:' END) || ?, ?`)
354
- .bind(input.actorId, input.idempotencyKey, input.grantId, input.flowId, input.grantId, input.occurredAt),
355
- deps.db
356
- .prepare(`DELETE FROM resource_grants
357
- WHERE id = ? AND resource_type = 'flow' AND resource_id = ?`)
358
- .bind(input.grantId, input.flowId),
359
- deps.db
360
- .prepare(`INSERT INTO audit_events (
361
- id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
362
- ) SELECT ?, ?, 'flows.revoke', 'flow', ?,
363
- json_object(
364
- 'grantId', ?,
365
- 'revoked', json(CASE WHEN substr(resource_id, 1, 2) = '1:' THEN 'true' ELSE 'false' END)
366
- ), ?
367
- FROM idempotency_keys
368
- WHERE actor_id = ? AND operation = 'flows.revoke' AND idempotency_key = ?`)
369
- .bind(input.auditId, input.actorId, input.flowId, input.grantId, input.occurredAt, input.actorId, input.idempotencyKey),
370
- ]);
371
- }
372
- catch (error) {
373
- const replayed = await this.findIdempotentRevocation(input.actorId, input.idempotencyKey);
374
- if (replayed === null)
375
- throw error;
376
- return replayed;
377
- }
378
- return (await this.findIdempotentRevocation(input.actorId, input.idempotencyKey)) ?? false;
379
- },
380
484
  async insertRun(input) {
381
485
  const run = input.run;
382
486
  try {
@@ -384,9 +488,9 @@ export function createFlowRepository(deps) {
384
488
  deps.db
385
489
  .prepare(`INSERT INTO flow_runs (
386
490
  id, flow_id, version_id, status, current_node_id, input_json, output_json,
387
- error, initiated_by, created_at, updated_at, completed_at
388
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
389
- .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),
390
494
  deps.db
391
495
  .prepare(`INSERT INTO idempotency_keys (
392
496
  actor_id, operation, idempotency_key, resource_id, created_at
@@ -415,13 +519,138 @@ export function createFlowRepository(deps) {
415
519
  },
416
520
  async getRunVisible(actor, runId) {
417
521
  const row = await deps.db
418
- .prepare(`SELECT ${qualifiedRunColumns("run")} FROM flow_runs run
522
+ .prepare(`${subtreeCte}
523
+ SELECT ${qualifiedRunColumns("run")} FROM flow_runs run
419
524
  JOIN flows flow ON flow.id = run.flow_id
420
- WHERE run.id = ? AND ${visible}`)
421
- .bind(runId, ...accessBindings(actor, deps.now().toISOString()))
525
+ WHERE run.id = ? AND ${runVisible}`)
526
+ .bind(...readableBindings(actor), runId, ...runVisibleBindings(actor))
422
527
  .first();
423
528
  return row ? mapRun(row) : null;
424
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
598
+ JOIN flows flow ON flow.id = run.flow_id
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)
618
+ .first();
619
+ return row ? mapRun(row) : null;
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
+ },
425
654
  async advanceRun(input) {
426
655
  const run = input.run;
427
656
  try {