@anchrd/intel-api 0.3.1 → 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,3 +1,8 @@
1
+ import { nodeVerbBindings, nodeVerbQuery, subtreeBindings, subtreeCte } from "./db-grants.js";
2
+ const grantColumns = `id, node_id, principal_type, principal_id, verb, expires_at,
3
+ created_by, created_at`;
4
+ const linkColumns = `link.id, link.source_node_id, link.target_node_id, link.relation,
5
+ link.origin, link.label, link.created_by, link.created_at`;
1
6
  const nodeColumns = `n.id, n.parent_id, n.kind, n.title, n.description, n.context_policy,
2
7
  n.owner_id, n.current_version_id, n.created_at, n.updated_at, n.archived_at`;
3
8
  function mapNode(row) {
@@ -45,9 +50,9 @@ function mapGrant(row) {
45
50
  }
46
51
  return {
47
52
  id: row.id,
48
- resourceId: row.resource_id,
53
+ resourceId: row.node_id,
49
54
  principal,
50
- role: row.role,
55
+ verb: row.verb,
51
56
  expiresAt: row.expires_at,
52
57
  createdBy: row.created_by,
53
58
  createdAt: row.created_at,
@@ -59,6 +64,7 @@ function mapLink(row) {
59
64
  sourceNodeId: row.source_node_id,
60
65
  targetNodeId: row.target_node_id,
61
66
  relation: row.relation,
67
+ origin: row.origin,
62
68
  label: row.label,
63
69
  createdBy: row.created_by,
64
70
  createdAt: row.created_at,
@@ -72,40 +78,39 @@ function toFtsQuery(query) {
72
78
  .join(" AND ");
73
79
  }
74
80
  export function createKnowledgeRepository(deps) {
75
- const visibleCte = `WITH RECURSIVE allowed(id, parent_id) AS (
76
- SELECT seed.id, seed.parent_id
77
- FROM knowledge_nodes seed
78
- WHERE seed.owner_id = ?
79
- OR EXISTS (
80
- SELECT 1 FROM resource_grants grant_row
81
- WHERE grant_row.resource_type = 'knowledge'
82
- AND grant_row.resource_id = seed.id
83
- AND (
84
- (grant_row.principal_type = 'user' AND grant_row.principal_id = ?)
85
- OR (grant_row.principal_type = 'email' AND lower(grant_row.principal_id) = lower(?))
86
- OR (grant_row.principal_type = 'organization' AND grant_row.principal_id = '*')
87
- )
88
- AND (grant_row.expires_at IS NULL OR grant_row.expires_at > ?)
89
- )
90
- UNION
91
- SELECT child.id, child.parent_id
92
- FROM knowledge_nodes child
93
- JOIN allowed parent ON child.parent_id = parent.id
94
- )`;
81
+ // Reading is one verb among four; the tree walk itself lives in db-grants.ts so Flows asks the
82
+ // same question of the same table.
83
+ const visibleCte = subtreeCte;
84
+ const readBindings = (actor) => subtreeBindings(actor, "read", deps.now().toISOString());
85
+ /**
86
+ * One statement for the level the tree reads and for the bounded read the relation graph makes,
87
+ * so the visibility predicate cannot drift between them.
88
+ *
89
+ * ⚠️ In the bounded form `LIMIT` follows the `WHERE`, so the cut falls among the rows this actor
90
+ * may see and never before them, and `COUNT(*) OVER ()` counts those same rows alone (#30).
91
+ */
92
+ const visibleChildren = (bounded) => `${visibleCte}
93
+ SELECT ${nodeColumns}${bounded ? ", COUNT(*) OVER () AS total" : ""}
94
+ FROM knowledge_nodes n
95
+ JOIN allowed ON allowed.id = n.id
96
+ WHERE n.parent_id IS ? AND (? = 1 OR n.archived_at IS NULL)
97
+ ORDER BY CASE n.kind WHEN 'folder' THEN 0 ELSE 1 END, lower(n.title), n.id${bounded ? " LIMIT ?" : ""}`;
95
98
  return {
96
99
  async listVisible(actor, input) {
97
- const now = deps.now().toISOString();
98
100
  const result = await deps.db
99
- .prepare(`${visibleCte}
100
- SELECT ${nodeColumns}
101
- FROM knowledge_nodes n
102
- JOIN allowed ON allowed.id = n.id
103
- WHERE n.parent_id IS ? AND (? = 1 OR n.archived_at IS NULL)
104
- ORDER BY CASE n.kind WHEN 'folder' THEN 0 ELSE 1 END, lower(n.title), n.id`)
105
- .bind(actor.id, actor.id, actor.email, now, input.parentId, input.includeArchived ? 1 : 0)
101
+ .prepare(visibleChildren(false))
102
+ .bind(...readBindings(actor), input.parentId, input.includeArchived ? 1 : 0)
106
103
  .all();
107
104
  return (result.results ?? []).map(mapNode);
108
105
  },
106
+ async listVisibleBounded(actor, input) {
107
+ const result = await deps.db
108
+ .prepare(visibleChildren(true))
109
+ .bind(...readBindings(actor), input.parentId, 0, input.limit)
110
+ .all();
111
+ const rows = result.results ?? [];
112
+ return { items: rows.map(mapNode), total: rows[0]?.total ?? 0 };
113
+ },
109
114
  async getVisible(actor, nodeId) {
110
115
  const row = await deps.db
111
116
  .prepare(`${visibleCte}
@@ -113,65 +118,14 @@ export function createKnowledgeRepository(deps) {
113
118
  FROM knowledge_nodes n
114
119
  JOIN allowed ON allowed.id = n.id
115
120
  WHERE n.id = ?`)
116
- .bind(actor.id, actor.id, actor.email, deps.now().toISOString(), nodeId)
121
+ .bind(...readBindings(actor), nodeId)
117
122
  .first();
118
123
  return row ? mapNode(row) : null;
119
124
  },
120
- async canEdit(actor, nodeId) {
125
+ async can(actor, nodeId, verb) {
121
126
  const row = await deps.db
122
- .prepare(`WITH RECURSIVE ancestors(id, parent_id, owner_id) AS (
123
- SELECT id, parent_id, owner_id FROM knowledge_nodes WHERE id = ?
124
- UNION ALL
125
- SELECT parent.id, parent.parent_id, parent.owner_id
126
- FROM knowledge_nodes parent
127
- JOIN ancestors child ON child.parent_id = parent.id
128
- )
129
- SELECT 1 AS allowed
130
- FROM ancestors
131
- WHERE owner_id = ?
132
- OR EXISTS (
133
- SELECT 1 FROM resource_grants grant_row
134
- WHERE grant_row.resource_type = 'knowledge'
135
- AND grant_row.resource_id = ancestors.id
136
- AND (
137
- (grant_row.principal_type = 'user' AND grant_row.principal_id = ?)
138
- OR (grant_row.principal_type = 'email' AND lower(grant_row.principal_id) = lower(?))
139
- OR (grant_row.principal_type = 'organization' AND grant_row.principal_id = '*')
140
- )
141
- AND grant_row.role IN ('editor', 'manager')
142
- AND (grant_row.expires_at IS NULL OR grant_row.expires_at > ?)
143
- )
144
- LIMIT 1`)
145
- .bind(nodeId, actor.id, actor.id, actor.email, deps.now().toISOString())
146
- .first();
147
- return row?.allowed === 1;
148
- },
149
- async canManage(actor, nodeId) {
150
- const row = await deps.db
151
- .prepare(`WITH RECURSIVE ancestors(id, parent_id, owner_id) AS (
152
- SELECT id, parent_id, owner_id FROM knowledge_nodes WHERE id = ?
153
- UNION ALL
154
- SELECT parent.id, parent.parent_id, parent.owner_id
155
- FROM knowledge_nodes parent
156
- JOIN ancestors child ON child.parent_id = parent.id
157
- )
158
- SELECT 1 AS allowed
159
- FROM ancestors
160
- WHERE owner_id = ?
161
- OR EXISTS (
162
- SELECT 1 FROM resource_grants grant_row
163
- WHERE grant_row.resource_type = 'knowledge'
164
- AND grant_row.resource_id = ancestors.id
165
- AND (
166
- (grant_row.principal_type = 'user' AND grant_row.principal_id = ?)
167
- OR (grant_row.principal_type = 'email' AND lower(grant_row.principal_id) = lower(?))
168
- OR (grant_row.principal_type = 'organization' AND grant_row.principal_id = '*')
169
- )
170
- AND grant_row.role = 'manager'
171
- AND (grant_row.expires_at IS NULL OR grant_row.expires_at > ?)
172
- )
173
- LIMIT 1`)
174
- .bind(nodeId, actor.id, actor.id, actor.email, deps.now().toISOString())
127
+ .prepare(nodeVerbQuery)
128
+ .bind(...nodeVerbBindings(nodeId, actor, verb, deps.now().toISOString()))
175
129
  .first();
176
130
  return row?.allowed === 1;
177
131
  },
@@ -228,7 +182,7 @@ export function createKnowledgeRepository(deps) {
228
182
  FROM knowledge_nodes n
229
183
  JOIN allowed ON allowed.id = n.id
230
184
  WHERE n.id = ?`)
231
- .bind(input.actorId, input.actorId, "", deps.now().toISOString(), replayedKey.resource_id)
185
+ .bind(...readBindings({ id: input.actorId, email: "" }), replayedKey.resource_id)
232
186
  .first();
233
187
  if (replayed)
234
188
  return mapNode(replayed);
@@ -245,6 +199,22 @@ export function createKnowledgeRepository(deps) {
245
199
  .first();
246
200
  return row ? mapVersion(row) : null;
247
201
  },
202
+ async listVersionContentKeys(nodeId) {
203
+ const result = await deps.db
204
+ .prepare(`SELECT content_key FROM knowledge_versions
205
+ WHERE node_id = ? ORDER BY sequence`)
206
+ .bind(nodeId)
207
+ .all();
208
+ return (result.results ?? []).map((row) => row.content_key);
209
+ },
210
+ async firstVersionContentKey(nodeId) {
211
+ const row = await deps.db
212
+ .prepare(`SELECT content_key FROM knowledge_versions
213
+ WHERE node_id = ? ORDER BY sequence LIMIT 1`)
214
+ .bind(nodeId)
215
+ .first();
216
+ return row?.content_key ?? null;
217
+ },
248
218
  async listVersions(nodeId) {
249
219
  const result = await deps.db
250
220
  .prepare(`SELECT id, node_id, sequence, content_key, media_type, content_hash,
@@ -429,122 +399,191 @@ export function createKnowledgeRepository(deps) {
429
399
  .first();
430
400
  return row?.current_version_id === version.id ? "saved" : "conflict";
431
401
  },
432
- async listGrants(resourceId) {
433
- const result = await deps.db
434
- .prepare(`SELECT id, resource_id, principal_type, principal_id, role, expires_at,
435
- created_by, created_at
436
- FROM resource_grants
437
- WHERE resource_type = 'knowledge' AND resource_id = ?
438
- ORDER BY created_at, id`)
439
- .bind(resourceId)
440
- .all();
441
- return (result.results ?? []).map(mapGrant);
442
- },
443
- async listLinksVisible(actor, nodeId) {
444
- const result = await deps.db
445
- .prepare(`${visibleCte}
446
- SELECT link.id, link.source_node_id, link.target_node_id, link.relation, link.label,
447
- link.created_by, link.created_at
448
- FROM knowledge_links link
449
- JOIN allowed source_access ON source_access.id = link.source_node_id
450
- JOIN allowed target_access ON target_access.id = link.target_node_id
451
- WHERE link.source_node_id = ? OR link.target_node_id = ?
452
- ORDER BY link.created_at, link.id`)
453
- .bind(actor.id, actor.id, actor.email, deps.now().toISOString(), nodeId, nodeId)
454
- .all();
455
- return (result.results ?? []).map(mapLink);
456
- },
457
- async getLinkVisible(actor, linkId) {
458
- const row = await deps.db
459
- .prepare(`${visibleCte}
460
- SELECT link.id, link.source_node_id, link.target_node_id, link.relation, link.label,
461
- link.created_by, link.created_at
462
- FROM knowledge_links link
463
- JOIN allowed source_access ON source_access.id = link.source_node_id
464
- JOIN allowed target_access ON target_access.id = link.target_node_id
465
- WHERE link.id = ?`)
466
- .bind(actor.id, actor.id, actor.email, deps.now().toISOString(), linkId)
467
- .first();
468
- return row ? mapLink(row) : null;
469
- },
470
- async findLinkVisible(actor, input) {
471
- const row = await deps.db
472
- .prepare(`${visibleCte}
473
- SELECT link.id, link.source_node_id, link.target_node_id, link.relation, link.label,
474
- link.created_by, link.created_at
475
- FROM knowledge_links link
476
- JOIN allowed source_access ON source_access.id = link.source_node_id
477
- JOIN allowed target_access ON target_access.id = link.target_node_id
478
- WHERE link.source_node_id = ? AND link.target_node_id = ? AND link.relation = ?`)
479
- .bind(actor.id, actor.id, actor.email, deps.now().toISOString(), input.sourceNodeId, input.targetNodeId, input.relation)
402
+ /**
403
+ * One appended segment of a table (#40).
404
+ *
405
+ * ⚠️ The sequence is computed inside the INSERT, never read out and sent back in. Two appends
406
+ * that arrive together would otherwise both read the same `MAX(sequence)` and the second would
407
+ * lose to `UNIQUE (node_id, sequence)` the very loss the ticket forbids.
408
+ *
409
+ * ⚠️ `current_version_id` only ever moves forward. It names the newest segment, and indexing
410
+ * keys off it; letting a slower append pull it back would leave the index describing a table
411
+ * that is missing its last rows.
412
+ */
413
+ async appendTableVersion(input) {
414
+ const version = input.version;
415
+ const stored = async () => await deps.db
416
+ .prepare(`SELECT id, node_id, sequence, content_key, media_type, content_hash,
417
+ size, created_by, created_at FROM knowledge_versions WHERE id = ?`)
418
+ .bind(version.id)
480
419
  .first();
481
- return row ? mapLink(row) : null;
482
- },
483
- async insertLink(input) {
484
- const link = input.link;
485
420
  try {
486
421
  await deps.db.batch([
487
422
  deps.db
488
- .prepare(`INSERT INTO knowledge_links (
489
- id, source_node_id, target_node_id, relation, label, created_by, created_at
490
- ) VALUES (?, ?, ?, ?, ?, ?, ?)`)
491
- .bind(link.id, link.sourceNodeId, link.targetNodeId, link.relation, link.label, link.createdBy, link.createdAt),
423
+ .prepare(`INSERT INTO knowledge_versions (
424
+ id, node_id, sequence, content_key, media_type, content_hash, size,
425
+ created_by, created_at
426
+ ) SELECT ?, ?, COALESCE(MAX(sequence), 0) + 1, ?, ?, ?, ?, ?, ?
427
+ FROM knowledge_versions WHERE node_id = ?`)
428
+ .bind(version.id, version.nodeId, version.contentKey, version.mediaType, version.contentHash, version.size, version.createdBy, version.createdAt, version.nodeId),
429
+ deps.db
430
+ .prepare(`UPDATE knowledge_nodes
431
+ SET current_version_id = ?, updated_at = ?
432
+ WHERE id = ?
433
+ AND EXISTS (SELECT 1 FROM knowledge_versions WHERE id = ?)
434
+ AND (
435
+ current_version_id IS NULL
436
+ OR COALESCE((
437
+ SELECT sequence FROM knowledge_versions
438
+ WHERE id = knowledge_nodes.current_version_id
439
+ ), 0) < (SELECT sequence FROM knowledge_versions WHERE id = ?)
440
+ )`)
441
+ .bind(version.id, version.createdAt, version.nodeId, version.id, version.id),
492
442
  deps.db
493
443
  .prepare(`INSERT INTO idempotency_keys (
494
444
  actor_id, operation, idempotency_key, resource_id, created_at
495
- ) VALUES (?, 'knowledge.link', ?, ?, ?)`)
496
- .bind(input.actorId, input.idempotencyKey, link.id, link.createdAt),
445
+ ) SELECT ?, 'knowledge.append', ?, ?, ?
446
+ WHERE EXISTS (SELECT 1 FROM knowledge_versions WHERE id = ?)`)
447
+ .bind(input.actorId, input.idempotencyKey, version.id, version.createdAt, version.id),
448
+ deps.db
449
+ .prepare(`INSERT INTO knowledge_index_state (
450
+ version_id, status, attempt_count, last_error, updated_at
451
+ ) SELECT ?, 'pending', 0, NULL, ?
452
+ WHERE EXISTS (SELECT 1 FROM knowledge_versions WHERE id = ?)`)
453
+ .bind(version.id, version.createdAt, version.id),
497
454
  deps.db
498
455
  .prepare(`INSERT INTO audit_events (
499
456
  id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
500
- ) VALUES (?, ?, 'knowledge.link', 'knowledge', ?, ?, ?)`)
501
- .bind(input.auditId, input.actorId, link.sourceNodeId, JSON.stringify({
502
- linkId: link.id,
503
- targetNodeId: link.targetNodeId,
504
- relation: link.relation,
505
- }), link.createdAt),
457
+ ) SELECT ?, ?, 'knowledge.append', 'knowledge', ?, json_object(
458
+ 'versionId', ?, 'size', ?
459
+ ), ?
460
+ WHERE EXISTS (SELECT 1 FROM knowledge_versions WHERE id = ?)`)
461
+ .bind(input.auditId, input.actorId, version.nodeId, version.id, version.size, version.createdAt, version.id),
506
462
  ]);
507
- return link;
508
463
  }
509
464
  catch (error) {
465
+ // The same key twice at the same moment: the first write owns the row, and this call
466
+ // answers with it rather than with a failure the caller cannot act on.
510
467
  const replayed = await deps.db
511
468
  .prepare(`SELECT resource_id FROM idempotency_keys
512
- WHERE actor_id = ? AND operation = 'knowledge.link' AND idempotency_key = ?`)
469
+ WHERE actor_id = ? AND operation = 'knowledge.append' AND idempotency_key = ?`)
513
470
  .bind(input.actorId, input.idempotencyKey)
514
471
  .first();
515
- const row = await deps.db
516
- .prepare(`SELECT id, source_node_id, target_node_id, relation, label,
517
- created_by, created_at FROM knowledge_links
518
- WHERE id = ? OR (source_node_id = ? AND target_node_id = ? AND relation = ?)`)
519
- .bind(replayed?.resource_id ?? link.id, link.sourceNodeId, link.targetNodeId, link.relation)
520
- .first();
472
+ const row = replayed
473
+ ? await deps.db
474
+ .prepare(`SELECT id, node_id, sequence, content_key, media_type, content_hash,
475
+ size, created_by, created_at FROM knowledge_versions WHERE id = ?`)
476
+ .bind(replayed.resource_id)
477
+ .first()
478
+ : null;
521
479
  if (row)
522
- return mapLink(row);
480
+ return mapVersion(row);
523
481
  throw error;
524
482
  }
483
+ const row = await stored();
484
+ if (!row)
485
+ throw new Error("Knowledge table append did not store a version");
486
+ return mapVersion(row);
525
487
  },
526
- async deleteLink(input) {
527
- const existing = await deps.db
528
- .prepare("SELECT id FROM knowledge_links WHERE id = ? AND source_node_id = ?")
529
- .bind(input.linkId, input.sourceNodeId)
488
+ async listGrants(resourceId) {
489
+ const result = await deps.db
490
+ .prepare(`SELECT ${grantColumns} FROM tree_grants
491
+ WHERE node_id = ?
492
+ ORDER BY principal_type, principal_id, verb`)
493
+ .bind(resourceId)
494
+ .all();
495
+ return (result.results ?? []).map(mapGrant);
496
+ },
497
+ // ⚠️ `UNION`, never `UNION ALL`: a ring in `parent_id` must end the recursion rather than the
498
+ // database, the same reason every other walk of this tree gives.
499
+ async organizationExecuteReaches(nodeId, exceptGrantId) {
500
+ const row = await deps.db
501
+ .prepare(`WITH RECURSIVE ancestors(id, parent_id) AS (
502
+ SELECT id, parent_id FROM knowledge_nodes WHERE id = ?
503
+ UNION
504
+ SELECT parent.id, parent.parent_id
505
+ FROM knowledge_nodes parent
506
+ JOIN ancestors child ON child.parent_id = parent.id
507
+ )
508
+ SELECT 1 AS reaches
509
+ FROM tree_grants grant_row
510
+ JOIN ancestors ON ancestors.id = grant_row.node_id
511
+ WHERE grant_row.id <> ?
512
+ AND grant_row.principal_type = 'organization'
513
+ AND grant_row.verb = 'execute'
514
+ AND (grant_row.expires_at IS NULL OR grant_row.expires_at > ?)
515
+ LIMIT 1`)
516
+ .bind(nodeId, exceptGrantId, deps.now().toISOString())
530
517
  .first();
531
- const deleted = existing !== null;
518
+ return row?.reaches === 1;
519
+ },
520
+ async listLinksVisible(actor, nodeId) {
521
+ const result = await deps.db
522
+ .prepare(`${visibleCte}
523
+ SELECT ${linkColumns}
524
+ FROM knowledge_links link
525
+ JOIN allowed source_access ON source_access.id = link.source_node_id
526
+ JOIN allowed target_access ON target_access.id = link.target_node_id
527
+ WHERE link.source_node_id = ? OR link.target_node_id = ?
528
+ ORDER BY link.created_at, link.id`)
529
+ .bind(...readBindings(actor), nodeId, nodeId)
530
+ .all();
531
+ return (result.results ?? []).map(mapLink);
532
+ },
533
+ /**
534
+ * The titles of the nodes among these that this actor may see (#41).
535
+ *
536
+ * ⚠️ The same `allowed` walk as every other read, joined rather than filtered afterwards, and
537
+ * archived nodes are out. An unreachable or deleted target contributes no row at all — no
538
+ * placeholder, no id echoed back — because a row is already the statement "something is here".
539
+ */
540
+ async resolveVisibleTitles(actor, nodeIds) {
541
+ const uniqueIds = [...new Set(nodeIds)].slice(0, 200);
542
+ if (uniqueIds.length === 0)
543
+ return [];
544
+ const rows = [];
545
+ for (let offset = 0; offset < uniqueIds.length; offset += 96) {
546
+ const ids = uniqueIds.slice(offset, offset + 96);
547
+ const placeholders = ids.map(() => "?").join(", ");
548
+ const result = await deps.db
549
+ .prepare(`${visibleCte}
550
+ SELECT n.id, n.title
551
+ FROM knowledge_nodes n
552
+ JOIN allowed ON allowed.id = n.id
553
+ WHERE n.id IN (${placeholders}) AND n.archived_at IS NULL`)
554
+ .bind(...readBindings(actor), ...ids)
555
+ .all();
556
+ rows.push(...(result.results ?? []));
557
+ }
558
+ return rows.map((row) => ({ nodeId: row.id, title: row.title }));
559
+ },
560
+ /**
561
+ * The `text` links of one document, replaced wholesale by what the saved text says (#41).
562
+ *
563
+ * ⚠️ `origin = 'text'` in the delete, and nowhere else. Links made in the removed dialog are
564
+ * `manual`; saving a document must not take away a relationship somebody entered before the
565
+ * text became the place to write one.
566
+ *
567
+ * The whole set is written in one batch, so a save never leaves the graph half-rewritten. There
568
+ * is no idempotency row: the operation is a replacement, and replaying it writes the same set.
569
+ */
570
+ async replaceTextLinks(input) {
532
571
  await deps.db.batch([
533
572
  deps.db
534
- .prepare("DELETE FROM knowledge_links WHERE id = ? AND source_node_id = ?")
535
- .bind(input.linkId, input.sourceNodeId),
536
- deps.db
537
- .prepare(`INSERT INTO idempotency_keys (
538
- actor_id, operation, idempotency_key, resource_id, created_at
539
- ) VALUES (?, 'knowledge.unlink', ?, ?, ?)`)
540
- .bind(input.actorId, input.idempotencyKey, `${deleted ? "1" : "0"}:${input.linkId}`, input.occurredAt),
573
+ .prepare("DELETE FROM knowledge_links WHERE source_node_id = ? AND origin = 'text'")
574
+ .bind(input.sourceNodeId),
575
+ ...input.links.map((link) => deps.db
576
+ .prepare(`INSERT INTO knowledge_links (
577
+ id, source_node_id, target_node_id, relation, origin, label, created_by, created_at
578
+ ) VALUES (?, ?, ?, 'references', 'text', NULL, ?, ?)
579
+ ON CONFLICT (source_node_id, target_node_id, relation) DO NOTHING`)
580
+ .bind(link.id, input.sourceNodeId, link.targetNodeId, input.actorId, input.occurredAt)),
541
581
  deps.db
542
582
  .prepare(`INSERT INTO audit_events (
543
583
  id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
544
- ) VALUES (?, ?, 'knowledge.unlink', 'knowledge', ?, ?, ?)`)
545
- .bind(input.auditId, input.actorId, input.sourceNodeId, JSON.stringify({ linkId: input.linkId, deleted }), input.occurredAt),
584
+ ) VALUES (?, ?, 'knowledge.link', 'knowledge', ?, ?, ?)`)
585
+ .bind(input.auditId, input.actorId, input.sourceNodeId, JSON.stringify({ targetNodeIds: input.links.map((link) => link.targetNodeId) }), input.occurredAt),
546
586
  ]);
547
- return deleted;
548
587
  },
549
588
  async graphVisible(actor, input) {
550
589
  const nodeResult = await deps.db
@@ -555,7 +594,7 @@ export function createKnowledgeRepository(deps) {
555
594
  WHERE n.archived_at IS NULL
556
595
  ORDER BY lower(n.title), n.id
557
596
  LIMIT ?`)
558
- .bind(actor.id, actor.id, actor.email, deps.now().toISOString(), input.limit)
597
+ .bind(...readBindings(actor), input.limit)
559
598
  .all();
560
599
  const nodes = (nodeResult.results ?? []).map(mapNode);
561
600
  if (nodes.length === 0)
@@ -568,13 +607,12 @@ export function createKnowledgeRepository(deps) {
568
607
  ORDER BY lower(n.title), n.id
569
608
  LIMIT ?
570
609
  )
571
- SELECT link.id, link.source_node_id, link.target_node_id, link.relation, link.label,
572
- link.created_by, link.created_at
610
+ SELECT ${linkColumns}
573
611
  FROM knowledge_links link
574
612
  JOIN graph_nodes source_node ON source_node.id = link.source_node_id
575
613
  JOIN graph_nodes target_node ON target_node.id = link.target_node_id
576
614
  ORDER BY link.created_at, link.id`)
577
- .bind(actor.id, actor.id, actor.email, deps.now().toISOString(), input.limit)
615
+ .bind(...readBindings(actor), input.limit)
578
616
  .all();
579
617
  return { nodes, links: (linkResult.results ?? []).map(mapLink) };
580
618
  },
@@ -589,32 +627,31 @@ export function createKnowledgeRepository(deps) {
589
627
  try {
590
628
  await deps.db.batch([
591
629
  deps.db
592
- .prepare(`INSERT INTO resource_grants (
593
- id, resource_type, resource_id, principal_type, principal_id, role,
630
+ // The verb is part of the key, so re-granting the same verb only refreshes its expiry
631
+ // and never turns one verb into another.
632
+ .prepare(`INSERT INTO tree_grants (
633
+ id, node_id, principal_type, principal_id, verb,
594
634
  expires_at, created_by, created_at
595
- ) VALUES (?, 'knowledge', ?, ?, ?, ?, ?, ?, ?)
596
- ON CONFLICT (resource_type, resource_id, principal_type, principal_id)
597
- DO UPDATE SET role = excluded.role, expires_at = excluded.expires_at`)
598
- .bind(grant.id, grant.resourceId, principalType, principalId, grant.role, grant.expiresAt, grant.createdBy, grant.createdAt),
635
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
636
+ ON CONFLICT (node_id, principal_type, principal_id, verb)
637
+ DO UPDATE SET expires_at = excluded.expires_at`)
638
+ .bind(grant.id, grant.resourceId, principalType, principalId, grant.verb, grant.expiresAt, grant.createdBy, grant.createdAt),
599
639
  deps.db
600
640
  .prepare(`INSERT INTO idempotency_keys (
601
641
  actor_id, operation, idempotency_key, resource_id, created_at
602
- ) SELECT ?, 'knowledge.share', ?, id, ? FROM resource_grants
603
- WHERE resource_type = 'knowledge' AND resource_id = ?
604
- AND principal_type = ? AND principal_id = ?`)
605
- .bind(input.actorId, input.idempotencyKey, grant.createdAt, grant.resourceId, principalType, principalId),
642
+ ) SELECT ?, 'knowledge.share', ?, id, ? FROM tree_grants
643
+ WHERE node_id = ? AND principal_type = ? AND principal_id = ? AND verb = ?`)
644
+ .bind(input.actorId, input.idempotencyKey, grant.createdAt, grant.resourceId, principalType, principalId, grant.verb),
606
645
  deps.db
607
646
  .prepare(`INSERT INTO audit_events (
608
647
  id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
609
648
  ) VALUES (?, ?, 'knowledge.share', 'knowledge', ?, ?, ?)`)
610
- .bind(input.auditId, input.actorId, grant.resourceId, JSON.stringify({ principalType, role: grant.role }), grant.createdAt),
649
+ .bind(input.auditId, input.actorId, grant.resourceId, JSON.stringify({ principalType, verb: grant.verb }), grant.createdAt),
611
650
  ]);
612
651
  const stored = await deps.db
613
- .prepare(`SELECT id, resource_id, principal_type, principal_id, role, expires_at,
614
- created_by, created_at FROM resource_grants
615
- WHERE resource_type = 'knowledge' AND resource_id = ?
616
- AND principal_type = ? AND principal_id = ?`)
617
- .bind(grant.resourceId, principalType, principalId)
652
+ .prepare(`SELECT ${grantColumns} FROM tree_grants
653
+ WHERE node_id = ? AND principal_type = ? AND principal_id = ? AND verb = ?`)
654
+ .bind(grant.resourceId, principalType, principalId, grant.verb)
618
655
  .first();
619
656
  if (!stored)
620
657
  throw new Error("Knowledge grant disappeared after upsert");
@@ -628,8 +665,7 @@ export function createKnowledgeRepository(deps) {
628
665
  .first();
629
666
  if (replayed) {
630
667
  const row = await deps.db
631
- .prepare(`SELECT id, resource_id, principal_type, principal_id, role, expires_at,
632
- created_by, created_at FROM resource_grants WHERE id = ?`)
668
+ .prepare(`SELECT ${grantColumns} FROM tree_grants WHERE id = ?`)
633
669
  .bind(replayed.resource_id)
634
670
  .first();
635
671
  if (row)
@@ -646,13 +682,11 @@ export function createKnowledgeRepository(deps) {
646
682
  actor_id, operation, idempotency_key, resource_id, created_at
647
683
  ) SELECT ?, 'knowledge.revoke', ?,
648
684
  (CASE WHEN EXISTS (
649
- SELECT 1 FROM resource_grants
650
- WHERE id = ? AND resource_type = 'knowledge' AND resource_id = ?
685
+ SELECT 1 FROM tree_grants WHERE id = ? AND node_id = ?
651
686
  ) THEN '1:' ELSE '0:' END) || ?, ?`)
652
687
  .bind(input.actorId, input.idempotencyKey, input.grantId, input.resourceId, input.grantId, input.occurredAt),
653
688
  deps.db
654
- .prepare(`DELETE FROM resource_grants
655
- WHERE id = ? AND resource_type = 'knowledge' AND resource_id = ?`)
689
+ .prepare("DELETE FROM tree_grants WHERE id = ? AND node_id = ?")
656
690
  .bind(input.grantId, input.resourceId),
657
691
  deps.db
658
692
  .prepare(`INSERT INTO audit_events (
@@ -693,7 +727,7 @@ export function createKnowledgeRepository(deps) {
693
727
  AND n.archived_at IS NULL
694
728
  ORDER BY rank, n.updated_at DESC
695
729
  LIMIT ?`)
696
- .bind(actor.id, actor.id, actor.email, deps.now().toISOString(), query, input.limit)
730
+ .bind(...readBindings(actor), query, input.limit)
697
731
  .all();
698
732
  return (result.results ?? []).map((row) => ({
699
733
  nodeId: row.node_id,
@@ -724,7 +758,7 @@ export function createKnowledgeRepository(deps) {
724
758
  JOIN knowledge_fts ON knowledge_fts.version_id = v.id
725
759
  JOIN allowed ON allowed.id = n.id
726
760
  WHERE n.id IN (${placeholders}) AND n.archived_at IS NULL`)
727
- .bind(actor.id, actor.id, actor.email, deps.now().toISOString(), ...ids)
761
+ .bind(...readBindings(actor), ...ids)
728
762
  .all();
729
763
  rows.push(...(result.results ?? []));
730
764
  }
@@ -1,4 +1,34 @@
1
- import type { FlowGraph } from "@anchrd/intel-contract";
1
+ import type { FlowGraph, FlowNode } from "@anchrd/intel-contract";
2
2
  import type { CompiledFlow, FlowDeps, FlowService } from "./flows.types.js";
3
+ type SubflowNode = Extract<FlowNode, {
4
+ kind: "subflow";
5
+ }>;
6
+ type KnowledgeStepNode = Extract<FlowNode, {
7
+ kind: "knowledge";
8
+ }>;
9
+ type ToolStepNode = Extract<FlowNode, {
10
+ kind: "tool";
11
+ }>;
12
+ /**
13
+ * One place reads a graph for each kind of step it contains, and everything else is derived from
14
+ * these three. The publish-time rule, the freeze, the sidebar, the relation graph, the requirements
15
+ * list and the repository's cycle walk all ask about the same nodes; a second `kind === "…"` walk
16
+ * beside them would drift quietly, because both would keep returning something plausible.
17
+ */
18
+ export declare function subflowNodes(graph: FlowGraph): SubflowNode[];
19
+ export declare function knowledgeNodes(graph: FlowGraph): KnowledgeStepNode[];
20
+ export declare function toolNodes(graph: FlowGraph): ToolStepNode[];
21
+ export declare function calleeIds(graph: FlowGraph): string[];
22
+ /**
23
+ * The Knowledge documents a graph names and the tools it calls, flattened and without repetition.
24
+ * The requirements list, the publish-time check and the run's first tool check read this one answer,
25
+ * so they cannot disagree about what a flow touches. A caller that needs to know *which step* names
26
+ * a document reads the node lists above instead — the relation graph draws exactly that edge.
27
+ */
28
+ export declare function graphReferences(graph: FlowGraph): {
29
+ knowledge: string[];
30
+ tools: string[];
31
+ };
3
32
  export declare function compileFlow(graph: FlowGraph): CompiledFlow;
4
33
  export declare function createFlows(deps: FlowDeps): FlowService;
34
+ export {};