@anchrd/intel-api 0.20.0 → 0.21.0

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.
@@ -649,16 +649,78 @@ export function createNodeRepository(deps) {
649
649
  },
650
650
  async countInboundLinks(nodeId) {
651
651
  const row = await deps.db
652
- .prepare("SELECT COUNT(*) AS n FROM node_links WHERE target_node_id = ?")
652
+ .prepare(`WITH RECURSIVE tree(id) AS (
653
+ SELECT id FROM nodes WHERE id = ?
654
+ UNION ALL SELECT child.id FROM nodes child JOIN tree parent ON child.parent_id = parent.id
655
+ ) SELECT COUNT(*) AS n FROM node_links
656
+ WHERE target_node_id IN (SELECT id FROM tree)
657
+ AND source_node_id NOT IN (SELECT id FROM tree)`)
653
658
  .bind(nodeId)
654
659
  .first();
655
660
  return row?.n ?? 0;
656
661
  },
662
+ async inspectPurgeTree(nodeId) {
663
+ const tree = `WITH RECURSIVE tree(id) AS (
664
+ SELECT id FROM nodes WHERE id = ?
665
+ UNION ALL SELECT child.id FROM nodes child JOIN tree parent ON child.parent_id = parent.id
666
+ )`;
667
+ const nodes = await deps.db
668
+ .prepare(`${tree} SELECT n.id, n.kind FROM nodes n JOIN tree ON tree.id = n.id`)
669
+ .bind(nodeId)
670
+ .all();
671
+ const nodeIds = (nodes.results ?? []).map((row) => row.id);
672
+ const flows = await deps.db
673
+ .prepare(`${tree} SELECT f.id FROM flows f WHERE f.parent_id IN (SELECT id FROM tree)`)
674
+ .bind(nodeId)
675
+ .all();
676
+ const keys = await deps.db
677
+ .prepare(`${tree} SELECT v.content_key FROM node_versions v WHERE v.node_id IN (SELECT id FROM tree)`)
678
+ .bind(nodeId)
679
+ .all();
680
+ const vectors = await deps.db
681
+ .prepare(`${tree} SELECT v.node_id, v.chunk_key FROM node_vectors v WHERE v.node_id IN (SELECT id FROM tree)`)
682
+ .bind(nodeId)
683
+ .all();
684
+ const grouped = new Map();
685
+ for (const row of vectors.results ?? []) {
686
+ const current = grouped.get(row.node_id) ?? [];
687
+ current.push(row.chunk_key);
688
+ grouped.set(row.node_id, current);
689
+ }
690
+ const counts = { flow: flows.results?.length ?? 0 };
691
+ for (const row of nodes.results ?? [])
692
+ counts[row.kind] = (counts[row.kind] ?? 0) + 1;
693
+ return {
694
+ nodeIds,
695
+ flowIds: (flows.results ?? []).map((row) => row.id),
696
+ contentKeys: [...new Set((keys.results ?? []).map((row) => row.content_key))],
697
+ vectorKeys: nodeIds.map((id) => ({ nodeId: id, keys: grouped.get(id) ?? [] })),
698
+ counts,
699
+ };
700
+ },
701
+ async findPurgeReplay(actorId, idempotencyKey) {
702
+ const row = await deps.db
703
+ .prepare(`SELECT title, content_keys_json FROM node_purge_receipts
704
+ WHERE actor_id = ? AND idempotency_key = ?`)
705
+ .bind(actorId, idempotencyKey)
706
+ .first();
707
+ return row ? { title: row.title, contentKeys: JSON.parse(row.content_keys_json) } : null;
708
+ },
709
+ async completePurgeReplay(actorId, idempotencyKey, completedAt) {
710
+ await deps.db
711
+ .prepare(`UPDATE node_purge_receipts SET completed_at = ?
712
+ WHERE actor_id = ? AND idempotency_key = ?`)
713
+ .bind(completedAt, actorId, idempotencyKey)
714
+ .run();
715
+ },
657
716
  async purgeNode(input) {
658
717
  // The keys BEFORE anything falls: after the batch there is no version row left to read them
659
718
  // from, and an object nobody can name is an object nobody can delete.
660
719
  const keys = await deps.db
661
- .prepare("SELECT content_key FROM node_versions WHERE node_id = ?")
720
+ .prepare(`WITH RECURSIVE tree(id) AS (
721
+ SELECT id FROM nodes WHERE id = ?
722
+ UNION ALL SELECT child.id FROM nodes child JOIN tree parent ON child.parent_id = parent.id
723
+ ) SELECT content_key FROM node_versions WHERE node_id IN (SELECT id FROM tree)`)
662
724
  .bind(input.nodeId)
663
725
  .all();
664
726
  // ⚠️ The guard belongs on EVERY statement, not only on the last one (found in review). A
@@ -669,6 +731,13 @@ export function createNodeRepository(deps) {
669
731
  // reader was told nothing had happened while the content was already gone. The audit row
670
732
  // carried the same condition, so it was not even booked.
671
733
  const alive = "EXISTS (SELECT 1 FROM nodes n WHERE n.id = ? AND n.archived_at IS NOT NULL)";
734
+ const tree = `WITH RECURSIVE tree(id) AS (
735
+ SELECT id FROM nodes WHERE id = ?
736
+ UNION ALL SELECT child.id FROM nodes child JOIN tree parent ON child.parent_id = parent.id
737
+ )`;
738
+ const treeFlows = `${tree}, tree_flows(id) AS (
739
+ SELECT id FROM flows WHERE parent_id IN (SELECT id FROM tree)
740
+ )`;
672
741
  const deleted = await deps.db.batch([
673
742
  // ⚠️ FIRST, and reading the title out of the row that falls at the end of this batch. Its
674
743
  // `WHERE EXISTS` is what makes it honest: no row, no entry — a purge that hit nothing does
@@ -680,40 +749,71 @@ export function createNodeRepository(deps) {
680
749
  json_patch(?, json_object('title', n.title, 'kind', n.kind)), ?
681
750
  FROM nodes n WHERE n.id = ? AND n.archived_at IS NOT NULL`)
682
751
  .bind(input.auditId, input.actorId, JSON.stringify(input.metadata), input.occurredAt, input.nodeId),
752
+ deps.db
753
+ .prepare(`INSERT INTO node_purge_receipts (
754
+ actor_id, idempotency_key, node_id, title, content_keys_json, completed_at
755
+ ) SELECT ?, ?, n.id, n.title, ?, NULL FROM nodes n
756
+ WHERE n.id = ? AND n.archived_at IS NOT NULL`)
757
+ .bind(input.actorId, input.idempotencyKey, JSON.stringify([...new Set((keys.results ?? []).map((row) => row.content_key))]), input.nodeId),
683
758
  // The derived indexes go with their source. ⚠️ A hit on a deleted document is not a blemish
684
759
  // but a leak: the title stands in the result and the content is gone.
685
760
  deps.db
686
- .prepare(`DELETE FROM node_fts WHERE node_id = ? AND ${alive}`)
761
+ .prepare(`${tree} DELETE FROM node_fts WHERE node_id IN (SELECT id FROM tree) AND ${alive}`)
687
762
  .bind(input.nodeId, input.nodeId),
688
763
  deps.db
689
- .prepare(`DELETE FROM node_vectors WHERE node_id = ? AND ${alive}`)
764
+ .prepare(`${tree} DELETE FROM node_vectors WHERE node_id IN (SELECT id FROM tree) AND ${alive}`)
690
765
  .bind(input.nodeId, input.nodeId),
691
766
  deps.db
692
- .prepare(`DELETE FROM node_index_state
693
- WHERE version_id IN (SELECT id FROM node_versions WHERE node_id = ?) AND ${alive}`)
767
+ .prepare(`${tree} DELETE FROM node_index_state
768
+ WHERE version_id IN (SELECT id FROM node_versions WHERE node_id IN (SELECT id FROM tree)) AND ${alive}`)
694
769
  .bind(input.nodeId, input.nodeId),
695
770
  // Both directions. Outgoing links belong to this node; incoming ones would otherwise point
696
771
  // at a row that is gone, and a foreign key would refuse the delete anyway.
697
772
  deps.db
698
- .prepare(`DELETE FROM node_links
699
- WHERE (source_node_id = ? OR target_node_id = ?) AND ${alive}`)
700
- .bind(input.nodeId, input.nodeId, input.nodeId),
773
+ .prepare(`${tree} DELETE FROM node_links
774
+ WHERE (source_node_id IN (SELECT id FROM tree) OR target_node_id IN (SELECT id FROM tree)) AND ${alive}`)
775
+ .bind(input.nodeId, input.nodeId),
701
776
  // A permission on a node that does not exist is a dangling permission.
702
777
  deps.db
703
- .prepare(`DELETE FROM node_grants WHERE node_id = ? AND ${alive}`)
778
+ .prepare(`${tree} DELETE FROM node_grants WHERE node_id IN (SELECT id FROM tree) AND ${alive}`)
779
+ .bind(input.nodeId, input.nodeId),
780
+ deps.db
781
+ .prepare(`${treeFlows} UPDATE flows SET published_version_id = NULL
782
+ WHERE id NOT IN (SELECT id FROM tree_flows) AND EXISTS (
783
+ SELECT 1 FROM flow_versions fv, json_tree(fv.graph_json) graph
784
+ WHERE fv.id IN (flows.current_version_id, flows.published_version_id)
785
+ AND ((graph.key = 'resourceId' AND graph.value IN (SELECT id FROM tree))
786
+ OR (graph.key = 'flowId' AND graph.value IN (SELECT id FROM tree_flows)))
787
+ ) AND ${alive}`)
788
+ .bind(input.nodeId, input.nodeId),
789
+ deps.db
790
+ .prepare(`${treeFlows} DELETE FROM flow_run_steps
791
+ WHERE run_id IN (SELECT id FROM flow_runs WHERE flow_id IN (SELECT id FROM tree_flows)) AND ${alive}`)
792
+ .bind(input.nodeId, input.nodeId),
793
+ deps.db
794
+ .prepare(`${treeFlows} DELETE FROM flow_runs WHERE flow_id IN (SELECT id FROM tree_flows) AND ${alive}`)
795
+ .bind(input.nodeId, input.nodeId),
796
+ deps.db
797
+ .prepare(`${treeFlows} DELETE FROM flow_versions WHERE flow_id IN (SELECT id FROM tree_flows) AND ${alive}`)
798
+ .bind(input.nodeId, input.nodeId),
799
+ deps.db
800
+ .prepare(`${treeFlows} DELETE FROM idempotency_keys WHERE resource_id IN (SELECT id FROM tree_flows) AND ${alive}`)
801
+ .bind(input.nodeId, input.nodeId),
802
+ deps.db
803
+ .prepare(`${treeFlows} DELETE FROM flows WHERE id IN (SELECT id FROM tree_flows) AND ${alive}`)
704
804
  .bind(input.nodeId, input.nodeId),
705
805
  deps.db
706
- .prepare(`DELETE FROM node_versions WHERE node_id = ? AND ${alive}`)
806
+ .prepare(`${tree} DELETE FROM node_versions WHERE node_id IN (SELECT id FROM tree) AND ${alive}`)
707
807
  .bind(input.nodeId, input.nodeId),
708
808
  // ⚠️ The replay keys of every earlier write to this node. Left behind, a later write reusing
709
809
  // one of them would be answered with a resource id that no longer resolves.
710
810
  deps.db
711
- .prepare(`DELETE FROM idempotency_keys WHERE resource_id = ? AND ${alive}`)
811
+ .prepare(`${tree} DELETE FROM idempotency_keys WHERE resource_id IN (SELECT id FROM tree) AND ${alive}`)
712
812
  .bind(input.nodeId, input.nodeId),
713
813
  // LAST — and its count is what the caller reads as the verdict.
714
814
  deps.db
715
- .prepare("DELETE FROM nodes WHERE id = ? AND archived_at IS NOT NULL")
716
- .bind(input.nodeId),
815
+ .prepare(`${tree} DELETE FROM nodes WHERE id IN (SELECT id FROM tree) AND ${alive}`)
816
+ .bind(input.nodeId, input.nodeId),
717
817
  ]);
718
818
  const removed = deleted.at(-1)?.meta?.changes ?? 0;
719
819
  if (removed === 0)
@@ -913,6 +1013,42 @@ export function createNodeRepository(deps) {
913
1013
  .all();
914
1014
  return (result.results ?? []).map(mapGrant);
915
1015
  },
1016
+ async listEffectiveAccess(resourceId) {
1017
+ // Owners and grants come from ONE statement, hence one D1 snapshot. Two parallel SELECTs are
1018
+ // not atomic: a grant mutation between their snapshots can produce a principal list that
1019
+ // never existed as a whole.
1020
+ const row = await deps.db
1021
+ .prepare(`WITH RECURSIVE ancestors(id, parent_id, owner_id) AS (
1022
+ SELECT id, parent_id, owner_id FROM nodes WHERE id = ?
1023
+ UNION
1024
+ SELECT parent.id, parent.parent_id, parent.owner_id
1025
+ FROM nodes parent
1026
+ JOIN ancestors child ON child.parent_id = parent.id
1027
+ )
1028
+ SELECT
1029
+ (SELECT json_group_array(owner_id)
1030
+ FROM (SELECT DISTINCT owner_id FROM ancestors ORDER BY owner_id)) AS owner_ids_json,
1031
+ (SELECT json_group_array(json_object(
1032
+ 'id', id, 'node_id', node_id, 'principal_type', principal_type,
1033
+ 'principal_id', principal_id, 'verb', verb, 'expires_at', expires_at,
1034
+ 'created_by', created_by, 'created_at', created_at
1035
+ ))
1036
+ FROM (
1037
+ SELECT grant_row.id, grant_row.node_id, grant_row.principal_type,
1038
+ grant_row.principal_id, grant_row.verb, grant_row.expires_at,
1039
+ grant_row.created_by, grant_row.created_at
1040
+ FROM node_grants grant_row
1041
+ JOIN ancestors ON ancestors.id = grant_row.node_id
1042
+ WHERE grant_row.expires_at IS NULL OR grant_row.expires_at > ?
1043
+ ORDER BY grant_row.principal_type, grant_row.principal_id, grant_row.verb
1044
+ )) AS grants_json`)
1045
+ .bind(resourceId, deps.now().toISOString())
1046
+ .first();
1047
+ return {
1048
+ ownerIds: JSON.parse(row?.owner_ids_json ?? "[]"),
1049
+ items: JSON.parse(row?.grants_json ?? "[]").map(mapGrant),
1050
+ };
1051
+ },
916
1052
  // ⚠️ `UNION`, never `UNION ALL`: a ring in `parent_id` must end the recursion rather than the
917
1053
  // database, the same reason every other walk of this tree gives.
918
1054
  async organizationExecuteReaches(nodeId, exceptGrantId) {
package/dist/http/http.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { ArchiveFlowInput, CreateFlowInput, GetFlowInput, GetFlowVersionInput, ListFlowsInput, PreviewFlowPublishInput, PublishFlowInput, PurgeFlowInput, RelationGraphInput, SaveFlowVersionInput, UnpublishFlowInput, UpdateFlowInput, } from "@anchrd/intel-contract/flow";
2
2
  import { CancelFlowRunInput, CompleteFlowRunStepInput, GetFlowRunInput, ListFlowRunsInput, StartFlowRunInput, } from "@anchrd/intel-contract/flow-run";
3
- import { ArchiveNodeInput, CreateNodeInput, GetNodeInput, GetNodeVersionInput, ListNodesInput, NodeGraphInput, PurgeNodeInput, ResolveNodeLinksInput, SaveAttachmentInput, SaveNodeVersionInput, SearchInput, UpdateNodeInput, } from "@anchrd/intel-contract/node";
3
+ import { ArchiveNodeInput, CreateNodeInput, GetNodeInput, GetNodeVersionInput, ListNodesInput, NodeGraphInput, PurgeNodeInput, PurgeNodePreviewInput, ResolveNodeLinksInput, SaveAttachmentInput, SaveNodeVersionInput, SearchInput, UpdateNodeInput, } from "@anchrd/intel-contract/node";
4
4
  import { RevokeGrantInput, ShareInput } from "@anchrd/intel-contract/share";
5
5
  import { AppendTableRowsInput, DefineTableInput, DeleteTableRowsInput, RedefineTableInput, UpdateTableRowsInput, } from "@anchrd/intel-contract/table";
6
6
  import { ExecuteToolInput, TestToolInput } from "@anchrd/intel-contract/tool";
@@ -366,13 +366,25 @@ export function createHttp(deps) {
366
366
  // two steps and the first one is reversible.
367
367
  app.delete("/nodes/:nodeId", async (context) => {
368
368
  const auth = requireCapability(context, "nodes", "write");
369
- const input = PurgeNodeInput.parse({ nodeId: context.req.param("nodeId") });
369
+ const input = PurgeNodeInput.parse(await context.req.json().catch(() => null));
370
+ if (input.nodeId !== context.req.param("nodeId")) {
371
+ throw new IntelError(400, "node_id_mismatch", "Path and body node IDs differ");
372
+ }
370
373
  return context.json(await deps.nodes.purge(asActor(auth), input));
371
374
  });
375
+ app.get("/nodes/:nodeId/purge-preview", async (context) => {
376
+ const auth = requireCapability(context, "nodes", "write");
377
+ const input = PurgeNodePreviewInput.parse({ nodeId: context.req.param("nodeId") });
378
+ return context.json(await deps.nodes.purgePreview(asActor(auth), input));
379
+ });
372
380
  app.get("/nodes/:nodeId/grants", async (context) => {
373
381
  const auth = requireCapability(context, "nodes", "share");
374
382
  return context.json(await deps.nodes.listGrants(asActor(auth), context.req.param("nodeId")));
375
383
  });
384
+ app.get("/nodes/:nodeId/effective-access", async (context) => {
385
+ const auth = requireCapability(context, "nodes", "share");
386
+ return context.json(await deps.nodes.listEffectiveAccess(asActor(auth), context.req.param("nodeId")));
387
+ });
376
388
  app.post("/nodes/:nodeId/grants", async (context) => {
377
389
  const auth = requireCapability(context, "nodes", "share");
378
390
  const input = ShareInput.parse(await context.req.json().catch(() => null));
@@ -381,6 +381,20 @@ export function createNodes(deps) {
381
381
  async get(actor, nodeId) {
382
382
  return await getDocument(await requireVisible(actor, nodeId));
383
383
  },
384
+ async purgePreview(actor, input) {
385
+ const node = await requireVisible(actor, input.nodeId);
386
+ if (!(await deps.repository.can(actor, input.nodeId, "write"))) {
387
+ throw new IntelError(403, "node_forbidden", "This node cannot be edited");
388
+ }
389
+ if (!node.archivedAt) {
390
+ throw new IntelError(409, "node_not_archived", "Only an archived node can be deleted for good. Archive it first.");
391
+ }
392
+ const tree = await deps.repository.inspectPurgeTree(node.id);
393
+ return {
394
+ inboundLinks: await deps.repository.countInboundLinks(node.id),
395
+ totalItems: tree.nodeIds.length + tree.flowIds.length,
396
+ };
397
+ },
384
398
  /**
385
399
  * The content of one pinned version (#147). Search citations pin the version they quoted, and
386
400
  * without this read a citation could name text no surface can show any more.
@@ -855,6 +869,14 @@ export function createNodes(deps) {
855
869
  * chosen to the safer side.
856
870
  */
857
871
  async purge(actor, input) {
872
+ const idempotencyKey = `${input.nodeId}:${input.idempotencyKey}`;
873
+ const replay = await deps.repository.findPurgeReplay(actor.id, idempotencyKey);
874
+ if (replay) {
875
+ for (const key of replay.contentKeys)
876
+ await deps.content.delete(key);
877
+ await deps.repository.completePurgeReplay(actor.id, idempotencyKey, deps.now().toISOString());
878
+ return { purged: true, title: replay.title };
879
+ }
858
880
  const node = await requireVisible(actor, input.nodeId);
859
881
  if (!(await deps.repository.can(actor, input.nodeId, "write"))) {
860
882
  throw new IntelError(403, "node_forbidden", "This node cannot be edited");
@@ -866,28 +888,14 @@ export function createNodes(deps) {
866
888
  if (!node.archivedAt) {
867
889
  throw new IntelError(409, "node_not_archived", "Only an archived node can be deleted for good. Archive it first.");
868
890
  }
869
- // A folder is refused while anything is still inside archived rows included. A cascade
870
- // would turn ONE confirmed click into an unknown number of deletions, and the confirmation
871
- // could then no longer name what disappears. `folder_execute_in_use` (#448) is the pattern:
872
- // an answer with a reason beats an effect that reaches further than the question.
873
- if (await deps.repository.hasAnyChild(node.id)) {
874
- throw new IntelError(409, "folder_not_empty", "This folder still holds entries. Empty it before deleting it for good.");
875
- }
876
- // ⚠️ The other class of dependant, and the reason it refuses where a prose link does not: a
877
- // flow would break at RUN TIME, in front of somebody who did not order this deletion.
878
- //
879
- // ⚠️ `flowsUsingNode`, NOT `externalFlowCallers` — the first version asked the second and
880
- // therefore never refused anything (found in review). `externalFlowCallers` answers "who calls
881
- // a FLOW filed inside this folder" (`kind: "subflow"`); a flow reads a NODE through a
882
- // tree-link step (`configuration.resourceId`). Two different graph shapes, and only the second
883
- // one is what a deleted document breaks.
884
- const users = await deps.flowsUsingNode(actor, node.id);
885
- if (users.visible.length || users.hidden) {
886
- throw new IntelError(409, "node_in_use_by_flow", callersDetail(users));
887
- }
891
+ // #492 replaces the refusal with owned cleanup. Flows inside the tree disappear with it;
892
+ // published flows outside it are depublished atomically before their dependency vanishes.
893
+ const tree = await deps.repository.inspectPurgeTree(node.id);
888
894
  // Not a refusal — a fact for the record. A prose reference is a mention, not an operation;
889
- // refusing over one would make deleting a lottery, decided by whoever once linked to it. The
890
- // surface names the number BEFORE the reader says yes.
895
+ // refusing over one would make deleting a lottery, decided by whoever once linked to it.
896
+ //
897
+ // The same count is exposed by `purgePreview`, so the surface can name the consequence before
898
+ // confirmation. It is counted again here because the audit records the state at deletion time.
891
899
  const inboundLinks = await deps.repository.countInboundLinks(node.id);
892
900
  // ⚠️ The vectors go FIRST, and the order is the correction of a wrong claim (found in review).
893
901
  // The comment here used to say Vectorize needed no call because archiving had already emptied
@@ -901,11 +909,14 @@ export function createNodes(deps) {
901
909
  // the rule behind both is the same: DO THE IRRECOVERABLE THING LAST. A failed batch after the
902
910
  // vectors are gone leaves an ARCHIVED node whose index can be rebuilt (`reindex`); a failed
903
911
  // removal after the batch leaves orphans nothing can name.
904
- const vectorKeys = await deps.repository.listVectorKeys(node.id);
912
+ const vectorKeys = tree.vectorKeys;
905
913
  // The whole-node chunk carries the empty key and is never in the table — the pass writes it
906
914
  // and records the rest, so a purge that only sent the recorded ones would leave it behind.
907
- if (deps.semantic)
908
- await deps.semantic.remove(node.id, ["", ...vectorKeys]);
915
+ if (deps.semantic) {
916
+ for (const vector of vectorKeys) {
917
+ await deps.semantic.remove(vector.nodeId, ["", ...vector.keys]);
918
+ }
919
+ }
909
920
  const purged = await deps.repository.purgeNode({
910
921
  nodeId: node.id,
911
922
  actorId: actor.id,
@@ -913,7 +924,8 @@ export function createNodes(deps) {
913
924
  occurredAt: deps.now().toISOString(),
914
925
  // ⚠️ The title travels INTO the record from the row itself (the repository reads it there),
915
926
  // and this is what travels beside it. After the delete nothing can look either up.
916
- metadata: { inboundLinks },
927
+ metadata: { inboundLinks, counts: tree.counts },
928
+ idempotencyKey,
917
929
  });
918
930
  // The row was restored or already gone between the check and the statement. Nothing was
919
931
  // deleted, and saying so is better than reporting a success that did not happen.
@@ -921,13 +933,13 @@ export function createNodes(deps) {
921
933
  throw new IntelError(409, "update_conflict", "This node was changed by another editor");
922
934
  }
923
935
  // ⚠️ AFTER the database agreed, never before: it is the truth about what exists, and an object
924
- // deleted ahead of a batch that then fails would leave a node whose content is gone.
925
- //
926
- // ⚠️ Vectorize needs no call here, and that follows from the guard above rather than from
927
- // luck: archiving already takes a node's vectors out of the index (see `archive`), and only
928
- // an archived node reaches this line. What is emptied above is Intel's own bookkeeping.
936
+ // deleted ahead of a batch that then fails would leave a node whose content is gone. This is
937
+ // the same rule the vectors follow in the other direction — do the irrecoverable thing last —
938
+ // and the directions differ because only one of the two can be rebuilt: an index can, an
939
+ // object cannot.
929
940
  for (const key of purged.contentKeys)
930
941
  await deps.content.delete(key);
942
+ await deps.repository.completePurgeReplay(actor.id, idempotencyKey, deps.now().toISOString());
931
943
  return { purged: true, title: node.title };
932
944
  },
933
945
  async listGrants(actor, resourceId) {
@@ -941,6 +953,14 @@ export function createNodes(deps) {
941
953
  items: await deps.repository.listGrants(resourceId),
942
954
  };
943
955
  },
956
+ async listEffectiveAccess(actor, resourceId) {
957
+ const node = await requireVisible(actor, resourceId);
958
+ if (!(await deps.repository.can(actor, resourceId, "share"))) {
959
+ throw new IntelError(403, "node_forbidden", "Sharing of this node cannot be managed");
960
+ }
961
+ const effective = await deps.repository.listEffectiveAccess(resourceId);
962
+ return { resourceId: node.id, ...effective };
963
+ },
944
964
  async listLinks(actor, nodeId) {
945
965
  await requireVisible(actor, nodeId);
946
966
  return { items: await deps.repository.listLinksVisible(actor, nodeId) };
@@ -87,16 +87,36 @@ export interface NodeRepository {
87
87
  hasAnyChild(nodeId: string): Promise<boolean>;
88
88
  listVectorKeys(nodeId: string): Promise<string[]>;
89
89
  countInboundLinks(nodeId: string): Promise<number>;
90
+ inspectPurgeTree(nodeId: string): Promise<{
91
+ nodeIds: string[];
92
+ flowIds: string[];
93
+ contentKeys: string[];
94
+ vectorKeys: Array<{
95
+ nodeId: string;
96
+ keys: string[];
97
+ }>;
98
+ counts: Record<string, number>;
99
+ }>;
100
+ findPurgeReplay(actorId: string, idempotencyKey: string): Promise<{
101
+ title: string;
102
+ contentKeys: string[];
103
+ } | null>;
104
+ completePurgeReplay(actorId: string, idempotencyKey: string, completedAt: string): Promise<void>;
90
105
  purgeNode(input: {
91
106
  nodeId: string;
92
107
  actorId: string;
93
108
  auditId: string;
94
109
  occurredAt: string;
95
110
  metadata: Record<string, unknown>;
111
+ idempotencyKey: string;
96
112
  }): Promise<"missing" | {
97
113
  contentKeys: string[];
98
114
  }>;
99
115
  listGrants(resourceId: string): Promise<ResourceGrant[]>;
116
+ listEffectiveAccess(resourceId: string): Promise<{
117
+ ownerIds: string[];
118
+ items: ResourceGrant[];
119
+ }>;
100
120
  organizationExecuteReaches(nodeId: string, exceptGrantId: string): Promise<boolean>;
101
121
  listLinksVisible(actor: Actor, nodeId: string): Promise<NodeLink[]>;
102
122
  resolveVisibleTitles(actor: Actor, nodeIds: string[]): Promise<Array<{
@@ -287,11 +307,19 @@ export interface NodeService {
287
307
  archive(actor: Actor, input: ArchiveNodeInput): Promise<Node>;
288
308
  purge(actor: Actor, input: {
289
309
  nodeId: string;
310
+ idempotencyKey: string;
290
311
  }): Promise<{
291
312
  purged: true;
292
313
  title: string;
293
314
  }>;
315
+ purgePreview(actor: Actor, input: {
316
+ nodeId: string;
317
+ }): Promise<{
318
+ inboundLinks: number;
319
+ totalItems: number;
320
+ }>;
294
321
  listGrants(actor: Actor, resourceId: string): Promise<ResourceGrantList>;
322
+ listEffectiveAccess(actor: Actor, resourceId: string): Promise<import("@anchrd/intel-contract/share").ResourceAccessList>;
295
323
  listLinks(actor: Actor, nodeId: string): Promise<{
296
324
  items: NodeLink[];
297
325
  }>;
@@ -0,0 +1,11 @@
1
+ -- #492: D1 commits before irreversible R2 deletion. This receipt keeps every object key reachable
2
+ -- when an R2 call fails, and also makes a repeated purge key return the original result.
3
+ CREATE TABLE node_purge_receipts (
4
+ actor_id TEXT NOT NULL,
5
+ idempotency_key TEXT NOT NULL,
6
+ node_id TEXT NOT NULL,
7
+ title TEXT NOT NULL,
8
+ content_keys_json TEXT NOT NULL,
9
+ completed_at TEXT,
10
+ PRIMARY KEY (actor_id, idempotency_key)
11
+ );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anchrd/intel-api",
3
- "version": "0.20.0",
3
+ "version": "0.21.0",
4
4
  "type": "module",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -43,7 +43,7 @@
43
43
  },
44
44
  "dependencies": {
45
45
  "@anchrd/gate-sdk": "^0.15.0",
46
- "@anchrd/intel-contract": "^0.16.0",
46
+ "@anchrd/intel-contract": "^0.17.0",
47
47
  "@cfworker/json-schema": "^4.1.1",
48
48
  "@modelcontextprotocol/sdk": "^1.30.0",
49
49
  "fflate": "^0.8.3",