@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.
- package/dist/adapters/db/db-feed.d.ts +8 -18
- package/dist/adapters/db/db-feed.js +101 -39
- package/dist/adapters/db/db-flows.js +2 -1
- package/dist/adapters/db/db-grants.d.ts +10 -0
- package/dist/adapters/db/db-grants.js +14 -2
- package/dist/adapters/db/db.js +8 -1
- package/migrations/0026_one_feed_over_two_kinds.sql +23 -0
- package/package.json +2 -2
|
@@ -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
|
-
//
|
|
4
|
-
//
|
|
5
|
-
|
|
6
|
-
const
|
|
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
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
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,
|
|
44
|
-
n.
|
|
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
|
|
47
|
-
JOIN
|
|
48
|
-
WHERE
|
|
49
|
-
|
|
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(
|
|
74
|
-
SELECT
|
|
75
|
-
FROM nodes
|
|
76
|
-
|
|
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
|
|
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.
|
|
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",
|
|
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
|
-
|
|
129
|
-
const
|
|
130
|
-
.
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
path: trails.get(row.
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
}
|
package/dist/adapters/db/db.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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",
|