@anchrd/intel-api 0.6.7 → 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 +148 -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 +431 -118
- 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 +324 -61
- 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,11 +110,11 @@ 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 ?" : ""}`;
|
|
@@ -114,11 +130,70 @@ export function createKnowledgeRepository(deps) {
|
|
|
114
130
|
*/
|
|
115
131
|
const archivedEverywhere = `${visibleCte}
|
|
116
132
|
SELECT ${nodeColumns}, 0 AS has_children
|
|
117
|
-
FROM
|
|
133
|
+
FROM nodes n
|
|
118
134
|
JOIN allowed ON allowed.id = n.id
|
|
119
135
|
WHERE n.archived_at IS NOT NULL
|
|
120
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] };
|
|
121
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
|
+
},
|
|
122
197
|
async listVisible(actor, input) {
|
|
123
198
|
const result = await deps.db
|
|
124
199
|
.prepare(input.archivedOnly ? archivedEverywhere : visibleChildren(false))
|
|
@@ -142,13 +217,168 @@ export function createKnowledgeRepository(deps) {
|
|
|
142
217
|
const row = await deps.db
|
|
143
218
|
.prepare(`${visibleCte}
|
|
144
219
|
SELECT ${nodeColumns}
|
|
145
|
-
FROM
|
|
220
|
+
FROM nodes n
|
|
146
221
|
JOIN allowed ON allowed.id = n.id
|
|
147
222
|
WHERE n.id = ?`)
|
|
148
223
|
.bind(...readBindings(actor), nodeId)
|
|
149
224
|
.first();
|
|
150
225
|
return row ? mapNode(row) : null;
|
|
151
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
|
+
},
|
|
152
382
|
async can(actor, nodeId, verb) {
|
|
153
383
|
const row = await deps.db
|
|
154
384
|
.prepare(nodeVerbQuery)
|
|
@@ -167,15 +397,26 @@ export function createKnowledgeRepository(deps) {
|
|
|
167
397
|
async findIdempotentRevocation(actorId, idempotencyKey) {
|
|
168
398
|
const row = await deps.db
|
|
169
399
|
.prepare(`SELECT resource_id FROM idempotency_keys
|
|
170
|
-
WHERE actor_id = ? AND operation = '
|
|
400
|
+
WHERE actor_id = ? AND operation = 'node.revoke' AND idempotency_key = ?`)
|
|
171
401
|
.bind(actorId, idempotencyKey)
|
|
172
402
|
.first();
|
|
173
403
|
if (!row)
|
|
174
404
|
return null;
|
|
175
405
|
return row.resource_id.startsWith("1:");
|
|
176
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
|
+
},
|
|
177
417
|
async insertNode(input) {
|
|
178
418
|
const node = input.node;
|
|
419
|
+
const application = input.application;
|
|
179
420
|
try {
|
|
180
421
|
await deps.db.batch([
|
|
181
422
|
deps.db
|
|
@@ -183,7 +424,7 @@ export function createKnowledgeRepository(deps) {
|
|
|
183
424
|
// without a DEFAULT and D1 will not let it be dropped — migration 0009 carries the
|
|
184
425
|
// reason. The fixed value is the price; nothing reads it, and the contract no longer
|
|
185
426
|
// knows the field. When the column goes (anchrd/intel#86), this line goes with it.
|
|
186
|
-
.prepare(`INSERT INTO
|
|
427
|
+
.prepare(`INSERT INTO nodes (
|
|
187
428
|
id, parent_id, kind, title, description, context_policy, owner_id,
|
|
188
429
|
current_version_id, created_at, updated_at, archived_at
|
|
189
430
|
) VALUES (?, ?, ?, ?, ?, 'relevant', ?, ?, ?, ?, ?)`)
|
|
@@ -191,26 +432,42 @@ export function createKnowledgeRepository(deps) {
|
|
|
191
432
|
deps.db
|
|
192
433
|
.prepare(`INSERT INTO idempotency_keys (
|
|
193
434
|
actor_id, operation, idempotency_key, resource_id, created_at
|
|
194
|
-
) VALUES (?, '
|
|
435
|
+
) VALUES (?, 'node.create', ?, ?, ?)`)
|
|
195
436
|
.bind(input.actorId, input.idempotencyKey, node.id, node.createdAt),
|
|
196
437
|
deps.db
|
|
197
438
|
.prepare(`INSERT INTO audit_events (
|
|
198
439
|
id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
|
|
199
|
-
) VALUES (?, ?, '
|
|
200
|
-
.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
|
+
: []),
|
|
201
458
|
]);
|
|
202
459
|
}
|
|
203
460
|
catch (error) {
|
|
204
461
|
const replayedKey = await deps.db
|
|
205
462
|
.prepare(`SELECT resource_id FROM idempotency_keys
|
|
206
|
-
WHERE actor_id = ? AND operation = '
|
|
463
|
+
WHERE actor_id = ? AND operation = 'node.create' AND idempotency_key = ?`)
|
|
207
464
|
.bind(input.actorId, input.idempotencyKey)
|
|
208
465
|
.first();
|
|
209
466
|
if (replayedKey) {
|
|
210
467
|
const replayed = await deps.db
|
|
211
468
|
.prepare(`${visibleCte}
|
|
212
469
|
SELECT ${nodeColumns}
|
|
213
|
-
FROM
|
|
470
|
+
FROM nodes n
|
|
214
471
|
JOIN allowed ON allowed.id = n.id
|
|
215
472
|
WHERE n.id = ?`)
|
|
216
473
|
.bind(...readBindings({ id: input.actorId, email: "" }), replayedKey.resource_id)
|
|
@@ -224,33 +481,38 @@ export function createKnowledgeRepository(deps) {
|
|
|
224
481
|
},
|
|
225
482
|
async getVersion(versionId) {
|
|
226
483
|
const row = await deps.db
|
|
227
|
-
.prepare(`SELECT
|
|
228
|
-
size, created_by, created_at FROM knowledge_versions WHERE id = ?`)
|
|
484
|
+
.prepare(`SELECT ${versionColumns} FROM node_versions WHERE id = ?`)
|
|
229
485
|
.bind(versionId)
|
|
230
486
|
.first();
|
|
231
487
|
return row ? mapVersion(row) : null;
|
|
232
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.
|
|
233
491
|
async listVersionContentKeys(nodeId) {
|
|
234
492
|
const result = await deps.db
|
|
235
|
-
.prepare(`SELECT content_key FROM
|
|
236
|
-
WHERE node_id = ?
|
|
237
|
-
|
|
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)
|
|
238
500
|
.all();
|
|
239
501
|
return (result.results ?? []).map((row) => row.content_key);
|
|
240
502
|
},
|
|
241
|
-
async
|
|
503
|
+
async tableHeaderContentKey(nodeId) {
|
|
242
504
|
const row = await deps.db
|
|
243
|
-
.prepare(`SELECT content_key FROM
|
|
244
|
-
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`)
|
|
245
508
|
.bind(nodeId)
|
|
246
509
|
.first();
|
|
247
510
|
return row?.content_key ?? null;
|
|
248
511
|
},
|
|
249
512
|
async listVersions(nodeId) {
|
|
250
513
|
const result = await deps.db
|
|
251
|
-
.prepare(`SELECT
|
|
252
|
-
|
|
253
|
-
FROM knowledge_versions
|
|
514
|
+
.prepare(`SELECT ${versionColumns}
|
|
515
|
+
FROM node_versions
|
|
254
516
|
WHERE node_id = ?
|
|
255
517
|
ORDER BY sequence DESC`)
|
|
256
518
|
.bind(nodeId)
|
|
@@ -263,13 +525,13 @@ export function createKnowledgeRepository(deps) {
|
|
|
263
525
|
await deps.db.batch([
|
|
264
526
|
deps.db
|
|
265
527
|
.prepare(`WITH RECURSIVE descendants(id) AS (
|
|
266
|
-
SELECT id FROM
|
|
528
|
+
SELECT id FROM nodes WHERE id = ?
|
|
267
529
|
UNION ALL
|
|
268
530
|
SELECT child.id
|
|
269
|
-
FROM
|
|
531
|
+
FROM nodes child
|
|
270
532
|
JOIN descendants parent ON child.parent_id = parent.id
|
|
271
533
|
)
|
|
272
|
-
UPDATE
|
|
534
|
+
UPDATE nodes
|
|
273
535
|
SET parent_id = ?, title = ?, description = ?, updated_at = ?
|
|
274
536
|
WHERE id = ? AND updated_at = ?
|
|
275
537
|
AND (? IS NULL OR ? NOT IN (SELECT id FROM descendants))`)
|
|
@@ -277,9 +539,9 @@ export function createKnowledgeRepository(deps) {
|
|
|
277
539
|
deps.db
|
|
278
540
|
.prepare(`INSERT INTO idempotency_keys (
|
|
279
541
|
actor_id, operation, idempotency_key, resource_id, created_at
|
|
280
|
-
) SELECT ?, '
|
|
542
|
+
) SELECT ?, 'node.update', ?, ?, ?
|
|
281
543
|
WHERE EXISTS (
|
|
282
|
-
SELECT 1 FROM
|
|
544
|
+
SELECT 1 FROM nodes
|
|
283
545
|
WHERE id = ? AND parent_id IS ? AND title = ? AND description IS ?
|
|
284
546
|
AND updated_at = ?
|
|
285
547
|
)`)
|
|
@@ -287,10 +549,10 @@ export function createKnowledgeRepository(deps) {
|
|
|
287
549
|
deps.db
|
|
288
550
|
.prepare(`INSERT INTO audit_events (
|
|
289
551
|
id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
|
|
290
|
-
) SELECT ?, ?, '
|
|
552
|
+
) SELECT ?, ?, 'node.update', 'node', ?, ?, ?
|
|
291
553
|
WHERE EXISTS (
|
|
292
554
|
SELECT 1 FROM idempotency_keys
|
|
293
|
-
WHERE actor_id = ? AND operation = '
|
|
555
|
+
WHERE actor_id = ? AND operation = 'node.update'
|
|
294
556
|
AND idempotency_key = ? AND resource_id = ?
|
|
295
557
|
)`)
|
|
296
558
|
.bind(input.auditId, input.actorId, node.id, JSON.stringify({
|
|
@@ -302,7 +564,7 @@ export function createKnowledgeRepository(deps) {
|
|
|
302
564
|
catch (error) {
|
|
303
565
|
const replayed = await deps.db
|
|
304
566
|
.prepare(`SELECT resource_id FROM idempotency_keys
|
|
305
|
-
WHERE actor_id = ? AND operation = '
|
|
567
|
+
WHERE actor_id = ? AND operation = 'node.update' AND idempotency_key = ?`)
|
|
306
568
|
.bind(input.actorId, input.idempotencyKey)
|
|
307
569
|
.first();
|
|
308
570
|
if (!replayed)
|
|
@@ -310,12 +572,12 @@ export function createKnowledgeRepository(deps) {
|
|
|
310
572
|
}
|
|
311
573
|
const replayed = await deps.db
|
|
312
574
|
.prepare(`SELECT resource_id FROM idempotency_keys
|
|
313
|
-
WHERE actor_id = ? AND operation = '
|
|
575
|
+
WHERE actor_id = ? AND operation = 'node.update' AND idempotency_key = ?`)
|
|
314
576
|
.bind(input.actorId, input.idempotencyKey)
|
|
315
577
|
.first();
|
|
316
578
|
if (replayed) {
|
|
317
579
|
const row = await deps.db
|
|
318
|
-
.prepare(`SELECT ${nodeColumns} FROM
|
|
580
|
+
.prepare(`SELECT ${nodeColumns} FROM nodes n WHERE n.id = ?`)
|
|
319
581
|
.bind(replayed.resource_id)
|
|
320
582
|
.first();
|
|
321
583
|
if (row)
|
|
@@ -324,10 +586,10 @@ export function createKnowledgeRepository(deps) {
|
|
|
324
586
|
if (node.parentId !== null) {
|
|
325
587
|
const cycle = await deps.db
|
|
326
588
|
.prepare(`WITH RECURSIVE descendants(id) AS (
|
|
327
|
-
SELECT id FROM
|
|
589
|
+
SELECT id FROM nodes WHERE id = ?
|
|
328
590
|
UNION ALL
|
|
329
591
|
SELECT child.id
|
|
330
|
-
FROM
|
|
592
|
+
FROM nodes child
|
|
331
593
|
JOIN descendants parent ON child.parent_id = parent.id
|
|
332
594
|
)
|
|
333
595
|
SELECT 1 AS found FROM descendants WHERE id = ? LIMIT 1`)
|
|
@@ -342,26 +604,26 @@ export function createKnowledgeRepository(deps) {
|
|
|
342
604
|
try {
|
|
343
605
|
await deps.db.batch([
|
|
344
606
|
deps.db
|
|
345
|
-
.prepare(`UPDATE
|
|
607
|
+
.prepare(`UPDATE nodes
|
|
346
608
|
SET archived_at = ?, updated_at = ?
|
|
347
609
|
WHERE id = ? AND updated_at = ?`)
|
|
348
610
|
.bind(input.archivedAt, input.updatedAt, input.nodeId, input.baseUpdatedAt),
|
|
349
611
|
deps.db
|
|
350
612
|
.prepare(`INSERT INTO idempotency_keys (
|
|
351
613
|
actor_id, operation, idempotency_key, resource_id, created_at
|
|
352
|
-
) SELECT ?, '
|
|
614
|
+
) SELECT ?, 'node.archive', ?, ?, ?
|
|
353
615
|
WHERE EXISTS (
|
|
354
|
-
SELECT 1 FROM
|
|
616
|
+
SELECT 1 FROM nodes
|
|
355
617
|
WHERE id = ? AND archived_at IS ? AND updated_at = ?
|
|
356
618
|
)`)
|
|
357
619
|
.bind(input.actorId, input.idempotencyKey, input.nodeId, input.updatedAt, input.nodeId, input.archivedAt, input.updatedAt),
|
|
358
620
|
deps.db
|
|
359
621
|
.prepare(`INSERT INTO audit_events (
|
|
360
622
|
id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
|
|
361
|
-
) SELECT ?, ?, '
|
|
623
|
+
) SELECT ?, ?, 'node.archive', 'node', ?, ?, ?
|
|
362
624
|
WHERE EXISTS (
|
|
363
625
|
SELECT 1 FROM idempotency_keys
|
|
364
|
-
WHERE actor_id = ? AND operation = '
|
|
626
|
+
WHERE actor_id = ? AND operation = 'node.archive'
|
|
365
627
|
AND idempotency_key = ? AND resource_id = ?
|
|
366
628
|
)`)
|
|
367
629
|
.bind(input.auditId, input.actorId, input.nodeId, JSON.stringify({ archived: input.archivedAt !== null }), input.updatedAt, input.actorId, input.idempotencyKey, input.nodeId),
|
|
@@ -370,7 +632,7 @@ export function createKnowledgeRepository(deps) {
|
|
|
370
632
|
catch (error) {
|
|
371
633
|
const replayed = await deps.db
|
|
372
634
|
.prepare(`SELECT resource_id FROM idempotency_keys
|
|
373
|
-
WHERE actor_id = ? AND operation = '
|
|
635
|
+
WHERE actor_id = ? AND operation = 'node.archive' AND idempotency_key = ?`)
|
|
374
636
|
.bind(input.actorId, input.idempotencyKey)
|
|
375
637
|
.first();
|
|
376
638
|
if (!replayed)
|
|
@@ -378,13 +640,13 @@ export function createKnowledgeRepository(deps) {
|
|
|
378
640
|
}
|
|
379
641
|
const replayed = await deps.db
|
|
380
642
|
.prepare(`SELECT resource_id FROM idempotency_keys
|
|
381
|
-
WHERE actor_id = ? AND operation = '
|
|
643
|
+
WHERE actor_id = ? AND operation = 'node.archive' AND idempotency_key = ?`)
|
|
382
644
|
.bind(input.actorId, input.idempotencyKey)
|
|
383
645
|
.first();
|
|
384
646
|
if (!replayed)
|
|
385
647
|
return "conflict";
|
|
386
648
|
const row = await deps.db
|
|
387
|
-
.prepare(`SELECT ${nodeColumns} FROM
|
|
649
|
+
.prepare(`SELECT ${nodeColumns} FROM nodes n WHERE n.id = ?`)
|
|
388
650
|
.bind(replayed.resource_id)
|
|
389
651
|
.first();
|
|
390
652
|
return row ? mapNode(row) : "conflict";
|
|
@@ -393,38 +655,39 @@ export function createKnowledgeRepository(deps) {
|
|
|
393
655
|
const version = input.version;
|
|
394
656
|
await deps.db.batch([
|
|
395
657
|
deps.db
|
|
396
|
-
.prepare(`INSERT INTO
|
|
397
|
-
id, node_id, sequence, content_key, media_type, content_hash, size,
|
|
398
|
-
|
|
399
|
-
|
|
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
|
|
400
663
|
WHERE id = ? AND current_version_id IS ?`)
|
|
401
|
-
.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),
|
|
402
665
|
deps.db
|
|
403
|
-
.prepare(`UPDATE
|
|
666
|
+
.prepare(`UPDATE nodes
|
|
404
667
|
SET current_version_id = ?, updated_at = ?
|
|
405
|
-
WHERE id = ? AND EXISTS (SELECT 1 FROM
|
|
668
|
+
WHERE id = ? AND EXISTS (SELECT 1 FROM node_versions WHERE id = ?)`)
|
|
406
669
|
.bind(version.id, version.createdAt, version.nodeId, version.id),
|
|
407
670
|
deps.db
|
|
408
671
|
.prepare(`INSERT INTO idempotency_keys (
|
|
409
672
|
actor_id, operation, idempotency_key, resource_id, created_at
|
|
410
|
-
) SELECT ?, '
|
|
411
|
-
WHERE EXISTS (SELECT 1 FROM
|
|
673
|
+
) SELECT ?, 'node.save', ?, ?, ?
|
|
674
|
+
WHERE EXISTS (SELECT 1 FROM node_versions WHERE id = ?)`)
|
|
412
675
|
.bind(input.actorId, input.idempotencyKey, version.id, version.createdAt, version.id),
|
|
413
676
|
deps.db
|
|
414
|
-
.prepare(`INSERT INTO
|
|
677
|
+
.prepare(`INSERT INTO node_index_state (
|
|
415
678
|
version_id, status, attempt_count, last_error, updated_at
|
|
416
679
|
) SELECT ?, 'pending', 0, NULL, ?
|
|
417
|
-
WHERE EXISTS (SELECT 1 FROM
|
|
680
|
+
WHERE EXISTS (SELECT 1 FROM node_versions WHERE id = ?)`)
|
|
418
681
|
.bind(version.id, version.createdAt, version.id),
|
|
419
682
|
deps.db
|
|
420
683
|
.prepare(`INSERT INTO audit_events (
|
|
421
684
|
id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
|
|
422
|
-
) SELECT ?, ?, '
|
|
423
|
-
WHERE EXISTS (SELECT 1 FROM
|
|
685
|
+
) SELECT ?, ?, 'node.save', 'node', ?, ?, ?
|
|
686
|
+
WHERE EXISTS (SELECT 1 FROM node_versions WHERE id = ?)`)
|
|
424
687
|
.bind(input.auditId, input.actorId, version.nodeId, JSON.stringify({ versionId: version.id, sequence: version.sequence }), version.createdAt, version.id),
|
|
425
688
|
]);
|
|
426
689
|
const row = await deps.db
|
|
427
|
-
.prepare("SELECT current_version_id FROM
|
|
690
|
+
.prepare("SELECT current_version_id FROM nodes WHERE id = ?")
|
|
428
691
|
.bind(version.nodeId)
|
|
429
692
|
.first();
|
|
430
693
|
return row?.current_version_id === version.id ? "saved" : "conflict";
|
|
@@ -443,51 +706,50 @@ export function createKnowledgeRepository(deps) {
|
|
|
443
706
|
async appendTableVersion(input) {
|
|
444
707
|
const version = input.version;
|
|
445
708
|
const stored = async () => await deps.db
|
|
446
|
-
.prepare(`SELECT
|
|
447
|
-
size, created_by, created_at FROM knowledge_versions WHERE id = ?`)
|
|
709
|
+
.prepare(`SELECT ${versionColumns} FROM node_versions WHERE id = ?`)
|
|
448
710
|
.bind(version.id)
|
|
449
711
|
.first();
|
|
450
712
|
try {
|
|
451
713
|
await deps.db.batch([
|
|
452
714
|
deps.db
|
|
453
|
-
.prepare(`INSERT INTO
|
|
454
|
-
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,
|
|
455
717
|
created_by, created_at
|
|
456
|
-
) SELECT ?, ?, COALESCE(MAX(sequence), 0) + 1, ?, ?, ?, ?, ?, ?
|
|
457
|
-
FROM
|
|
458
|
-
.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),
|
|
459
721
|
deps.db
|
|
460
|
-
.prepare(`UPDATE
|
|
722
|
+
.prepare(`UPDATE nodes
|
|
461
723
|
SET current_version_id = ?, updated_at = ?
|
|
462
724
|
WHERE id = ?
|
|
463
|
-
AND EXISTS (SELECT 1 FROM
|
|
725
|
+
AND EXISTS (SELECT 1 FROM node_versions WHERE id = ?)
|
|
464
726
|
AND (
|
|
465
727
|
current_version_id IS NULL
|
|
466
728
|
OR COALESCE((
|
|
467
|
-
SELECT sequence FROM
|
|
468
|
-
WHERE id =
|
|
469
|
-
), 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 = ?)
|
|
470
732
|
)`)
|
|
471
733
|
.bind(version.id, version.createdAt, version.nodeId, version.id, version.id),
|
|
472
734
|
deps.db
|
|
473
735
|
.prepare(`INSERT INTO idempotency_keys (
|
|
474
736
|
actor_id, operation, idempotency_key, resource_id, created_at
|
|
475
|
-
) SELECT ?, '
|
|
476
|
-
WHERE EXISTS (SELECT 1 FROM
|
|
737
|
+
) SELECT ?, 'node.append', ?, ?, ?
|
|
738
|
+
WHERE EXISTS (SELECT 1 FROM node_versions WHERE id = ?)`)
|
|
477
739
|
.bind(input.actorId, input.idempotencyKey, version.id, version.createdAt, version.id),
|
|
478
740
|
deps.db
|
|
479
|
-
.prepare(`INSERT INTO
|
|
741
|
+
.prepare(`INSERT INTO node_index_state (
|
|
480
742
|
version_id, status, attempt_count, last_error, updated_at
|
|
481
743
|
) SELECT ?, 'pending', 0, NULL, ?
|
|
482
|
-
WHERE EXISTS (SELECT 1 FROM
|
|
744
|
+
WHERE EXISTS (SELECT 1 FROM node_versions WHERE id = ?)`)
|
|
483
745
|
.bind(version.id, version.createdAt, version.id),
|
|
484
746
|
deps.db
|
|
485
747
|
.prepare(`INSERT INTO audit_events (
|
|
486
748
|
id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
|
|
487
|
-
) SELECT ?, ?, '
|
|
749
|
+
) SELECT ?, ?, 'node.append', 'node', ?, json_object(
|
|
488
750
|
'versionId', ?, 'size', ?
|
|
489
751
|
), ?
|
|
490
|
-
WHERE EXISTS (SELECT 1 FROM
|
|
752
|
+
WHERE EXISTS (SELECT 1 FROM node_versions WHERE id = ?)`)
|
|
491
753
|
.bind(input.auditId, input.actorId, version.nodeId, version.id, version.size, version.createdAt, version.id),
|
|
492
754
|
]);
|
|
493
755
|
}
|
|
@@ -496,13 +758,12 @@ export function createKnowledgeRepository(deps) {
|
|
|
496
758
|
// answers with it rather than with a failure the caller cannot act on.
|
|
497
759
|
const replayed = await deps.db
|
|
498
760
|
.prepare(`SELECT resource_id FROM idempotency_keys
|
|
499
|
-
WHERE actor_id = ? AND operation = '
|
|
761
|
+
WHERE actor_id = ? AND operation = 'node.append' AND idempotency_key = ?`)
|
|
500
762
|
.bind(input.actorId, input.idempotencyKey)
|
|
501
763
|
.first();
|
|
502
764
|
const row = replayed
|
|
503
765
|
? await deps.db
|
|
504
|
-
.prepare(`SELECT
|
|
505
|
-
size, created_by, created_at FROM knowledge_versions WHERE id = ?`)
|
|
766
|
+
.prepare(`SELECT ${versionColumns} FROM node_versions WHERE id = ?`)
|
|
506
767
|
.bind(replayed.resource_id)
|
|
507
768
|
.first()
|
|
508
769
|
: null;
|
|
@@ -512,9 +773,61 @@ export function createKnowledgeRepository(deps) {
|
|
|
512
773
|
}
|
|
513
774
|
const row = await stored();
|
|
514
775
|
if (!row)
|
|
515
|
-
throw new Error("
|
|
776
|
+
throw new Error("Node table append did not store a version");
|
|
516
777
|
return mapVersion(row);
|
|
517
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
|
+
},
|
|
518
831
|
async listGrants(resourceId) {
|
|
519
832
|
const result = await deps.db
|
|
520
833
|
.prepare(`SELECT ${grantColumns} FROM tree_grants
|
|
@@ -529,10 +842,10 @@ export function createKnowledgeRepository(deps) {
|
|
|
529
842
|
async organizationExecuteReaches(nodeId, exceptGrantId) {
|
|
530
843
|
const row = await deps.db
|
|
531
844
|
.prepare(`WITH RECURSIVE ancestors(id, parent_id) AS (
|
|
532
|
-
SELECT id, parent_id FROM
|
|
845
|
+
SELECT id, parent_id FROM nodes WHERE id = ?
|
|
533
846
|
UNION
|
|
534
847
|
SELECT parent.id, parent.parent_id
|
|
535
|
-
FROM
|
|
848
|
+
FROM nodes parent
|
|
536
849
|
JOIN ancestors child ON child.parent_id = parent.id
|
|
537
850
|
)
|
|
538
851
|
SELECT 1 AS reaches
|
|
@@ -551,7 +864,7 @@ export function createKnowledgeRepository(deps) {
|
|
|
551
864
|
const result = await deps.db
|
|
552
865
|
.prepare(`${visibleCte}
|
|
553
866
|
SELECT ${linkColumns}
|
|
554
|
-
FROM
|
|
867
|
+
FROM node_links link
|
|
555
868
|
JOIN allowed source_access ON source_access.id = link.source_node_id
|
|
556
869
|
JOIN allowed target_access ON target_access.id = link.target_node_id
|
|
557
870
|
WHERE link.source_node_id = ? OR link.target_node_id = ?
|
|
@@ -578,7 +891,7 @@ export function createKnowledgeRepository(deps) {
|
|
|
578
891
|
const result = await deps.db
|
|
579
892
|
.prepare(`${visibleCte}
|
|
580
893
|
SELECT n.id, n.title
|
|
581
|
-
FROM
|
|
894
|
+
FROM nodes n
|
|
582
895
|
JOIN allowed ON allowed.id = n.id
|
|
583
896
|
WHERE n.id IN (${placeholders}) AND n.archived_at IS NULL`)
|
|
584
897
|
.bind(...readBindings(actor), ...ids)
|
|
@@ -600,10 +913,10 @@ export function createKnowledgeRepository(deps) {
|
|
|
600
913
|
async replaceTextLinks(input) {
|
|
601
914
|
await deps.db.batch([
|
|
602
915
|
deps.db
|
|
603
|
-
.prepare("DELETE FROM
|
|
916
|
+
.prepare("DELETE FROM node_links WHERE source_node_id = ? AND origin = 'text'")
|
|
604
917
|
.bind(input.sourceNodeId),
|
|
605
918
|
...input.links.map((link) => deps.db
|
|
606
|
-
.prepare(`INSERT INTO
|
|
919
|
+
.prepare(`INSERT INTO node_links (
|
|
607
920
|
id, source_node_id, target_node_id, relation, origin, label, created_by, created_at
|
|
608
921
|
) VALUES (?, ?, ?, 'references', 'text', NULL, ?, ?)
|
|
609
922
|
ON CONFLICT (source_node_id, target_node_id, relation) DO NOTHING`)
|
|
@@ -611,7 +924,7 @@ export function createKnowledgeRepository(deps) {
|
|
|
611
924
|
deps.db
|
|
612
925
|
.prepare(`INSERT INTO audit_events (
|
|
613
926
|
id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
|
|
614
|
-
) VALUES (?, ?, '
|
|
927
|
+
) VALUES (?, ?, 'node.link', 'node', ?, ?, ?)`)
|
|
615
928
|
.bind(input.auditId, input.actorId, input.sourceNodeId, JSON.stringify({ targetNodeIds: input.links.map((link) => link.targetNodeId) }), input.occurredAt),
|
|
616
929
|
]);
|
|
617
930
|
},
|
|
@@ -619,7 +932,7 @@ export function createKnowledgeRepository(deps) {
|
|
|
619
932
|
const nodeResult = await deps.db
|
|
620
933
|
.prepare(`${visibleCte}
|
|
621
934
|
SELECT ${nodeColumns}
|
|
622
|
-
FROM
|
|
935
|
+
FROM nodes n
|
|
623
936
|
JOIN allowed ON allowed.id = n.id
|
|
624
937
|
WHERE n.archived_at IS NULL
|
|
625
938
|
ORDER BY lower(n.title), n.id
|
|
@@ -631,14 +944,14 @@ export function createKnowledgeRepository(deps) {
|
|
|
631
944
|
return { nodes, links: [] };
|
|
632
945
|
const linkResult = await deps.db
|
|
633
946
|
.prepare(`${visibleCte}, graph_nodes(id) AS (
|
|
634
|
-
SELECT n.id FROM
|
|
947
|
+
SELECT n.id FROM nodes n
|
|
635
948
|
JOIN allowed ON allowed.id = n.id
|
|
636
949
|
WHERE n.archived_at IS NULL
|
|
637
950
|
ORDER BY lower(n.title), n.id
|
|
638
951
|
LIMIT ?
|
|
639
952
|
)
|
|
640
953
|
SELECT ${linkColumns}
|
|
641
|
-
FROM
|
|
954
|
+
FROM node_links link
|
|
642
955
|
JOIN graph_nodes source_node ON source_node.id = link.source_node_id
|
|
643
956
|
JOIN graph_nodes target_node ON target_node.id = link.target_node_id
|
|
644
957
|
ORDER BY link.created_at, link.id`)
|
|
@@ -669,13 +982,13 @@ export function createKnowledgeRepository(deps) {
|
|
|
669
982
|
deps.db
|
|
670
983
|
.prepare(`INSERT INTO idempotency_keys (
|
|
671
984
|
actor_id, operation, idempotency_key, resource_id, created_at
|
|
672
|
-
) SELECT ?, '
|
|
985
|
+
) SELECT ?, 'node.share', ?, id, ? FROM tree_grants
|
|
673
986
|
WHERE node_id = ? AND principal_type = ? AND principal_id = ? AND verb = ?`)
|
|
674
987
|
.bind(input.actorId, input.idempotencyKey, grant.createdAt, grant.resourceId, principalType, principalId, grant.verb),
|
|
675
988
|
deps.db
|
|
676
989
|
.prepare(`INSERT INTO audit_events (
|
|
677
990
|
id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
|
|
678
|
-
) VALUES (?, ?, '
|
|
991
|
+
) VALUES (?, ?, 'node.share', 'node', ?, ?, ?)`)
|
|
679
992
|
.bind(input.auditId, input.actorId, grant.resourceId, JSON.stringify({ principalType, verb: grant.verb }), grant.createdAt),
|
|
680
993
|
]);
|
|
681
994
|
const stored = await deps.db
|
|
@@ -684,13 +997,13 @@ export function createKnowledgeRepository(deps) {
|
|
|
684
997
|
.bind(grant.resourceId, principalType, principalId, grant.verb)
|
|
685
998
|
.first();
|
|
686
999
|
if (!stored)
|
|
687
|
-
throw new Error("
|
|
1000
|
+
throw new Error("Node grant disappeared after upsert");
|
|
688
1001
|
return mapGrant(stored);
|
|
689
1002
|
}
|
|
690
1003
|
catch (error) {
|
|
691
1004
|
const replayed = await deps.db
|
|
692
1005
|
.prepare(`SELECT resource_id FROM idempotency_keys
|
|
693
|
-
WHERE actor_id = ? AND operation = '
|
|
1006
|
+
WHERE actor_id = ? AND operation = 'node.share' AND idempotency_key = ?`)
|
|
694
1007
|
.bind(input.actorId, input.idempotencyKey)
|
|
695
1008
|
.first();
|
|
696
1009
|
if (replayed) {
|
|
@@ -710,7 +1023,7 @@ export function createKnowledgeRepository(deps) {
|
|
|
710
1023
|
deps.db
|
|
711
1024
|
.prepare(`INSERT INTO idempotency_keys (
|
|
712
1025
|
actor_id, operation, idempotency_key, resource_id, created_at
|
|
713
|
-
) SELECT ?, '
|
|
1026
|
+
) SELECT ?, 'node.revoke', ?,
|
|
714
1027
|
(CASE WHEN EXISTS (
|
|
715
1028
|
SELECT 1 FROM tree_grants WHERE id = ? AND node_id = ?
|
|
716
1029
|
) THEN '1:' ELSE '0:' END) || ?, ?`)
|
|
@@ -721,13 +1034,13 @@ export function createKnowledgeRepository(deps) {
|
|
|
721
1034
|
deps.db
|
|
722
1035
|
.prepare(`INSERT INTO audit_events (
|
|
723
1036
|
id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
|
|
724
|
-
) SELECT ?, ?, '
|
|
1037
|
+
) SELECT ?, ?, 'node.revoke', 'node', ?,
|
|
725
1038
|
json_object(
|
|
726
1039
|
'grantId', ?,
|
|
727
1040
|
'revoked', json(CASE WHEN substr(resource_id, 1, 2) = '1:' THEN 'true' ELSE 'false' END)
|
|
728
1041
|
), ?
|
|
729
1042
|
FROM idempotency_keys
|
|
730
|
-
WHERE actor_id = ? AND operation = '
|
|
1043
|
+
WHERE actor_id = ? AND operation = 'node.revoke' AND idempotency_key = ?`)
|
|
731
1044
|
.bind(input.auditId, input.actorId, input.resourceId, input.grantId, input.occurredAt, input.actorId, input.idempotencyKey),
|
|
732
1045
|
]);
|
|
733
1046
|
}
|
|
@@ -744,33 +1057,33 @@ export function createKnowledgeRepository(deps) {
|
|
|
744
1057
|
if (!query)
|
|
745
1058
|
return [];
|
|
746
1059
|
const result = await deps.db
|
|
747
|
-
.prepare(`${visibleCte}
|
|
1060
|
+
.prepare(`${visibleCte}${scopeCte(input.scopeId)}
|
|
748
1061
|
SELECT n.id AS node_id, v.id AS version_id, n.title,
|
|
749
|
-
snippet(
|
|
750
|
-
n.updated_at AS freshness, bm25(
|
|
751
|
-
FROM
|
|
752
|
-
JOIN
|
|
753
|
-
JOIN
|
|
754
|
-
JOIN allowed ON allowed.id = n.id
|
|
755
|
-
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 ?
|
|
756
1069
|
AND n.current_version_id = v.id
|
|
757
1070
|
AND n.archived_at IS NULL
|
|
758
1071
|
ORDER BY rank, n.updated_at DESC
|
|
759
1072
|
LIMIT ?`)
|
|
760
|
-
.bind(...readBindings(actor), query, input.limit)
|
|
1073
|
+
.bind(...readBindings(actor), ...scopeBindings(input.scopeId), query, input.limit)
|
|
761
1074
|
.all();
|
|
762
1075
|
return (result.results ?? []).map((row) => ({
|
|
763
1076
|
nodeId: row.node_id,
|
|
764
1077
|
versionId: row.version_id,
|
|
765
1078
|
title: row.title,
|
|
766
1079
|
passage: row.passage,
|
|
767
|
-
source: `intel://
|
|
1080
|
+
source: `intel://nodes/${encodeURIComponent(row.node_id)}?version=${encodeURIComponent(row.version_id)}`,
|
|
768
1081
|
freshness: row.freshness,
|
|
769
1082
|
score: 1 / (1 + Math.abs(row.rank)),
|
|
770
1083
|
match: "lexical",
|
|
771
1084
|
}));
|
|
772
1085
|
},
|
|
773
|
-
async hydrateVisibleCitations(actor, nodeIds) {
|
|
1086
|
+
async hydrateVisibleCitations(actor, nodeIds, scopeId) {
|
|
774
1087
|
const uniqueIds = [...new Set(nodeIds)].slice(0, 100);
|
|
775
1088
|
if (uniqueIds.length === 0)
|
|
776
1089
|
return [];
|
|
@@ -779,16 +1092,16 @@ export function createKnowledgeRepository(deps) {
|
|
|
779
1092
|
const ids = uniqueIds.slice(offset, offset + 96);
|
|
780
1093
|
const placeholders = ids.map(() => "?").join(", ");
|
|
781
1094
|
const result = await deps.db
|
|
782
|
-
.prepare(`${visibleCte}
|
|
1095
|
+
.prepare(`${visibleCte}${scopeCte(scopeId)}
|
|
783
1096
|
SELECT n.id AS node_id, v.id AS version_id, n.title,
|
|
784
|
-
substr(
|
|
1097
|
+
substr(node_fts.content, 1, 480) AS passage,
|
|
785
1098
|
n.updated_at AS freshness
|
|
786
|
-
FROM
|
|
787
|
-
JOIN
|
|
788
|
-
JOIN
|
|
789
|
-
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)}
|
|
790
1103
|
WHERE n.id IN (${placeholders}) AND n.archived_at IS NULL`)
|
|
791
|
-
.bind(...readBindings(actor), ...ids)
|
|
1104
|
+
.bind(...readBindings(actor), ...scopeBindings(scopeId), ...ids)
|
|
792
1105
|
.all();
|
|
793
1106
|
rows.push(...(result.results ?? []));
|
|
794
1107
|
}
|
|
@@ -799,7 +1112,7 @@ export function createKnowledgeRepository(deps) {
|
|
|
799
1112
|
versionId: row.version_id,
|
|
800
1113
|
title: row.title,
|
|
801
1114
|
passage: row.passage,
|
|
802
|
-
source: `intel://
|
|
1115
|
+
source: `intel://nodes/${encodeURIComponent(row.node_id)}?version=${encodeURIComponent(row.version_id)}`,
|
|
803
1116
|
freshness: row.freshness,
|
|
804
1117
|
score: 0,
|
|
805
1118
|
match: "semantic",
|
|
@@ -807,7 +1120,7 @@ export function createKnowledgeRepository(deps) {
|
|
|
807
1120
|
},
|
|
808
1121
|
async listCurrentVersionIds(input) {
|
|
809
1122
|
const result = await deps.db
|
|
810
|
-
.prepare(`SELECT current_version_id AS id FROM
|
|
1123
|
+
.prepare(`SELECT current_version_id AS id FROM nodes
|
|
811
1124
|
WHERE current_version_id IS NOT NULL AND archived_at IS NULL
|
|
812
1125
|
AND (? IS NULL OR current_version_id > ?)
|
|
813
1126
|
ORDER BY id LIMIT ?`)
|