@anchrd/intel-api 0.36.0 → 0.37.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.
@@ -1,23 +1,5 @@
1
1
  import type { FeedRepository } from "../../feed/feed.types.js";
2
2
  import type { D1Database } from "./db.types.js";
3
- /**
4
- * One page of the journal, newest first, cut down to the rows this actor may see.
5
- *
6
- * ⚠️ **The visibility check is the same JOIN against `allowed` that `audit_list` uses**, which is
7
- * the same tree walk `nodes` reads through (`db-grants.ts`). An event about a node is visible
8
- * exactly when the node is. A second, similar-looking query beside the first is the drift that
9
- * `anchrd/intel#457` was built out of: two queries with similar names are two different questions.
10
- *
11
- * ⚠️ **`ORDER BY` and the cursor comparison name BOTH columns, in the same direction.** Two events
12
- * in the same millisecond are the normal case inside a batch, not the exception; on `occurred_at`
13
- * alone the second one is skipped in silence. SQLite has no row-value comparison here, so the
14
- * tuple comparison is written out: "earlier timestamp, OR same timestamp and smaller id".
15
- *
16
- * ⚠️ **Both columns descend, and that is what lets the index carry this.** `audit_events_feed_idx`
17
- * is `(resource_type, occurred_at, id)` ascending; SQLite reads an index backwards only when every
18
- * sort column is reversed together. Reversing one would cost a sort over the whole table, and that
19
- * is invisible on the first pages and only hurts far down.
20
- */
21
3
  export declare const feedPageQuery: string;
22
4
  /**
23
5
  * The folders above each of a page's nodes, and only the ones the actor may open.
@@ -32,6 +14,14 @@ export declare const feedPageQuery: string;
32
14
  * and `UNION` ends the walk because the row repeats — a depth counter would make every row unique
33
15
  * and turn the same cycle into a query that never returns. The order is rebuilt in TypeScript from
34
16
  * `parent_id`, where a visited-set makes the cycle harmless.
17
+ *
18
+ * ⚠️ **The seeds are the PARENTS, not the nodes** (#645). Starting at the node and stepping up to
19
+ * its parent works only while the node exists, and the one event that most needs a trail is the one
20
+ * whose node is gone. Starting a level higher costs nothing for the ordinary case — the parent is
21
+ * where the walk went anyway — and it is the only anchor a deletion has left.
22
+ *
23
+ * There is no `seed` column: ids are unique, so one flat set of visible ancestors serves every card
24
+ * on the page, and each trail is rebuilt from its own `parentId` through that set.
35
25
  */
36
26
  export declare const feedAncestorsQuery: (seeds: number) => string;
