@anchrd/intel-api 0.6.6 → 0.7.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/README.md +44 -3
- package/dist/adapters/cloudflare/cloudflare.js +50 -16
- package/dist/adapters/cloudflare/cloudflare.types.d.ts +11 -0
- package/dist/adapters/content/content.d.ts +1 -1
- package/dist/adapters/db/db-flows.js +161 -20
- package/dist/adapters/db/db-grants.d.ts +13 -2
- package/dist/adapters/db/db-grants.js +25 -8
- package/dist/adapters/db/db-indexing.d.ts +2 -2
- package/dist/adapters/db/db-indexing.js +26 -19
- package/dist/adapters/db/db.d.ts +3 -3
- package/dist/adapters/db/db.js +448 -119
- package/dist/adapters/gate-applications/gate-applications.d.ts +23 -0
- package/dist/adapters/gate-applications/gate-applications.js +66 -0
- package/dist/adapters/index-queue/index-queue.d.ts +1 -1
- package/dist/adapters/index-queue/index-queue.js +2 -2
- package/dist/adapters/semantic-index/semantic-index.types.d.ts +2 -2
- package/dist/agent-runtime/agent-runtime.d.ts +16 -0
- package/dist/agent-runtime/agent-runtime.js +76 -0
- package/dist/agent-runtime/agent-runtime.types.d.ts +57 -0
- package/dist/bundle/bundle.d.ts +4 -0
- package/dist/bundle/bundle.js +1035 -0
- package/dist/bundle/bundle.types.d.ts +33 -0
- package/dist/bundle/bundle.types.js +1 -0
- package/dist/cli/cli.js +10 -1
- package/dist/flows/flows.d.ts +8 -8
- package/dist/flows/flows.js +158 -42
- package/dist/flows/flows.types.d.ts +40 -7
- package/dist/http/http.d.ts +1 -0
- package/dist/http/http.js +329 -63
- package/dist/http/http.types.d.ts +6 -2
- package/dist/indexing/indexing.js +14 -2
- package/dist/indexing/indexing.types.d.ts +2 -2
- package/dist/intel/intel.js +12 -3
- package/dist/intel/intel.types.d.ts +6 -2
- package/dist/mcp/mcp.js +483 -124
- package/dist/mcp/mcp.types.d.ts +11 -2
- package/dist/nodes/nodes.d.ts +2 -0
- package/dist/nodes/nodes.js +1337 -0
- package/dist/nodes/nodes.types.d.ts +314 -0
- package/dist/nodes/nodes.types.js +1 -0
- package/migrations/0011_one_name_for_the_tree.sql +53 -0
- package/migrations/0012_table_snapshots.sql +29 -0
- package/migrations/0013_agents_in_the_tree.sql +76 -0
- package/migrations/0014_agent_applications.sql +25 -0
- package/package.json +3 -2
- package/dist/knowledge/knowledge.d.ts +0 -2
- package/dist/knowledge/knowledge.js +0 -761
- package/dist/knowledge/knowledge.types.d.ts +0 -198
- /package/dist/{knowledge/knowledge.types.js → agent-runtime/agent-runtime.types.js} +0 -0
- /package/dist/{knowledge → nodes}/document-links/document-links.d.ts +0 -0
- /package/dist/{knowledge → nodes}/document-links/document-links.js +0 -0
package/dist/adapters/db/db.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import { nodeVerbBindings, nodeVerbQuery, subtreeBindings, subtreeCte } from "./db-grants.js";
|
|
1
|
+
import { descendantsCte, nodeVerbBindings, nodeVerbQuery, subtreeBindings, subtreeCte, } from "./db-grants.js";
|
|
2
|
+
const versionColumns = `id, node_id, sequence, content_key, media_type, content_hash,
|
|
3
|
+
size, segment, created_by, created_at`;
|
|
2
4
|
const grantColumns = `id, node_id, principal_type, principal_id, verb, expires_at,
|
|
3
5
|
created_by, created_at`;
|
|
4
6
|
const linkColumns = `link.id, link.source_node_id, link.target_node_id, link.relation,
|
|
@@ -28,6 +30,7 @@ function mapVersion(row) {
|
|
|
28
30
|
mediaType: row.media_type,
|
|
29
31
|
contentHash: row.content_hash,
|
|
30
32
|
size: row.size,
|
|
33
|
+
segment: row.segment,
|
|
31
34
|
createdBy: row.created_by,
|
|
32
35
|
createdAt: row.created_at,
|
|
33
36
|
};
|
|
@@ -45,7 +48,7 @@ function mapGrant(row) {
|
|
|
45
48
|
principal = { type: "organization" };
|
|
46
49
|
break;
|
|
47
50
|
default:
|
|
48
|
-
throw new Error("Unsupported
|
|
51
|
+
throw new Error("Unsupported node grant principal type");
|
|
49
52
|
}
|
|
50
53
|
return {
|
|
51
54
|
id: row.id,
|
|
@@ -76,11 +79,24 @@ function toFtsQuery(query) {
|
|
|
76
79
|
.map((token) => `"${token.replaceAll('"', '""')}"`)
|
|
77
80
|
.join(" AND ");
|
|
78
81
|
}
|
|
79
|
-
export function
|
|
82
|
+
export function createNodeRepository(deps) {
|
|
80
83
|
// Reading is one verb among four; the tree walk itself lives in db-grants.ts so Flows asks the
|
|
81
84
|
// same question of the same table.
|
|
82
85
|
const visibleCte = subtreeCte;
|
|
83
86
|
const readBindings = (actor) => subtreeBindings(actor, "read", deps.now().toISOString());
|
|
87
|
+
/**
|
|
88
|
+
* The optional folder cut a search carries (#126), in the three places one statement needs it:
|
|
89
|
+
* the extra CTE, the extra join, and the one binding between the actor's and the query's. All
|
|
90
|
+
* three are absent together when there is no scope, so an unscoped search runs the exact
|
|
91
|
+
* statement it ran before.
|
|
92
|
+
*
|
|
93
|
+
* ⚠️ The join is added *beside* `allowed`, never instead of it. `descendants` is seeded from a
|
|
94
|
+
* caller-supplied id and knows nothing about grants; on its own it would answer "everything under
|
|
95
|
+
* this folder" to anyone who names the folder.
|
|
96
|
+
*/
|
|
97
|
+
const scopeCte = (scopeId) => scopeId === undefined ? "" : `,\n${descendantsCte}`;
|
|
98
|
+
const scopeJoin = (scopeId) => scopeId === undefined ? "" : "\n JOIN descendants ON descendants.id = n.id";
|
|
99
|
+
const scopeBindings = (scopeId) => (scopeId === undefined ? [] : [scopeId]);
|
|
84
100
|
/**
|
|
85
101
|
* One statement for the level the tree reads and for the bounded read the relation graph makes,
|
|
86
102
|
* so the visibility predicate cannot drift between them.
|
|
@@ -94,19 +110,94 @@ export function createKnowledgeRepository(deps) {
|
|
|
94
110
|
-- level down. A second rule written here would drift from the one above it, and the drift
|
|
95
111
|
-- would show as a chevron that opens onto nothing.
|
|
96
112
|
EXISTS (
|
|
97
|
-
SELECT 1 FROM
|
|
113
|
+
SELECT 1 FROM nodes child
|
|
98
114
|
JOIN allowed AS allowed_child ON allowed_child.id = child.id
|
|
99
115
|
WHERE child.parent_id = n.id AND child.archived_at IS NULL
|
|
100
116
|
) AS has_children
|
|
101
|
-
FROM
|
|
117
|
+
FROM nodes n
|
|
102
118
|
JOIN allowed ON allowed.id = n.id
|
|
103
119
|
WHERE n.parent_id IS ? AND (? = 1 OR n.archived_at IS NULL)
|
|
104
120
|
ORDER BY CASE n.kind WHEN 'folder' THEN 0 ELSE 1 END, lower(n.title), n.id${bounded ? " LIMIT ?" : ""}`;
|
|
121
|
+
/**
|
|
122
|
+
* Everything archived that this actor may see, wherever it sits (#113).
|
|
123
|
+
*
|
|
124
|
+
* ⚠️ No `parent_id` at all, and that is the point rather than an omission: somebody looking for
|
|
125
|
+
* what they archived does not know which folder it was in. The visibility predicate is the same
|
|
126
|
+
* `allowed` set every other read uses, so the archive shows no more than the tree would have.
|
|
127
|
+
*
|
|
128
|
+
* `has_children` is answered as 0 without asking: an archived row is not expandable — its
|
|
129
|
+
* children are reached by restoring it, not by opening it here.
|
|
130
|
+
*/
|
|
131
|
+
const archivedEverywhere = `${visibleCte}
|
|
132
|
+
SELECT ${nodeColumns}, 0 AS has_children
|
|
133
|
+
FROM nodes n
|
|
134
|
+
JOIN allowed ON allowed.id = n.id
|
|
135
|
+
WHERE n.archived_at IS NOT NULL
|
|
136
|
+
ORDER BY n.archived_at DESC, lower(n.title), n.id`;
|
|
137
|
+
/**
|
|
138
|
+
* The agents this actor may see, over the whole tree or under one folder (#139).
|
|
139
|
+
*
|
|
140
|
+
* ⚠️ The same `allowed` set every other read joins against, not a second predicate written for
|
|
141
|
+
* agents. An agent is a node, and the moment its list answers from its own rule it can answer
|
|
142
|
+
* differently from `list` about the very same row.
|
|
143
|
+
*
|
|
144
|
+
* Three scopes, matching the flow list: no clause at all for the whole tree, `IS NULL` for the
|
|
145
|
+
* root level, and one folder by ID.
|
|
146
|
+
*/
|
|
147
|
+
const visibleAgents = (clause, includeArchived) => `${visibleCte}
|
|
148
|
+
SELECT ${nodeColumns}
|
|
149
|
+
FROM nodes n
|
|
150
|
+
JOIN allowed ON allowed.id = n.id
|
|
151
|
+
WHERE n.kind = 'agent' ${includeArchived ? "" : "AND n.archived_at IS NULL"} ${clause}
|
|
152
|
+
ORDER BY lower(n.title), n.id`;
|
|
153
|
+
const agentScopeOf = (parentId) => parentId === undefined
|
|
154
|
+
? { clause: "", bindings: [] }
|
|
155
|
+
: parentId === null
|
|
156
|
+
? { clause: "AND n.parent_id IS NULL", bindings: [] }
|
|
157
|
+
: { clause: "AND n.parent_id = ?", bindings: [parentId] };
|
|
105
158
|
return {
|
|
159
|
+
async listVisibleAgents(actor, input) {
|
|
160
|
+
const scope = agentScopeOf(input.parentId);
|
|
161
|
+
const result = await deps.db
|
|
162
|
+
.prepare(visibleAgents(scope.clause, input.includeArchived === true))
|
|
163
|
+
.bind(...readBindings(actor), ...scope.bindings)
|
|
164
|
+
.all();
|
|
165
|
+
return (result.results ?? []).map(mapNode);
|
|
166
|
+
},
|
|
167
|
+
/**
|
|
168
|
+
* The R2 keys of the current definitions of every agent in this folder's subtree that this
|
|
169
|
+
* actor may see — the input to the share warning (#139, ADR-0005 §2).
|
|
170
|
+
*
|
|
171
|
+
* ⚠️ Keys, never bodies and never titles. Whether a referenced node may be NAMED to whoever is
|
|
172
|
+
* sharing is the tree's question and is asked afterwards through `getVisible`, exactly as the
|
|
173
|
+
* flow side does it. Answering it here would be the second rule that #17 and #19 got wrong.
|
|
174
|
+
*
|
|
175
|
+
* ⚠️ Agents this actor may not see contribute nothing, not even a count. Their references would
|
|
176
|
+
* otherwise be attributed to a folder this actor administers, which discloses that the agent is
|
|
177
|
+
* there at all.
|
|
178
|
+
*/
|
|
179
|
+
async listVisibleAgentDefinitionKeys(actor, folderId) {
|
|
180
|
+
const result = await deps.db
|
|
181
|
+
.prepare(`${visibleCte},
|
|
182
|
+
scope(id) AS (
|
|
183
|
+
SELECT id FROM nodes WHERE id = ?
|
|
184
|
+
UNION
|
|
185
|
+
SELECT child.id FROM nodes child JOIN scope ON child.parent_id = scope.id
|
|
186
|
+
)
|
|
187
|
+
SELECT version.content_key AS content_key
|
|
188
|
+
FROM nodes n
|
|
189
|
+
JOIN allowed ON allowed.id = n.id
|
|
190
|
+
JOIN node_versions version ON version.id = n.current_version_id
|
|
191
|
+
WHERE n.kind = 'agent' AND n.archived_at IS NULL AND n.id IN (SELECT id FROM scope)
|
|
192
|
+
ORDER BY n.id`)
|
|
193
|
+
.bind(...readBindings(actor), folderId)
|
|
194
|
+
.all();
|
|
195
|
+
return (result.results ?? []).map((row) => row.content_key);
|
|
196
|
+
},
|
|
106
197
|
async listVisible(actor, input) {
|
|
107
198
|
const result = await deps.db
|
|
108
|
-
.prepare(visibleChildren(false))
|
|
109
|
-
.bind(...readBindings(actor), input.parentId, input.includeArchived ? 1 : 0)
|
|
199
|
+
.prepare(input.archivedOnly ? archivedEverywhere : visibleChildren(false))
|
|
200
|
+
.bind(...readBindings(actor), ...(input.archivedOnly ? [] : [input.parentId, input.includeArchived ? 1 : 0]))
|
|
110
201
|
.all();
|
|
111
202
|
const rows = result.results ?? [];
|
|
112
203
|
return {
|
|
@@ -126,13 +217,168 @@ export function createKnowledgeRepository(deps) {
|
|
|
126
217
|
const row = await deps.db
|
|
127
218
|
.prepare(`${visibleCte}
|
|
128
219
|
SELECT ${nodeColumns}
|
|
129
|
-
FROM
|
|
220
|
+
FROM nodes n
|
|
130
221
|
JOIN allowed ON allowed.id = n.id
|
|
131
222
|
WHERE n.id = ?`)
|
|
132
223
|
.bind(...readBindings(actor), nodeId)
|
|
133
224
|
.first();
|
|
134
225
|
return row ? mapNode(row) : null;
|
|
135
226
|
},
|
|
227
|
+
/**
|
|
228
|
+
* The readable subtree as one statement (#136): the `allowed` walk says what the actor may
|
|
229
|
+
* read, and `walk` says what hangs together under the chosen root. Both joins apply at every
|
|
230
|
+
* step, so the walk neither descends into an archived node nor through an unreadable one — a
|
|
231
|
+
* child the actor may read under a folder they may not stays out, because the bundle would have
|
|
232
|
+
* no path to file it under.
|
|
233
|
+
*
|
|
234
|
+
* ⚠️ `UNION`, never `UNION ALL`, for the reason `nodeVerbQuery` gives: a cycle in `parent_id`
|
|
235
|
+
* can reach the table and this walks downwards into it.
|
|
236
|
+
*/
|
|
237
|
+
async listVisibleSubtree(actor, rootId) {
|
|
238
|
+
const seed = rootId === null
|
|
239
|
+
? // "Everything this actor may read": the top of every readable region, not only the top
|
|
240
|
+
// of the tree. A reader who holds a grant on one nested folder has that folder as the
|
|
241
|
+
// top of what they see, and their whole-installation export starts there.
|
|
242
|
+
"(seed.parent_id IS NULL OR seed.parent_id NOT IN (SELECT id FROM allowed))"
|
|
243
|
+
: // The root node itself is the seed; whether it is a folder is the service's question.
|
|
244
|
+
"seed.id = ?";
|
|
245
|
+
const result = await deps.db
|
|
246
|
+
.prepare(`${visibleCte},
|
|
247
|
+
walk(id) AS (
|
|
248
|
+
SELECT seed.id FROM nodes seed
|
|
249
|
+
JOIN allowed ON allowed.id = seed.id
|
|
250
|
+
WHERE ${seed} AND seed.archived_at IS NULL
|
|
251
|
+
UNION
|
|
252
|
+
SELECT child.id FROM nodes child
|
|
253
|
+
JOIN walk ON child.parent_id = walk.id
|
|
254
|
+
JOIN allowed AS allowed_child ON allowed_child.id = child.id
|
|
255
|
+
WHERE child.archived_at IS NULL
|
|
256
|
+
)
|
|
257
|
+
SELECT ${nodeColumns},
|
|
258
|
+
version.id AS version_id, version.media_type, version.content_key
|
|
259
|
+
FROM nodes n
|
|
260
|
+
JOIN walk ON walk.id = n.id
|
|
261
|
+
LEFT JOIN node_versions version ON version.id = n.current_version_id`)
|
|
262
|
+
.bind(...readBindings(actor), ...(rootId === null ? [] : [rootId]))
|
|
263
|
+
.all();
|
|
264
|
+
return (result.results ?? []).map((row) => ({
|
|
265
|
+
node: mapNode(row),
|
|
266
|
+
version: row.version_id === null || row.media_type === null || row.content_key === null
|
|
267
|
+
? null
|
|
268
|
+
: { id: row.version_id, mediaType: row.media_type, contentKey: row.content_key },
|
|
269
|
+
}));
|
|
270
|
+
},
|
|
271
|
+
/**
|
|
272
|
+
* One bundle import as one transaction (#137). Every table takes its rows from a single JSON
|
|
273
|
+
* parameter through `json_each`, so the statement count stays constant however large the
|
|
274
|
+
* bundle is — the same reason `idList` exists in db-flows — and `db.batch` makes the whole
|
|
275
|
+
* import land or refuse as one.
|
|
276
|
+
*
|
|
277
|
+
* ⚠️ `nodes` arrive parents before children: `parent_id REFERENCES nodes(id)` is checked as
|
|
278
|
+
* each row lands, and a child before its parent would abort the batch that is otherwise valid.
|
|
279
|
+
* The service owns that order; this only writes what it is handed.
|
|
280
|
+
*
|
|
281
|
+
* `context_policy` is the dead column migration 0009 explains; 'relevant' is the fixed value
|
|
282
|
+
* `insertNode` writes for the same reason.
|
|
283
|
+
*/
|
|
284
|
+
async importTree(input) {
|
|
285
|
+
const statements = [
|
|
286
|
+
deps.db
|
|
287
|
+
.prepare(`INSERT INTO nodes (
|
|
288
|
+
id, parent_id, kind, title, description, context_policy, owner_id,
|
|
289
|
+
current_version_id, created_at, updated_at, archived_at
|
|
290
|
+
) SELECT
|
|
291
|
+
json_extract(value, '$.id'), json_extract(value, '$.parentId'),
|
|
292
|
+
json_extract(value, '$.kind'), json_extract(value, '$.title'),
|
|
293
|
+
json_extract(value, '$.description'), 'relevant',
|
|
294
|
+
json_extract(value, '$.ownerId'), json_extract(value, '$.currentVersionId'),
|
|
295
|
+
json_extract(value, '$.createdAt'), json_extract(value, '$.updatedAt'), NULL
|
|
296
|
+
FROM json_each(?)`)
|
|
297
|
+
.bind(JSON.stringify(input.nodes)),
|
|
298
|
+
deps.db
|
|
299
|
+
.prepare(`INSERT INTO node_versions (
|
|
300
|
+
id, node_id, sequence, content_key, media_type, content_hash, size, segment,
|
|
301
|
+
created_by, created_at
|
|
302
|
+
) SELECT
|
|
303
|
+
json_extract(value, '$.id'), json_extract(value, '$.nodeId'),
|
|
304
|
+
json_extract(value, '$.sequence'), json_extract(value, '$.contentKey'),
|
|
305
|
+
json_extract(value, '$.mediaType'), json_extract(value, '$.contentHash'),
|
|
306
|
+
json_extract(value, '$.size'), json_extract(value, '$.segment'),
|
|
307
|
+
json_extract(value, '$.createdBy'), json_extract(value, '$.createdAt')
|
|
308
|
+
FROM json_each(?)`)
|
|
309
|
+
.bind(JSON.stringify(input.versions)),
|
|
310
|
+
deps.db
|
|
311
|
+
.prepare(`INSERT INTO node_links (
|
|
312
|
+
id, source_node_id, target_node_id, relation, origin, label, created_by, created_at
|
|
313
|
+
) SELECT
|
|
314
|
+
json_extract(value, '$.id'), json_extract(value, '$.sourceNodeId'),
|
|
315
|
+
json_extract(value, '$.targetNodeId'), 'references', 'text', NULL, ?, ?
|
|
316
|
+
FROM json_each(?)`)
|
|
317
|
+
.bind(input.actorId, input.occurredAt, JSON.stringify(input.links)),
|
|
318
|
+
deps.db
|
|
319
|
+
.prepare(`INSERT INTO flows (
|
|
320
|
+
id, parent_id, title, description, owner_id, current_version_id,
|
|
321
|
+
published_version_id, created_at, updated_at, archived_at
|
|
322
|
+
) SELECT
|
|
323
|
+
json_extract(value, '$.id'), json_extract(value, '$.parentId'),
|
|
324
|
+
json_extract(value, '$.title'), json_extract(value, '$.description'),
|
|
325
|
+
json_extract(value, '$.ownerId'), json_extract(value, '$.currentVersionId'),
|
|
326
|
+
json_extract(value, '$.publishedVersionId'), json_extract(value, '$.createdAt'),
|
|
327
|
+
json_extract(value, '$.updatedAt'), NULL
|
|
328
|
+
FROM json_each(?)`)
|
|
329
|
+
.bind(JSON.stringify(input.flows)),
|
|
330
|
+
deps.db
|
|
331
|
+
.prepare(`INSERT INTO flow_versions (
|
|
332
|
+
id, flow_id, sequence, graph_json, created_by, created_at
|
|
333
|
+
) SELECT
|
|
334
|
+
json_extract(value, '$.id'), json_extract(value, '$.flowId'),
|
|
335
|
+
-- json_extract of an object answers with its JSON text, which is exactly what the
|
|
336
|
+
-- graph_json column stores.
|
|
337
|
+
json_extract(value, '$.sequence'), json_extract(value, '$.graph'),
|
|
338
|
+
json_extract(value, '$.createdBy'), json_extract(value, '$.createdAt')
|
|
339
|
+
FROM json_each(?)`)
|
|
340
|
+
.bind(JSON.stringify(input.flowVersions)),
|
|
341
|
+
// ⚠️ The idempotency row stores the AUDIT id, unlike its siblings that store the resource:
|
|
342
|
+
// one import creates many resources, and the collective audit event is where the summary a
|
|
343
|
+
// replay answers with is kept.
|
|
344
|
+
deps.db
|
|
345
|
+
.prepare(`INSERT INTO idempotency_keys (
|
|
346
|
+
actor_id, operation, idempotency_key, resource_id, created_at
|
|
347
|
+
) VALUES (?, 'node.import', ?, ?, ?)`)
|
|
348
|
+
.bind(input.actorId, input.idempotencyKey, input.auditId, input.occurredAt),
|
|
349
|
+
deps.db
|
|
350
|
+
.prepare(`INSERT INTO audit_events (
|
|
351
|
+
id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
|
|
352
|
+
) VALUES (?, ?, 'node.import', 'node', ?, ?, ?)`)
|
|
353
|
+
.bind(input.auditId, input.actorId, input.auditResourceId, JSON.stringify(input.metadata), input.occurredAt),
|
|
354
|
+
];
|
|
355
|
+
try {
|
|
356
|
+
await deps.db.batch(statements);
|
|
357
|
+
return "created";
|
|
358
|
+
}
|
|
359
|
+
catch (error) {
|
|
360
|
+
const replayed = await this.findImportReplay(input.actorId, input.idempotencyKey);
|
|
361
|
+
if (replayed !== null)
|
|
362
|
+
return "replayed";
|
|
363
|
+
throw error;
|
|
364
|
+
}
|
|
365
|
+
},
|
|
366
|
+
async findImportReplay(actorId, idempotencyKey) {
|
|
367
|
+
const row = await deps.db
|
|
368
|
+
.prepare(`SELECT audit.metadata_json AS metadata_json
|
|
369
|
+
FROM idempotency_keys keys
|
|
370
|
+
JOIN audit_events audit ON audit.id = keys.resource_id
|
|
371
|
+
WHERE keys.actor_id = ? AND keys.operation = 'node.import'
|
|
372
|
+
AND keys.idempotency_key = ?`)
|
|
373
|
+
.bind(actorId, idempotencyKey)
|
|
374
|
+
.first();
|
|
375
|
+
if (!row)
|
|
376
|
+
return null;
|
|
377
|
+
const parsed = JSON.parse(row.metadata_json);
|
|
378
|
+
return typeof parsed === "object" && parsed !== null
|
|
379
|
+
? parsed
|
|
380
|
+
: null;
|
|
381
|
+
},
|
|
136
382
|
async can(actor, nodeId, verb) {
|
|
137
383
|
const row = await deps.db
|
|
138
384
|
.prepare(nodeVerbQuery)
|
|
@@ -151,15 +397,26 @@ export function createKnowledgeRepository(deps) {
|
|
|
151
397
|
async findIdempotentRevocation(actorId, idempotencyKey) {
|
|
152
398
|
const row = await deps.db
|
|
153
399
|
.prepare(`SELECT resource_id FROM idempotency_keys
|
|
154
|
-
WHERE actor_id = ? AND operation = '
|
|
400
|
+
WHERE actor_id = ? AND operation = 'node.revoke' AND idempotency_key = ?`)
|
|
155
401
|
.bind(actorId, idempotencyKey)
|
|
156
402
|
.first();
|
|
157
403
|
if (!row)
|
|
158
404
|
return null;
|
|
159
405
|
return row.resource_id.startsWith("1:");
|
|
160
406
|
},
|
|
407
|
+
// ⚠️ Its own statement rather than a column on the node read: four of the five kinds can never
|
|
408
|
+
// have an Application, and the two joins every node read already carries are not going to grow
|
|
409
|
+
// a third for a question only agents ask (#182).
|
|
410
|
+
async agentApplicationId(nodeId) {
|
|
411
|
+
const row = await deps.db
|
|
412
|
+
.prepare("SELECT application_id FROM agent_applications WHERE node_id = ?")
|
|
413
|
+
.bind(nodeId)
|
|
414
|
+
.first();
|
|
415
|
+
return row?.application_id ?? null;
|
|
416
|
+
},
|
|
161
417
|
async insertNode(input) {
|
|
162
418
|
const node = input.node;
|
|
419
|
+
const application = input.application;
|
|
163
420
|
try {
|
|
164
421
|
await deps.db.batch([
|
|
165
422
|
deps.db
|
|
@@ -167,7 +424,7 @@ export function createKnowledgeRepository(deps) {
|
|
|
167
424
|
// without a DEFAULT and D1 will not let it be dropped — migration 0009 carries the
|
|
168
425
|
// reason. The fixed value is the price; nothing reads it, and the contract no longer
|
|
169
426
|
// knows the field. When the column goes (anchrd/intel#86), this line goes with it.
|
|
170
|
-
.prepare(`INSERT INTO
|
|
427
|
+
.prepare(`INSERT INTO nodes (
|
|
171
428
|
id, parent_id, kind, title, description, context_policy, owner_id,
|
|
172
429
|
current_version_id, created_at, updated_at, archived_at
|
|
173
430
|
) VALUES (?, ?, ?, ?, ?, 'relevant', ?, ?, ?, ?, ?)`)
|
|
@@ -175,26 +432,42 @@ export function createKnowledgeRepository(deps) {
|
|
|
175
432
|
deps.db
|
|
176
433
|
.prepare(`INSERT INTO idempotency_keys (
|
|
177
434
|
actor_id, operation, idempotency_key, resource_id, created_at
|
|
178
|
-
) VALUES (?, '
|
|
435
|
+
) VALUES (?, 'node.create', ?, ?, ?)`)
|
|
179
436
|
.bind(input.actorId, input.idempotencyKey, node.id, node.createdAt),
|
|
180
437
|
deps.db
|
|
181
438
|
.prepare(`INSERT INTO audit_events (
|
|
182
439
|
id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
|
|
183
|
-
) VALUES (?, ?, '
|
|
184
|
-
.bind(input.auditId, input.actorId, node.id,
|
|
440
|
+
) VALUES (?, ?, 'node.create', 'node', ?, ?, ?)`)
|
|
441
|
+
.bind(input.auditId, input.actorId, node.id,
|
|
442
|
+
// ⚠️ The Application ID is deliberately NOT in this metadata, and the key is not
|
|
443
|
+
// within reach of it at all. An audit event is read back by people and by the
|
|
444
|
+
// reindexer; what belongs in it is that an agent was created, not the name of the
|
|
445
|
+
// principal behind it — the mapping row below is where that is kept, once (D27).
|
|
446
|
+
JSON.stringify({ kind: node.kind, parentId: node.parentId }), node.createdAt),
|
|
447
|
+
// Last in the batch, and in the SAME batch: D1 runs a batch as one transaction, so the
|
|
448
|
+
// mapping cannot end up without its node or the node without its mapping. An agent whose
|
|
449
|
+
// principal is recorded nowhere is one that archiving would quietly fail to switch off.
|
|
450
|
+
...(application
|
|
451
|
+
? [
|
|
452
|
+
deps.db
|
|
453
|
+
.prepare(`INSERT INTO agent_applications (node_id, application_id, created_at)
|
|
454
|
+
VALUES (?, ?, ?)`)
|
|
455
|
+
.bind(node.id, application.id, node.createdAt),
|
|
456
|
+
]
|
|
457
|
+
: []),
|
|
185
458
|
]);
|
|
186
459
|
}
|
|
187
460
|
catch (error) {
|
|
188
461
|
const replayedKey = await deps.db
|
|
189
462
|
.prepare(`SELECT resource_id FROM idempotency_keys
|
|
190
|
-
WHERE actor_id = ? AND operation = '
|
|
463
|
+
WHERE actor_id = ? AND operation = 'node.create' AND idempotency_key = ?`)
|
|
191
464
|
.bind(input.actorId, input.idempotencyKey)
|
|
192
465
|
.first();
|
|
193
466
|
if (replayedKey) {
|
|
194
467
|
const replayed = await deps.db
|
|
195
468
|
.prepare(`${visibleCte}
|
|
196
469
|
SELECT ${nodeColumns}
|
|
197
|
-
FROM
|
|
470
|
+
FROM nodes n
|
|
198
471
|
JOIN allowed ON allowed.id = n.id
|
|
199
472
|
WHERE n.id = ?`)
|
|
200
473
|
.bind(...readBindings({ id: input.actorId, email: "" }), replayedKey.resource_id)
|
|
@@ -208,33 +481,38 @@ export function createKnowledgeRepository(deps) {
|
|
|
208
481
|
},
|
|
209
482
|
async getVersion(versionId) {
|
|
210
483
|
const row = await deps.db
|
|
211
|
-
.prepare(`SELECT
|
|
212
|
-
size, created_by, created_at FROM knowledge_versions WHERE id = ?`)
|
|
484
|
+
.prepare(`SELECT ${versionColumns} FROM node_versions WHERE id = ?`)
|
|
213
485
|
.bind(versionId)
|
|
214
486
|
.first();
|
|
215
487
|
return row ? mapVersion(row) : null;
|
|
216
488
|
},
|
|
489
|
+
// From the newest snapshot on (#135). `COALESCE(..., 0)` keeps a table from before snapshots
|
|
490
|
+
// readable in full: without any snapshot row every segment still counts.
|
|
217
491
|
async listVersionContentKeys(nodeId) {
|
|
218
492
|
const result = await deps.db
|
|
219
|
-
.prepare(`SELECT content_key FROM
|
|
220
|
-
WHERE node_id = ?
|
|
221
|
-
|
|
493
|
+
.prepare(`SELECT content_key FROM node_versions
|
|
494
|
+
WHERE node_id = ? AND sequence >= COALESCE((
|
|
495
|
+
SELECT MAX(sequence) FROM node_versions
|
|
496
|
+
WHERE node_id = ? AND segment = 'snapshot'
|
|
497
|
+
), 0)
|
|
498
|
+
ORDER BY sequence`)
|
|
499
|
+
.bind(nodeId, nodeId)
|
|
222
500
|
.all();
|
|
223
501
|
return (result.results ?? []).map((row) => row.content_key);
|
|
224
502
|
},
|
|
225
|
-
async
|
|
503
|
+
async tableHeaderContentKey(nodeId) {
|
|
226
504
|
const row = await deps.db
|
|
227
|
-
.prepare(`SELECT content_key FROM
|
|
228
|
-
WHERE node_id = ?
|
|
505
|
+
.prepare(`SELECT content_key FROM node_versions
|
|
506
|
+
WHERE node_id = ? AND segment = 'snapshot'
|
|
507
|
+
ORDER BY sequence DESC LIMIT 1`)
|
|
229
508
|
.bind(nodeId)
|
|
230
509
|
.first();
|
|
231
510
|
return row?.content_key ?? null;
|
|
232
511
|
},
|
|
233
512
|
async listVersions(nodeId) {
|
|
234
513
|
const result = await deps.db
|
|
235
|
-
.prepare(`SELECT
|
|
236
|
-
|
|
237
|
-
FROM knowledge_versions
|
|
514
|
+
.prepare(`SELECT ${versionColumns}
|
|
515
|
+
FROM node_versions
|
|
238
516
|
WHERE node_id = ?
|
|
239
517
|
ORDER BY sequence DESC`)
|
|
240
518
|
.bind(nodeId)
|
|
@@ -247,13 +525,13 @@ export function createKnowledgeRepository(deps) {
|
|
|
247
525
|
await deps.db.batch([
|
|
248
526
|
deps.db
|
|
249
527
|
.prepare(`WITH RECURSIVE descendants(id) AS (
|
|
250
|
-
SELECT id FROM
|
|
528
|
+
SELECT id FROM nodes WHERE id = ?
|
|
251
529
|
UNION ALL
|
|
252
530
|
SELECT child.id
|
|
253
|
-
FROM
|
|
531
|
+
FROM nodes child
|
|
254
532
|
JOIN descendants parent ON child.parent_id = parent.id
|
|
255
533
|
)
|
|
256
|
-
UPDATE
|
|
534
|
+
UPDATE nodes
|
|
257
535
|
SET parent_id = ?, title = ?, description = ?, updated_at = ?
|
|
258
536
|
WHERE id = ? AND updated_at = ?
|
|
259
537
|
AND (? IS NULL OR ? NOT IN (SELECT id FROM descendants))`)
|
|
@@ -261,9 +539,9 @@ export function createKnowledgeRepository(deps) {
|
|
|
261
539
|
deps.db
|
|
262
540
|
.prepare(`INSERT INTO idempotency_keys (
|
|
263
541
|
actor_id, operation, idempotency_key, resource_id, created_at
|
|
264
|
-
) SELECT ?, '
|
|
542
|
+
) SELECT ?, 'node.update', ?, ?, ?
|
|
265
543
|
WHERE EXISTS (
|
|
266
|
-
SELECT 1 FROM
|
|
544
|
+
SELECT 1 FROM nodes
|
|
267
545
|
WHERE id = ? AND parent_id IS ? AND title = ? AND description IS ?
|
|
268
546
|
AND updated_at = ?
|
|
269
547
|
)`)
|
|
@@ -271,10 +549,10 @@ export function createKnowledgeRepository(deps) {
|
|
|
271
549
|
deps.db
|
|
272
550
|
.prepare(`INSERT INTO audit_events (
|
|
273
551
|
id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
|
|
274
|
-
) SELECT ?, ?, '
|
|
552
|
+
) SELECT ?, ?, 'node.update', 'node', ?, ?, ?
|
|
275
553
|
WHERE EXISTS (
|
|
276
554
|
SELECT 1 FROM idempotency_keys
|
|
277
|
-
WHERE actor_id = ? AND operation = '
|
|
555
|
+
WHERE actor_id = ? AND operation = 'node.update'
|
|
278
556
|
AND idempotency_key = ? AND resource_id = ?
|
|
279
557
|
)`)
|
|
280
558
|
.bind(input.auditId, input.actorId, node.id, JSON.stringify({
|
|
@@ -286,7 +564,7 @@ export function createKnowledgeRepository(deps) {
|
|
|
286
564
|
catch (error) {
|
|
287
565
|
const replayed = await deps.db
|
|
288
566
|
.prepare(`SELECT resource_id FROM idempotency_keys
|
|
289
|
-
WHERE actor_id = ? AND operation = '
|
|
567
|
+
WHERE actor_id = ? AND operation = 'node.update' AND idempotency_key = ?`)
|
|
290
568
|
.bind(input.actorId, input.idempotencyKey)
|
|
291
569
|
.first();
|
|
292
570
|
if (!replayed)
|
|
@@ -294,12 +572,12 @@ export function createKnowledgeRepository(deps) {
|
|
|
294
572
|
}
|
|
295
573
|
const replayed = await deps.db
|
|
296
574
|
.prepare(`SELECT resource_id FROM idempotency_keys
|
|
297
|
-
WHERE actor_id = ? AND operation = '
|
|
575
|
+
WHERE actor_id = ? AND operation = 'node.update' AND idempotency_key = ?`)
|
|
298
576
|
.bind(input.actorId, input.idempotencyKey)
|
|
299
577
|
.first();
|
|
300
578
|
if (replayed) {
|
|
301
579
|
const row = await deps.db
|
|
302
|
-
.prepare(`SELECT ${nodeColumns} FROM
|
|
580
|
+
.prepare(`SELECT ${nodeColumns} FROM nodes n WHERE n.id = ?`)
|
|
303
581
|
.bind(replayed.resource_id)
|
|
304
582
|
.first();
|
|
305
583
|
if (row)
|
|
@@ -308,10 +586,10 @@ export function createKnowledgeRepository(deps) {
|
|
|
308
586
|
if (node.parentId !== null) {
|
|
309
587
|
const cycle = await deps.db
|
|
310
588
|
.prepare(`WITH RECURSIVE descendants(id) AS (
|
|
311
|
-
SELECT id FROM
|
|
589
|
+
SELECT id FROM nodes WHERE id = ?
|
|
312
590
|
UNION ALL
|
|
313
591
|
SELECT child.id
|
|
314
|
-
FROM
|
|
592
|
+
FROM nodes child
|
|
315
593
|
JOIN descendants parent ON child.parent_id = parent.id
|
|
316
594
|
)
|
|
317
595
|
SELECT 1 AS found FROM descendants WHERE id = ? LIMIT 1`)
|
|
@@ -326,26 +604,26 @@ export function createKnowledgeRepository(deps) {
|
|
|
326
604
|
try {
|
|
327
605
|
await deps.db.batch([
|
|
328
606
|
deps.db
|
|
329
|
-
.prepare(`UPDATE
|
|
607
|
+
.prepare(`UPDATE nodes
|
|
330
608
|
SET archived_at = ?, updated_at = ?
|
|
331
609
|
WHERE id = ? AND updated_at = ?`)
|
|
332
610
|
.bind(input.archivedAt, input.updatedAt, input.nodeId, input.baseUpdatedAt),
|
|
333
611
|
deps.db
|
|
334
612
|
.prepare(`INSERT INTO idempotency_keys (
|
|
335
613
|
actor_id, operation, idempotency_key, resource_id, created_at
|
|
336
|
-
) SELECT ?, '
|
|
614
|
+
) SELECT ?, 'node.archive', ?, ?, ?
|
|
337
615
|
WHERE EXISTS (
|
|
338
|
-
SELECT 1 FROM
|
|
616
|
+
SELECT 1 FROM nodes
|
|
339
617
|
WHERE id = ? AND archived_at IS ? AND updated_at = ?
|
|
340
618
|
)`)
|
|
341
619
|
.bind(input.actorId, input.idempotencyKey, input.nodeId, input.updatedAt, input.nodeId, input.archivedAt, input.updatedAt),
|
|
342
620
|
deps.db
|
|
343
621
|
.prepare(`INSERT INTO audit_events (
|
|
344
622
|
id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
|
|
345
|
-
) SELECT ?, ?, '
|
|
623
|
+
) SELECT ?, ?, 'node.archive', 'node', ?, ?, ?
|
|
346
624
|
WHERE EXISTS (
|
|
347
625
|
SELECT 1 FROM idempotency_keys
|
|
348
|
-
WHERE actor_id = ? AND operation = '
|
|
626
|
+
WHERE actor_id = ? AND operation = 'node.archive'
|
|
349
627
|
AND idempotency_key = ? AND resource_id = ?
|
|
350
628
|
)`)
|
|
351
629
|
.bind(input.auditId, input.actorId, input.nodeId, JSON.stringify({ archived: input.archivedAt !== null }), input.updatedAt, input.actorId, input.idempotencyKey, input.nodeId),
|
|
@@ -354,7 +632,7 @@ export function createKnowledgeRepository(deps) {
|
|
|
354
632
|
catch (error) {
|
|
355
633
|
const replayed = await deps.db
|
|
356
634
|
.prepare(`SELECT resource_id FROM idempotency_keys
|
|
357
|
-
WHERE actor_id = ? AND operation = '
|
|
635
|
+
WHERE actor_id = ? AND operation = 'node.archive' AND idempotency_key = ?`)
|
|
358
636
|
.bind(input.actorId, input.idempotencyKey)
|
|
359
637
|
.first();
|
|
360
638
|
if (!replayed)
|
|
@@ -362,13 +640,13 @@ export function createKnowledgeRepository(deps) {
|
|
|
362
640
|
}
|
|
363
641
|
const replayed = await deps.db
|
|
364
642
|
.prepare(`SELECT resource_id FROM idempotency_keys
|
|
365
|
-
WHERE actor_id = ? AND operation = '
|
|
643
|
+
WHERE actor_id = ? AND operation = 'node.archive' AND idempotency_key = ?`)
|
|
366
644
|
.bind(input.actorId, input.idempotencyKey)
|
|
367
645
|
.first();
|
|
368
646
|
if (!replayed)
|
|
369
647
|
return "conflict";
|
|
370
648
|
const row = await deps.db
|
|
371
|
-
.prepare(`SELECT ${nodeColumns} FROM
|
|
649
|
+
.prepare(`SELECT ${nodeColumns} FROM nodes n WHERE n.id = ?`)
|
|
372
650
|
.bind(replayed.resource_id)
|
|
373
651
|
.first();
|
|
374
652
|
return row ? mapNode(row) : "conflict";
|
|
@@ -377,38 +655,39 @@ export function createKnowledgeRepository(deps) {
|
|
|
377
655
|
const version = input.version;
|
|
378
656
|
await deps.db.batch([
|
|
379
657
|
deps.db
|
|
380
|
-
.prepare(`INSERT INTO
|
|
381
|
-
id, node_id, sequence, content_key, media_type, content_hash, size,
|
|
382
|
-
|
|
383
|
-
|
|
658
|
+
.prepare(`INSERT INTO node_versions (
|
|
659
|
+
id, node_id, sequence, content_key, media_type, content_hash, size, segment,
|
|
660
|
+
created_by, created_at
|
|
661
|
+
) SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
|
662
|
+
FROM nodes
|
|
384
663
|
WHERE id = ? AND current_version_id IS ?`)
|
|
385
|
-
.bind(version.id, version.nodeId, version.sequence, version.contentKey, version.mediaType, version.contentHash, version.size, version.createdBy, version.createdAt, version.nodeId, input.baseVersionId),
|
|
664
|
+
.bind(version.id, version.nodeId, version.sequence, version.contentKey, version.mediaType, version.contentHash, version.size, version.segment, version.createdBy, version.createdAt, version.nodeId, input.baseVersionId),
|
|
386
665
|
deps.db
|
|
387
|
-
.prepare(`UPDATE
|
|
666
|
+
.prepare(`UPDATE nodes
|
|
388
667
|
SET current_version_id = ?, updated_at = ?
|
|
389
|
-
WHERE id = ? AND EXISTS (SELECT 1 FROM
|
|
668
|
+
WHERE id = ? AND EXISTS (SELECT 1 FROM node_versions WHERE id = ?)`)
|
|
390
669
|
.bind(version.id, version.createdAt, version.nodeId, version.id),
|
|
391
670
|
deps.db
|
|
392
671
|
.prepare(`INSERT INTO idempotency_keys (
|
|
393
672
|
actor_id, operation, idempotency_key, resource_id, created_at
|
|
394
|
-
) SELECT ?, '
|
|
395
|
-
WHERE EXISTS (SELECT 1 FROM
|
|
673
|
+
) SELECT ?, 'node.save', ?, ?, ?
|
|
674
|
+
WHERE EXISTS (SELECT 1 FROM node_versions WHERE id = ?)`)
|
|
396
675
|
.bind(input.actorId, input.idempotencyKey, version.id, version.createdAt, version.id),
|
|
397
676
|
deps.db
|
|
398
|
-
.prepare(`INSERT INTO
|
|
677
|
+
.prepare(`INSERT INTO node_index_state (
|
|
399
678
|
version_id, status, attempt_count, last_error, updated_at
|
|
400
679
|
) SELECT ?, 'pending', 0, NULL, ?
|
|
401
|
-
WHERE EXISTS (SELECT 1 FROM
|
|
680
|
+
WHERE EXISTS (SELECT 1 FROM node_versions WHERE id = ?)`)
|
|
402
681
|
.bind(version.id, version.createdAt, version.id),
|
|
403
682
|
deps.db
|
|
404
683
|
.prepare(`INSERT INTO audit_events (
|
|
405
684
|
id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
|
|
406
|
-
) SELECT ?, ?, '
|
|
407
|
-
WHERE EXISTS (SELECT 1 FROM
|
|
685
|
+
) SELECT ?, ?, 'node.save', 'node', ?, ?, ?
|
|
686
|
+
WHERE EXISTS (SELECT 1 FROM node_versions WHERE id = ?)`)
|
|
408
687
|
.bind(input.auditId, input.actorId, version.nodeId, JSON.stringify({ versionId: version.id, sequence: version.sequence }), version.createdAt, version.id),
|
|
409
688
|
]);
|
|
410
689
|
const row = await deps.db
|
|
411
|
-
.prepare("SELECT current_version_id FROM
|
|
690
|
+
.prepare("SELECT current_version_id FROM nodes WHERE id = ?")
|
|
412
691
|
.bind(version.nodeId)
|
|
413
692
|
.first();
|
|
414
693
|
return row?.current_version_id === version.id ? "saved" : "conflict";
|
|
@@ -427,51 +706,50 @@ export function createKnowledgeRepository(deps) {
|
|
|
427
706
|
async appendTableVersion(input) {
|
|
428
707
|
const version = input.version;
|
|
429
708
|
const stored = async () => await deps.db
|
|
430
|
-
.prepare(`SELECT
|
|
431
|
-
size, created_by, created_at FROM knowledge_versions WHERE id = ?`)
|
|
709
|
+
.prepare(`SELECT ${versionColumns} FROM node_versions WHERE id = ?`)
|
|
432
710
|
.bind(version.id)
|
|
433
711
|
.first();
|
|
434
712
|
try {
|
|
435
713
|
await deps.db.batch([
|
|
436
714
|
deps.db
|
|
437
|
-
.prepare(`INSERT INTO
|
|
438
|
-
id, node_id, sequence, content_key, media_type, content_hash, size,
|
|
715
|
+
.prepare(`INSERT INTO node_versions (
|
|
716
|
+
id, node_id, sequence, content_key, media_type, content_hash, size, segment,
|
|
439
717
|
created_by, created_at
|
|
440
|
-
) SELECT ?, ?, COALESCE(MAX(sequence), 0) + 1, ?, ?, ?, ?, ?, ?
|
|
441
|
-
FROM
|
|
442
|
-
.bind(version.id, version.nodeId, version.contentKey, version.mediaType, version.contentHash, version.size, version.createdBy, version.createdAt, version.nodeId),
|
|
718
|
+
) SELECT ?, ?, COALESCE(MAX(sequence), 0) + 1, ?, ?, ?, ?, ?, ?, ?
|
|
719
|
+
FROM node_versions WHERE node_id = ?`)
|
|
720
|
+
.bind(version.id, version.nodeId, version.contentKey, version.mediaType, version.contentHash, version.size, version.segment, version.createdBy, version.createdAt, version.nodeId),
|
|
443
721
|
deps.db
|
|
444
|
-
.prepare(`UPDATE
|
|
722
|
+
.prepare(`UPDATE nodes
|
|
445
723
|
SET current_version_id = ?, updated_at = ?
|
|
446
724
|
WHERE id = ?
|
|
447
|
-
AND EXISTS (SELECT 1 FROM
|
|
725
|
+
AND EXISTS (SELECT 1 FROM node_versions WHERE id = ?)
|
|
448
726
|
AND (
|
|
449
727
|
current_version_id IS NULL
|
|
450
728
|
OR COALESCE((
|
|
451
|
-
SELECT sequence FROM
|
|
452
|
-
WHERE id =
|
|
453
|
-
), 0) < (SELECT sequence FROM
|
|
729
|
+
SELECT sequence FROM node_versions
|
|
730
|
+
WHERE id = nodes.current_version_id
|
|
731
|
+
), 0) < (SELECT sequence FROM node_versions WHERE id = ?)
|
|
454
732
|
)`)
|
|
455
733
|
.bind(version.id, version.createdAt, version.nodeId, version.id, version.id),
|
|
456
734
|
deps.db
|
|
457
735
|
.prepare(`INSERT INTO idempotency_keys (
|
|
458
736
|
actor_id, operation, idempotency_key, resource_id, created_at
|
|
459
|
-
) SELECT ?, '
|
|
460
|
-
WHERE EXISTS (SELECT 1 FROM
|
|
737
|
+
) SELECT ?, 'node.append', ?, ?, ?
|
|
738
|
+
WHERE EXISTS (SELECT 1 FROM node_versions WHERE id = ?)`)
|
|
461
739
|
.bind(input.actorId, input.idempotencyKey, version.id, version.createdAt, version.id),
|
|
462
740
|
deps.db
|
|
463
|
-
.prepare(`INSERT INTO
|
|
741
|
+
.prepare(`INSERT INTO node_index_state (
|
|
464
742
|
version_id, status, attempt_count, last_error, updated_at
|
|
465
743
|
) SELECT ?, 'pending', 0, NULL, ?
|
|
466
|
-
WHERE EXISTS (SELECT 1 FROM
|
|
744
|
+
WHERE EXISTS (SELECT 1 FROM node_versions WHERE id = ?)`)
|
|
467
745
|
.bind(version.id, version.createdAt, version.id),
|
|
468
746
|
deps.db
|
|
469
747
|
.prepare(`INSERT INTO audit_events (
|
|
470
748
|
id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
|
|
471
|
-
) SELECT ?, ?, '
|
|
749
|
+
) SELECT ?, ?, 'node.append', 'node', ?, json_object(
|
|
472
750
|
'versionId', ?, 'size', ?
|
|
473
751
|
), ?
|
|
474
|
-
WHERE EXISTS (SELECT 1 FROM
|
|
752
|
+
WHERE EXISTS (SELECT 1 FROM node_versions WHERE id = ?)`)
|
|
475
753
|
.bind(input.auditId, input.actorId, version.nodeId, version.id, version.size, version.createdAt, version.id),
|
|
476
754
|
]);
|
|
477
755
|
}
|
|
@@ -480,13 +758,12 @@ export function createKnowledgeRepository(deps) {
|
|
|
480
758
|
// answers with it rather than with a failure the caller cannot act on.
|
|
481
759
|
const replayed = await deps.db
|
|
482
760
|
.prepare(`SELECT resource_id FROM idempotency_keys
|
|
483
|
-
WHERE actor_id = ? AND operation = '
|
|
761
|
+
WHERE actor_id = ? AND operation = 'node.append' AND idempotency_key = ?`)
|
|
484
762
|
.bind(input.actorId, input.idempotencyKey)
|
|
485
763
|
.first();
|
|
486
764
|
const row = replayed
|
|
487
765
|
? await deps.db
|
|
488
|
-
.prepare(`SELECT
|
|
489
|
-
size, created_by, created_at FROM knowledge_versions WHERE id = ?`)
|
|
766
|
+
.prepare(`SELECT ${versionColumns} FROM node_versions WHERE id = ?`)
|
|
490
767
|
.bind(replayed.resource_id)
|
|
491
768
|
.first()
|
|
492
769
|
: null;
|
|
@@ -496,9 +773,61 @@ export function createKnowledgeRepository(deps) {
|
|
|
496
773
|
}
|
|
497
774
|
const row = await stored();
|
|
498
775
|
if (!row)
|
|
499
|
-
throw new Error("
|
|
776
|
+
throw new Error("Node table append did not store a version");
|
|
500
777
|
return mapVersion(row);
|
|
501
778
|
},
|
|
779
|
+
/**
|
|
780
|
+
* One snapshot of a table, written only against the state it replaces (#135).
|
|
781
|
+
*
|
|
782
|
+
* ⚠️ The shape of `appendVersion`, not of the append above it: the base check sits inside the
|
|
783
|
+
* INSERT's own WHERE, so a segment that landed between the service's read and this batch makes
|
|
784
|
+
* the INSERT store nothing and every guarded statement after it a no-op. The service reads the
|
|
785
|
+
* answer the same way `save` does — by asking whether the node now stands on the new version.
|
|
786
|
+
*
|
|
787
|
+
* The idempotency row carries the mutation's own operation, so a replayed update can never be
|
|
788
|
+
* confused with a delete that reused the key, and the audit trail names what was done.
|
|
789
|
+
*/
|
|
790
|
+
async appendTableSnapshot(input) {
|
|
791
|
+
const version = input.version;
|
|
792
|
+
await deps.db.batch([
|
|
793
|
+
deps.db
|
|
794
|
+
.prepare(`INSERT INTO node_versions (
|
|
795
|
+
id, node_id, sequence, content_key, media_type, content_hash, size, segment,
|
|
796
|
+
created_by, created_at
|
|
797
|
+
) SELECT ?, ?, ?, ?, ?, ?, ?, 'snapshot', ?, ?
|
|
798
|
+
FROM nodes
|
|
799
|
+
WHERE id = ? AND current_version_id IS ?`)
|
|
800
|
+
.bind(version.id, version.nodeId, version.sequence, version.contentKey, version.mediaType, version.contentHash, version.size, version.createdBy, version.createdAt, version.nodeId, input.baseVersionId),
|
|
801
|
+
deps.db
|
|
802
|
+
.prepare(`UPDATE nodes
|
|
803
|
+
SET current_version_id = ?, updated_at = ?
|
|
804
|
+
WHERE id = ? AND EXISTS (SELECT 1 FROM node_versions WHERE id = ?)`)
|
|
805
|
+
.bind(version.id, version.createdAt, version.nodeId, version.id),
|
|
806
|
+
deps.db
|
|
807
|
+
.prepare(`INSERT INTO idempotency_keys (
|
|
808
|
+
actor_id, operation, idempotency_key, resource_id, created_at
|
|
809
|
+
) SELECT ?, ?, ?, ?, ?
|
|
810
|
+
WHERE EXISTS (SELECT 1 FROM node_versions WHERE id = ?)`)
|
|
811
|
+
.bind(input.actorId, input.operation, input.idempotencyKey, version.id, version.createdAt, version.id),
|
|
812
|
+
deps.db
|
|
813
|
+
.prepare(`INSERT INTO node_index_state (
|
|
814
|
+
version_id, status, attempt_count, last_error, updated_at
|
|
815
|
+
) SELECT ?, 'pending', 0, NULL, ?
|
|
816
|
+
WHERE EXISTS (SELECT 1 FROM node_versions WHERE id = ?)`)
|
|
817
|
+
.bind(version.id, version.createdAt, version.id),
|
|
818
|
+
deps.db
|
|
819
|
+
.prepare(`INSERT INTO audit_events (
|
|
820
|
+
id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
|
|
821
|
+
) SELECT ?, ?, ?, 'node', ?, ?, ?
|
|
822
|
+
WHERE EXISTS (SELECT 1 FROM node_versions WHERE id = ?)`)
|
|
823
|
+
.bind(input.auditId, input.actorId, input.operation, version.nodeId, JSON.stringify({ versionId: version.id, ...input.metadata }), version.createdAt, version.id),
|
|
824
|
+
]);
|
|
825
|
+
const row = await deps.db
|
|
826
|
+
.prepare("SELECT current_version_id FROM nodes WHERE id = ?")
|
|
827
|
+
.bind(version.nodeId)
|
|
828
|
+
.first();
|
|
829
|
+
return row?.current_version_id === version.id ? "saved" : "conflict";
|
|
830
|
+
},
|
|
502
831
|
async listGrants(resourceId) {
|
|
503
832
|
const result = await deps.db
|
|
504
833
|
.prepare(`SELECT ${grantColumns} FROM tree_grants
|
|
@@ -513,10 +842,10 @@ export function createKnowledgeRepository(deps) {
|
|
|
513
842
|
async organizationExecuteReaches(nodeId, exceptGrantId) {
|
|
514
843
|
const row = await deps.db
|
|
515
844
|
.prepare(`WITH RECURSIVE ancestors(id, parent_id) AS (
|
|
516
|
-
SELECT id, parent_id FROM
|
|
845
|
+
SELECT id, parent_id FROM nodes WHERE id = ?
|
|
517
846
|
UNION
|
|
518
847
|
SELECT parent.id, parent.parent_id
|
|
519
|
-
FROM
|
|
848
|
+
FROM nodes parent
|
|
520
849
|
JOIN ancestors child ON child.parent_id = parent.id
|
|
521
850
|
)
|
|
522
851
|
SELECT 1 AS reaches
|
|
@@ -535,7 +864,7 @@ export function createKnowledgeRepository(deps) {
|
|
|
535
864
|
const result = await deps.db
|
|
536
865
|
.prepare(`${visibleCte}
|
|
537
866
|
SELECT ${linkColumns}
|
|
538
|
-
FROM
|
|
867
|
+
FROM node_links link
|
|
539
868
|
JOIN allowed source_access ON source_access.id = link.source_node_id
|
|
540
869
|
JOIN allowed target_access ON target_access.id = link.target_node_id
|
|
541
870
|
WHERE link.source_node_id = ? OR link.target_node_id = ?
|
|
@@ -562,7 +891,7 @@ export function createKnowledgeRepository(deps) {
|
|
|
562
891
|
const result = await deps.db
|
|
563
892
|
.prepare(`${visibleCte}
|
|
564
893
|
SELECT n.id, n.title
|
|
565
|
-
FROM
|
|
894
|
+
FROM nodes n
|
|
566
895
|
JOIN allowed ON allowed.id = n.id
|
|
567
896
|
WHERE n.id IN (${placeholders}) AND n.archived_at IS NULL`)
|
|
568
897
|
.bind(...readBindings(actor), ...ids)
|
|
@@ -584,10 +913,10 @@ export function createKnowledgeRepository(deps) {
|
|
|
584
913
|
async replaceTextLinks(input) {
|
|
585
914
|
await deps.db.batch([
|
|
586
915
|
deps.db
|
|
587
|
-
.prepare("DELETE FROM
|
|
916
|
+
.prepare("DELETE FROM node_links WHERE source_node_id = ? AND origin = 'text'")
|
|
588
917
|
.bind(input.sourceNodeId),
|
|
589
918
|
...input.links.map((link) => deps.db
|
|
590
|
-
.prepare(`INSERT INTO
|
|
919
|
+
.prepare(`INSERT INTO node_links (
|
|
591
920
|
id, source_node_id, target_node_id, relation, origin, label, created_by, created_at
|
|
592
921
|
) VALUES (?, ?, ?, 'references', 'text', NULL, ?, ?)
|
|
593
922
|
ON CONFLICT (source_node_id, target_node_id, relation) DO NOTHING`)
|
|
@@ -595,7 +924,7 @@ export function createKnowledgeRepository(deps) {
|
|
|
595
924
|
deps.db
|
|
596
925
|
.prepare(`INSERT INTO audit_events (
|
|
597
926
|
id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
|
|
598
|
-
) VALUES (?, ?, '
|
|
927
|
+
) VALUES (?, ?, 'node.link', 'node', ?, ?, ?)`)
|
|
599
928
|
.bind(input.auditId, input.actorId, input.sourceNodeId, JSON.stringify({ targetNodeIds: input.links.map((link) => link.targetNodeId) }), input.occurredAt),
|
|
600
929
|
]);
|
|
601
930
|
},
|
|
@@ -603,7 +932,7 @@ export function createKnowledgeRepository(deps) {
|
|
|
603
932
|
const nodeResult = await deps.db
|
|
604
933
|
.prepare(`${visibleCte}
|
|
605
934
|
SELECT ${nodeColumns}
|
|
606
|
-
FROM
|
|
935
|
+
FROM nodes n
|
|
607
936
|
JOIN allowed ON allowed.id = n.id
|
|
608
937
|
WHERE n.archived_at IS NULL
|
|
609
938
|
ORDER BY lower(n.title), n.id
|
|
@@ -615,14 +944,14 @@ export function createKnowledgeRepository(deps) {
|
|
|
615
944
|
return { nodes, links: [] };
|
|
616
945
|
const linkResult = await deps.db
|
|
617
946
|
.prepare(`${visibleCte}, graph_nodes(id) AS (
|
|
618
|
-
SELECT n.id FROM
|
|
947
|
+
SELECT n.id FROM nodes n
|
|
619
948
|
JOIN allowed ON allowed.id = n.id
|
|
620
949
|
WHERE n.archived_at IS NULL
|
|
621
950
|
ORDER BY lower(n.title), n.id
|
|
622
951
|
LIMIT ?
|
|
623
952
|
)
|
|
624
953
|
SELECT ${linkColumns}
|
|
625
|
-
FROM
|
|
954
|
+
FROM node_links link
|
|
626
955
|
JOIN graph_nodes source_node ON source_node.id = link.source_node_id
|
|
627
956
|
JOIN graph_nodes target_node ON target_node.id = link.target_node_id
|
|
628
957
|
ORDER BY link.created_at, link.id`)
|
|
@@ -653,13 +982,13 @@ export function createKnowledgeRepository(deps) {
|
|
|
653
982
|
deps.db
|
|
654
983
|
.prepare(`INSERT INTO idempotency_keys (
|
|
655
984
|
actor_id, operation, idempotency_key, resource_id, created_at
|
|
656
|
-
) SELECT ?, '
|
|
985
|
+
) SELECT ?, 'node.share', ?, id, ? FROM tree_grants
|
|
657
986
|
WHERE node_id = ? AND principal_type = ? AND principal_id = ? AND verb = ?`)
|
|
658
987
|
.bind(input.actorId, input.idempotencyKey, grant.createdAt, grant.resourceId, principalType, principalId, grant.verb),
|
|
659
988
|
deps.db
|
|
660
989
|
.prepare(`INSERT INTO audit_events (
|
|
661
990
|
id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
|
|
662
|
-
) VALUES (?, ?, '
|
|
991
|
+
) VALUES (?, ?, 'node.share', 'node', ?, ?, ?)`)
|
|
663
992
|
.bind(input.auditId, input.actorId, grant.resourceId, JSON.stringify({ principalType, verb: grant.verb }), grant.createdAt),
|
|
664
993
|
]);
|
|
665
994
|
const stored = await deps.db
|
|
@@ -668,13 +997,13 @@ export function createKnowledgeRepository(deps) {
|
|
|
668
997
|
.bind(grant.resourceId, principalType, principalId, grant.verb)
|
|
669
998
|
.first();
|
|
670
999
|
if (!stored)
|
|
671
|
-
throw new Error("
|
|
1000
|
+
throw new Error("Node grant disappeared after upsert");
|
|
672
1001
|
return mapGrant(stored);
|
|
673
1002
|
}
|
|
674
1003
|
catch (error) {
|
|
675
1004
|
const replayed = await deps.db
|
|
676
1005
|
.prepare(`SELECT resource_id FROM idempotency_keys
|
|
677
|
-
WHERE actor_id = ? AND operation = '
|
|
1006
|
+
WHERE actor_id = ? AND operation = 'node.share' AND idempotency_key = ?`)
|
|
678
1007
|
.bind(input.actorId, input.idempotencyKey)
|
|
679
1008
|
.first();
|
|
680
1009
|
if (replayed) {
|
|
@@ -694,7 +1023,7 @@ export function createKnowledgeRepository(deps) {
|
|
|
694
1023
|
deps.db
|
|
695
1024
|
.prepare(`INSERT INTO idempotency_keys (
|
|
696
1025
|
actor_id, operation, idempotency_key, resource_id, created_at
|
|
697
|
-
) SELECT ?, '
|
|
1026
|
+
) SELECT ?, 'node.revoke', ?,
|
|
698
1027
|
(CASE WHEN EXISTS (
|
|
699
1028
|
SELECT 1 FROM tree_grants WHERE id = ? AND node_id = ?
|
|
700
1029
|
) THEN '1:' ELSE '0:' END) || ?, ?`)
|
|
@@ -705,13 +1034,13 @@ export function createKnowledgeRepository(deps) {
|
|
|
705
1034
|
deps.db
|
|
706
1035
|
.prepare(`INSERT INTO audit_events (
|
|
707
1036
|
id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
|
|
708
|
-
) SELECT ?, ?, '
|
|
1037
|
+
) SELECT ?, ?, 'node.revoke', 'node', ?,
|
|
709
1038
|
json_object(
|
|
710
1039
|
'grantId', ?,
|
|
711
1040
|
'revoked', json(CASE WHEN substr(resource_id, 1, 2) = '1:' THEN 'true' ELSE 'false' END)
|
|
712
1041
|
), ?
|
|
713
1042
|
FROM idempotency_keys
|
|
714
|
-
WHERE actor_id = ? AND operation = '
|
|
1043
|
+
WHERE actor_id = ? AND operation = 'node.revoke' AND idempotency_key = ?`)
|
|
715
1044
|
.bind(input.auditId, input.actorId, input.resourceId, input.grantId, input.occurredAt, input.actorId, input.idempotencyKey),
|
|
716
1045
|
]);
|
|
717
1046
|
}
|
|
@@ -728,33 +1057,33 @@ export function createKnowledgeRepository(deps) {
|
|
|
728
1057
|
if (!query)
|
|
729
1058
|
return [];
|
|
730
1059
|
const result = await deps.db
|
|
731
|
-
.prepare(`${visibleCte}
|
|
1060
|
+
.prepare(`${visibleCte}${scopeCte(input.scopeId)}
|
|
732
1061
|
SELECT n.id AS node_id, v.id AS version_id, n.title,
|
|
733
|
-
snippet(
|
|
734
|
-
n.updated_at AS freshness, bm25(
|
|
735
|
-
FROM
|
|
736
|
-
JOIN
|
|
737
|
-
JOIN
|
|
738
|
-
JOIN allowed ON allowed.id = n.id
|
|
739
|
-
WHERE
|
|
1062
|
+
snippet(node_fts, 3, '', '', ' … ', 32) AS passage,
|
|
1063
|
+
n.updated_at AS freshness, bm25(node_fts, 0.0, 0.0, 4.0, 1.0) AS rank
|
|
1064
|
+
FROM node_fts
|
|
1065
|
+
JOIN nodes n ON n.id = node_fts.node_id
|
|
1066
|
+
JOIN node_versions v ON v.id = node_fts.version_id
|
|
1067
|
+
JOIN allowed ON allowed.id = n.id${scopeJoin(input.scopeId)}
|
|
1068
|
+
WHERE node_fts MATCH ?
|
|
740
1069
|
AND n.current_version_id = v.id
|
|
741
1070
|
AND n.archived_at IS NULL
|
|
742
1071
|
ORDER BY rank, n.updated_at DESC
|
|
743
1072
|
LIMIT ?`)
|
|
744
|
-
.bind(...readBindings(actor), query, input.limit)
|
|
1073
|
+
.bind(...readBindings(actor), ...scopeBindings(input.scopeId), query, input.limit)
|
|
745
1074
|
.all();
|
|
746
1075
|
return (result.results ?? []).map((row) => ({
|
|
747
1076
|
nodeId: row.node_id,
|
|
748
1077
|
versionId: row.version_id,
|
|
749
1078
|
title: row.title,
|
|
750
1079
|
passage: row.passage,
|
|
751
|
-
source: `intel://
|
|
1080
|
+
source: `intel://nodes/${encodeURIComponent(row.node_id)}?version=${encodeURIComponent(row.version_id)}`,
|
|
752
1081
|
freshness: row.freshness,
|
|
753
1082
|
score: 1 / (1 + Math.abs(row.rank)),
|
|
754
1083
|
match: "lexical",
|
|
755
1084
|
}));
|
|
756
1085
|
},
|
|
757
|
-
async hydrateVisibleCitations(actor, nodeIds) {
|
|
1086
|
+
async hydrateVisibleCitations(actor, nodeIds, scopeId) {
|
|
758
1087
|
const uniqueIds = [...new Set(nodeIds)].slice(0, 100);
|
|
759
1088
|
if (uniqueIds.length === 0)
|
|
760
1089
|
return [];
|
|
@@ -763,16 +1092,16 @@ export function createKnowledgeRepository(deps) {
|
|
|
763
1092
|
const ids = uniqueIds.slice(offset, offset + 96);
|
|
764
1093
|
const placeholders = ids.map(() => "?").join(", ");
|
|
765
1094
|
const result = await deps.db
|
|
766
|
-
.prepare(`${visibleCte}
|
|
1095
|
+
.prepare(`${visibleCte}${scopeCte(scopeId)}
|
|
767
1096
|
SELECT n.id AS node_id, v.id AS version_id, n.title,
|
|
768
|
-
substr(
|
|
1097
|
+
substr(node_fts.content, 1, 480) AS passage,
|
|
769
1098
|
n.updated_at AS freshness
|
|
770
|
-
FROM
|
|
771
|
-
JOIN
|
|
772
|
-
JOIN
|
|
773
|
-
JOIN allowed ON allowed.id = n.id
|
|
1099
|
+
FROM nodes n
|
|
1100
|
+
JOIN node_versions v ON v.id = n.current_version_id
|
|
1101
|
+
JOIN node_fts ON node_fts.version_id = v.id
|
|
1102
|
+
JOIN allowed ON allowed.id = n.id${scopeJoin(scopeId)}
|
|
774
1103
|
WHERE n.id IN (${placeholders}) AND n.archived_at IS NULL`)
|
|
775
|
-
.bind(...readBindings(actor), ...ids)
|
|
1104
|
+
.bind(...readBindings(actor), ...scopeBindings(scopeId), ...ids)
|
|
776
1105
|
.all();
|
|
777
1106
|
rows.push(...(result.results ?? []));
|
|
778
1107
|
}
|
|
@@ -783,7 +1112,7 @@ export function createKnowledgeRepository(deps) {
|
|
|
783
1112
|
versionId: row.version_id,
|
|
784
1113
|
title: row.title,
|
|
785
1114
|
passage: row.passage,
|
|
786
|
-
source: `intel://
|
|
1115
|
+
source: `intel://nodes/${encodeURIComponent(row.node_id)}?version=${encodeURIComponent(row.version_id)}`,
|
|
787
1116
|
freshness: row.freshness,
|
|
788
1117
|
score: 0,
|
|
789
1118
|
match: "semantic",
|
|
@@ -791,7 +1120,7 @@ export function createKnowledgeRepository(deps) {
|
|
|
791
1120
|
},
|
|
792
1121
|
async listCurrentVersionIds(input) {
|
|
793
1122
|
const result = await deps.db
|
|
794
|
-
.prepare(`SELECT current_version_id AS id FROM
|
|
1123
|
+
.prepare(`SELECT current_version_id AS id FROM nodes
|
|
795
1124
|
WHERE current_version_id IS NOT NULL AND archived_at IS NULL
|
|
796
1125
|
AND (? IS NULL OR current_version_id > ?)
|
|
797
1126
|
ORDER BY id LIMIT ?`)
|