37
27
  export declare function createFeedRepository(deps: {
@@ -1,9 +1,12 @@
1
- import { FeedAction } from "@anchrd/intel-contract/feed";
2
- import { subtreeBindings, subtreeCte } from "./db-grants.js";
3
- // Built once from the contract enum rather than written out here: two lists of actions that have to
4
- // agree would eventually stop agreeing, and the one that silently wins is the SQL.
5
- const ACTIONS = FeedAction.options;
6
- const ACTION_PLACEHOLDERS = ACTIONS.map(() => "?").join(", ");
1
+ import { FeedAction, feedKindOf, } from "@anchrd/intel-contract/feed";
2
+ import { flowInSubtreeBindings, flowInSubtreeOver, subtreeBindings, subtreeCte, } from "./db-grants.js";
3
+ // The two kinds of resource the feed reads, split out of the one action list so the SQL can name
4
+ // each side by itself. Derived from the contract rather than written again here: one list that has
5
+ // to agree with another is one list too many.
6
+ const NODE_ACTIONS = FeedAction.options.filter((action) => feedKindOf(action) === "node");
7
+ const FLOW_ACTIONS = FeedAction.options.filter((action) => feedKindOf(action) === "flow");
8
+ const NODE_PLACEHOLDERS = NODE_ACTIONS.map(() => "?").join(", ");
9
+ const FLOW_PLACEHOLDERS = FLOW_ACTIONS.map(() => "?").join(", ");
7
10
  function parseMetadata(raw) {
8
11
  // The column is `NOT NULL DEFAULT '{}'` but it is written by eleven call sites over
9
12
  // `JSON.stringify` on whatever each had at hand. A row that cannot be parsed must not take the
@@ -29,24 +32,72 @@ function parseMetadata(raw) {
29
32
  * exactly when the node is. A second, similar-looking query beside the first is the drift that
30
33
  * `anchrd/intel#457` was built out of: two queries with similar names are two different questions.
31
34
  *
35
+ * ⚠️ **Two events have no row left to check, and that is why the join reads `COALESCE`** (#645 for
36
+ * `node.purge`, #763 for `flows.purge`).
37
+ * After `node.purge` the row is gone, so `JOIN nodes` dropped the event for everybody — creation,
38
+ * change and archiving arrived, final deletion never did. It now falls back to the `parentId` the
39
+ * purge wrote into its own metadata, and the check runs against that surviving FOLDER.
40
+ *
41
+ * The fallback is deliberately narrow: `COALESCE(n.id, …)` prefers the node whenever it exists, so
42
+ * for every other event nothing changes at all. It is not "or the parent is allowed" — that would
43
+ * hand out every event in a readable folder about nodes inside it that are NOT readable.
44
+ *
45
+ * ⚠️ **The title comes from the metadata for the same reason** — there is no row to read it from —
46
+ * and events written before #645 carry no `parentId`, so they stay unreachable. That cannot be
47
+ * repaired: at a deleted row there is nothing left to look up where it hung.
48
+ *
32
49
  * ⚠️ **`ORDER BY` and the cursor comparison name BOTH columns, in the same direction.** Two events
33
50
  * in the same millisecond are the normal case inside a batch, not the exception; on `occurred_at`
34
51
  * alone the second one is skipped in silence. SQLite has no row-value comparison here, so the
35
52
  * tuple comparison is written out: "earlier timestamp, OR same timestamp and smaller id".
36
53
  *
37
- * ⚠️ **Both columns descend, and that is what lets the index carry this.** `audit_events_feed_idx`
38
- * is `(resource_type, occurred_at, id)` ascending; SQLite reads an index backwards only when every
39
- * sort column is reversed together. Reversing one would cost a sort over the whole table, and that
40
- * is invisible on the first pages and only hurts far down.
54
+ * ⚠️ **Both columns descend, and that is what lets an index carry this.** SQLite reads an index
55
+ * backwards only when every sort column is reversed together. Reversing one would cost a sort over
56
+ * the whole table, and that is invisible on the first pages and only hurts far down.
57
+ *
58
+ * ⚠️ **The index that carries it is `audit_events_time_idx`, NOT `audit_events_feed_idx`** (#763).
59
+ * The feed reads two kinds at once, and the older index leads with `resource_type`; under
60
+ * `IN ('node', 'flow')` — or, as written here, under two branches joined by `OR` — that column
61
+ * stops being an equality prefix, and the ordering falls off it. Measured before `0026` existed:
62
+ * `SEARCH e USING INDEX audit_events_feed_idx (resource_type=?)` followed by
63
+ * `USE TEMP B-TREE FOR ORDER BY`. The older index stays for `audit_list`, which names one kind.
64
+ *
65
+ * ⚠️ **The unary `+` in front of each `resource_type` is load-bearing, not a typo.** It tells SQLite
66
+ * the term may not drive an index. Without it the planner sees two indexable `OR` branches, picks
67
+ * `MULTI-INDEX OR` over the OLD index — one search per branch, both on `resource_type=?` — and buys
68
+ * the filtering back at the price of the ordering: `USE TEMP B-TREE FOR ORDER BY` returns, over the
69
+ * whole table. Measured both ways. Filtering on kind is cheap here (two values out of three);
70
+ * sorting the journal is not.
71
+ *
72
+ * ⚠️ **The two halves ask two DIFFERENT authorization questions, and neither may stand in for the
73
+ * other.** A node is visible when the node itself is in `allowed`; a flow when its FOLDER is, or
74
+ * when it carries a grant of its own — flows hang in the tree but have nothing beneath them, so
75
+ * `flowInSubtree` is the rule and `allowed.id = flow.id` would answer about a node that does not
76
+ * exist. Both halves fall back on the metadata `parentId` for the one event whose row is gone.
41
77
  */
78
+ const FLOW_PARENT = "COALESCE(flow.parent_id, json_extract(e.metadata_json, '$.parentId'))";
79
+ const NODE_PARENT = "COALESCE(n.id, json_extract(e.metadata_json, '$.parentId'))";
42
80
  export const feedPageQuery = `${subtreeCte}
43
- SELECT e.id, e.actor_id, e.action, e.resource_id, n.title AS node_title,
44
- n.parent_id AS node_parent_id, e.metadata_json, e.occurred_at
81
+ SELECT e.id, e.actor_id, e.action, e.resource_id,
82
+ COALESCE(n.title, flow.title, json_extract(e.metadata_json, '$.title')) AS resource_title,
83
+ COALESCE(n.parent_id, flow.parent_id, json_extract(e.metadata_json, '$.parentId'))
84
+ AS resource_parent_id,
85
+ e.metadata_json, e.occurred_at
45
86
  FROM audit_events e
46
- JOIN allowed ON allowed.id = e.resource_id
47
- JOIN nodes n ON n.id = e.resource_id
48
- WHERE e.resource_type = 'node'
49
- AND e.action IN (${ACTION_PLACEHOLDERS})
87
+ LEFT JOIN nodes n ON n.id = e.resource_id AND e.resource_type = 'node'
88
+ LEFT JOIN flows flow ON flow.id = e.resource_id AND e.resource_type = 'flow'
89
+ WHERE (
90
+ (
91
+ +e.resource_type = 'node'
92
+ AND e.action IN (${NODE_PLACEHOLDERS})
93
+ AND ${NODE_PARENT} IN (SELECT id FROM allowed)
94
+ )
95
+ OR (
96
+ +e.resource_type = 'flow'
97
+ AND e.action IN (${FLOW_PLACEHOLDERS})
98
+ AND ${flowInSubtreeOver(FLOW_PARENT)}
99
+ )
100
+ )
50
101
  AND (? IS NULL OR e.actor_id = ?)
51
102
  AND (
52
103
  ? IS NULL
@@ -68,19 +119,26 @@ export const feedPageQuery = `${subtreeCte}
68
119
  * and `UNION` ends the walk because the row repeats — a depth counter would make every row unique
69
120
  * and turn the same cycle into a query that never returns. The order is rebuilt in TypeScript from
70
121
  * `parent_id`, where a visited-set makes the cycle harmless.
122
+ *
123
+ * ⚠️ **The seeds are the PARENTS, not the nodes** (#645). Starting at the node and stepping up to
124
+ * its parent works only while the node exists, and the one event that most needs a trail is the one
125
+ * whose node is gone. Starting a level higher costs nothing for the ordinary case — the parent is
126
+ * where the walk went anyway — and it is the only anchor a deletion has left.
127
+ *
128
+ * There is no `seed` column: ids are unique, so one flat set of visible ancestors serves every card
129
+ * on the page, and each trail is rebuilt from its own `parentId` through that set.
71
130
  */
72
131
  export const feedAncestorsQuery = (seeds) => `${subtreeCte},
73
- ancestry(seed, id, parent_id, title) AS (
74
- SELECT child.id, parent.id, parent.parent_id, parent.title
75
- FROM nodes child
76
- JOIN nodes parent ON parent.id = child.parent_id
77
- WHERE child.id IN (${Array.from({ length: seeds }, () => "?").join(", ")})
132
+ ancestry(id, parent_id, title) AS (
133
+ SELECT parent.id, parent.parent_id, parent.title
134
+ FROM nodes parent
135
+ WHERE parent.id IN (${Array.from({ length: seeds }, () => "?").join(", ")})
78
136
  UNION
79
- SELECT a.seed, grandparent.id, grandparent.parent_id, grandparent.title
137
+ SELECT grandparent.id, grandparent.parent_id, grandparent.title
80
138
  FROM nodes grandparent
81
139
  JOIN ancestry a ON a.parent_id = grandparent.id
82
140
  )
83
- SELECT a.seed, a.id, a.parent_id, a.title
141
+ SELECT a.id, a.parent_id, a.title
84
142
  FROM ancestry a
85
143
  JOIN allowed ON allowed.id = a.id`;
86
144
  /**
@@ -115,36 +173,40 @@ export function createFeedRepository(deps) {
115
173
  return {
116
174
  async listEvents(actor, query) {
117
175
  const before = query.before ?? null;
176
+ // One reading of the clock for the whole call. Both guards measure grant expiry against it,
177
+ // and two `now()` calls could straddle the moment a grant runs out — a page whose flow half
178
+ // answers about a permission its node half no longer has.
179
+ const now = deps.now().toISOString();
118
180
  const page = await deps.db
119
181
  .prepare(feedPageQuery)
120
- .bind(...subtreeBindings(actor, "read", deps.now().toISOString()), ...ACTIONS, query.actor, query.actor, before === null ? null : before.occurredAt, before === null ? null : before.occurredAt, before === null ? null : before.occurredAt, before === null ? null : before.id, query.limit)
182
+ .bind(...subtreeBindings(actor, "read", now), ...NODE_ACTIONS, ...FLOW_ACTIONS, ...flowInSubtreeBindings(actor, "read", now), query.actor, query.actor, before === null ? null : before.occurredAt, before === null ? null : before.occurredAt, before === null ? null : before.occurredAt, before === null ? null : before.id, query.limit)
121
183
  .all();
122
184
  const rows = page.results ?? [];
123
185
  if (rows.length === 0)
124
186
  return { events: [] };
125
187
  // One walk for the whole page rather than one per row. The set is at most the page size, and
126
188
  // it is deduplicated because a busy morning on one document is exactly the case the feed
127
- // shows unsummarised today.
128
- const nodeIds = [...new Set(rows.map((row) => row.resource_id))];
129
- const ancestors = await deps.db
130
- .prepare(feedAncestorsQuery(nodeIds.length))
131
- .bind(...subtreeBindings(actor, "read", deps.now().toISOString()), ...nodeIds)
132
- .all();
133
- const ancestorRows = ancestors.results ?? [];
134
- const parentOf = new Map(rows.map((row) => [row.resource_id, row.node_parent_id]));
135
- const trails = new Map(nodeIds.map((id) => [
136
- id,
137
- trailFor(parentOf.get(id) ?? null, ancestorRows.filter((row) => row.seed === id)),
138
- ]));
189
+ // shows unsummarised today. The trail is keyed by EVENT rather than by node: two events about
190
+ // one node share a trail, but a deleted node and a live one can carry the same parent.
191
+ const parentIds = [
192
+ ...new Set(rows.map((row) => row.resource_parent_id).filter((id) => id !== null)),
193
+ ];
194
+ const ancestorRows = parentIds.length === 0
195
+ ? []
196
+ : ((await deps.db
197
+ .prepare(feedAncestorsQuery(parentIds.length))
198
+ .bind(...subtreeBindings(actor, "read", now), ...parentIds)
199
+ .all()).results ?? []);
200
+ const trails = new Map(rows.map((row) => [row.id, trailFor(row.resource_parent_id, ancestorRows)]));
139
201
  const events = rows.map((row) => ({
140
202
  id: row.id,
141
203
  actorId: row.actor_id,
142
204
  // Narrowed at the door by the SQL `IN`, so the cast names what the query already
143
205
  // guarantees rather than trusting the column.
144
206
  action: row.action,
145
- nodeId: row.resource_id,
146
- nodeTitle: row.node_title,
147
- path: trails.get(row.resource_id) ?? [],
207
+ resourceId: row.resource_id,
208
+ resourceTitle: row.resource_title,
209
+ path: trails.get(row.id) ?? [],
148
210
  metadata: parseMetadata(row.metadata_json),
149
211
  occurredAt: row.occurred_at,
150
212
  }));
@@ -532,7 +532,8 @@ export function createFlowRepository(deps) {
532
532
  deps.db
533
533
  .prepare(`INSERT INTO audit_events (
534
534
  id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
535
- ) SELECT ?, ?, 'flows.purge', 'flow', f.id, json_object('title', f.title), ?
535
+ ) SELECT ?, ?, 'flows.purge', 'flow', f.id,
536
+ json_object('title', f.title, 'parentId', f.parent_id), ?
536
537
  FROM flows f WHERE f.id = ? AND f.archived_at IS NOT NULL`)
537
538
  .bind(input.auditId, input.actorId, input.occurredAt, input.flowId),
538
539
  // The runs are this flow's history: the steps hang off the runs, the runs off the flow.
@@ -106,5 +106,15 @@ export declare function flowVerbBindings(flowId: string, actor: GrantActor, verb
106
106
  * different verbs would produce a predicate that is neither, and it is the kind of mismatch nothing
107
107
  * fails on: the query still runs and quietly hands out the wrong list.
108
108
  */
109
+ /**
110
+ * ⚠️ The parent is a PARAMETER for exactly one caller, and giving it one was cheaper than the
111
+ * alternative. The feed reads events about flows that no longer exist: after `flows.purge` the row
112
+ * is gone, so `flow.parent_id`, `flow.owner_id` and every `flow_grants` row are gone with it, and
113
+ * the only anchor left is the `parentId` the purge wrote into its own metadata (#763, D73). A
114
+ * second, similar-looking predicate beside this one is the drift `destructive.md` warns about: two
115
+ * expressions with similar names are two different questions, and the one that goes stale is the
116
+ * copy.
117
+ */
118
+ export declare function flowInSubtreeOver(parentExpression: string): string;
109
119
  export declare const flowInSubtree: string;
110
120
  export declare function flowInSubtreeBindings(actor: GrantActor, verb: ResourceVerb, now: string): unknown[];
@@ -233,12 +233,24 @@ export function flowVerbBindings(flowId, actor, verb, now) {
233
233
  * different verbs would produce a predicate that is neither, and it is the kind of mismatch nothing
234
234
  * fails on: the query still runs and quietly hands out the wrong list.
235
235
  */
236
- export const flowInSubtree = `(
236
+ /**
237
+ * ⚠️ The parent is a PARAMETER for exactly one caller, and giving it one was cheaper than the
238
+ * alternative. The feed reads events about flows that no longer exist: after `flows.purge` the row
239
+ * is gone, so `flow.parent_id`, `flow.owner_id` and every `flow_grants` row are gone with it, and
240
+ * the only anchor left is the `parentId` the purge wrote into its own metadata (#763, D73). A
241
+ * second, similar-looking predicate beside this one is the drift `destructive.md` warns about: two
242
+ * expressions with similar names are two different questions, and the one that goes stale is the
243
+ * copy.
244
+ */
245
+ export function flowInSubtreeOver(parentExpression) {
246
+ return `(
237
247
  ? = 1
238
248
  OR flow.owner_id = ?
239
- OR flow.parent_id IN (SELECT id FROM allowed)
249
+ OR ${parentExpression} IN (SELECT id FROM allowed)
240
250
  OR ${flowGrantExists("flow.id")}
241
251
  )`;
252
+ }
253
+ export const flowInSubtree = flowInSubtreeOver("flow.parent_id");
242
254
  export function flowInSubtreeBindings(actor, verb, now) {
243
255
  return [actor.isAdmin ? 1 : 0, actor.id, ...grantBindings(actor, verb, now)];
244
256
  }
@@ -904,11 +904,18 @@ export function createNodeRepository(deps) {
904
904
  // ⚠️ FIRST, and reading the title out of the row that falls at the end of this batch. Its
905
905
  // `WHERE EXISTS` is what makes it honest: no row, no entry — a purge that hit nothing does
906
906
  // not book one.
907
+ //
908
+ // ⚠️ **`parentId` travels beside the title, and it is what makes this row READABLE
909
+ // afterwards** (#645). Visibility everywhere else walks `nodes` from the row itself, and
910
+ // after this batch that row is gone — so until now the event was unreachable for everybody,
911
+ // admin included. The surviving FOLDER answers instead. It is written here rather than
912
+ // derived later for the same reason the title is: after the delete there is nothing left to
913
+ // look either of them up from (`destructive.md`).
907
914
  deps.db
908
915
  .prepare(`INSERT INTO audit_events (
909
916
  id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
910
917
  ) SELECT ?, ?, 'node.purge', 'node', n.id,
911
- json_patch(?, json_object('title', n.title, 'kind', n.kind)), ?
918
+ json_patch(?, json_object('title', n.title, 'kind', n.kind, 'parentId', n.parent_id)), ?
912
919
  FROM nodes n WHERE n.id = ? AND n.archived_at IS NOT NULL`)
913
920
  .bind(input.auditId, input.actorId, JSON.stringify(input.metadata), input.occurredAt, input.nodeId),
914
921
  deps.db
@@ -0,0 +1,23 @@
1
+ -- The feed reads both kinds at once — nodes and flows — and `audit_events_feed_idx` cannot serve
2
+ -- that. It leads with `resource_type` because every reader until now named exactly one kind, so for
3
+ -- a query saying `resource_type IN ('node', 'flow')` that column is no longer an equality prefix but
4
+ -- an ordinary filter, and the ordering falls off the index.
5
+ --
6
+ -- ⚠️ Measured against the real `feedPageQuery` before this file existed, not assumed
7
+ -- (anchrd/intel#763):
8
+ --
9
+ -- SEARCH e USING INDEX audit_events_feed_idx (resource_type=?)
10
+ -- … | USE TEMP B-TREE FOR ORDER BY
11
+ --
12
+ -- That last line is a sorting pass over the whole table. It costs nothing on the first page and
13
+ -- everything far down — which is exactly where an endless scroll goes.
14
+ --
15
+ -- ⚠️ Both columns, in the order the cursor compares them, for the reason `0022` gives: two events
16
+ -- written in the same millisecond are the normal case inside one batch, and without the tie-break
17
+ -- on `id` a reader skips the second one with no error and no log.
18
+ --
19
+ -- ⚠️ This does NOT replace `audit_events_feed_idx`. That one still serves `audit_list`, which names
20
+ -- one kind and is the contract `anchrd/signals` builds on; dropping it would put a sorting pass into
21
+ -- the reader that has none today. Two readers, two shapes, two indexes.
22
+ CREATE INDEX audit_events_time_idx
23
+ ON audit_events(occurred_at, id);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anchrd/intel-api",
3
- "version": "0.36.0",
3
+ "version": "0.37.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.25.0",
46
- "@anchrd/intel-contract": "^0.28.0",
46
+ "@anchrd/intel-contract": "^0.29.0",
47
47
  "@cfworker/json-schema": "^4.1.1",
48
48
  "@modelcontextprotocol/sdk": "^1.30.0",
49
49
  "fflate": "^0.8.3",