akm-cli 0.9.17-alpha.7 → 0.9.17-alpha.8

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.
@@ -21,6 +21,7 @@ import { buildSearchText } from "../../indexer/search/search-fields.js";
21
21
  import { sha256Hex } from "../../runtime.js";
22
22
  import { ENTRY_COLUMNS, rowToIndexedEntry } from "./index-entry-mapper.js";
23
23
  import { deleteFtsEntries, replaceFtsEntry } from "./index-fts-repository.js";
24
+ import { deleteEntryLinks, replaceEntryLinks } from "./index-links-repository.js";
24
25
  import { SQLITE_CHUNK_SIZE } from "./index-sql.js";
25
26
  import { deleteEntryVectors } from "./index-vec-repository.js";
26
27
  // ── Entry operations ────────────────────────────────────────────────────────
@@ -36,9 +37,9 @@ function embedHash(entry) {
36
37
  * Insert or update one canonical entry and all synchronously derived search
37
38
  * state. Returns the stable row id.
38
39
  *
39
- * The entries row, FTS projection, and stale-vector invalidation commit as one
40
- * SQLite transaction. Callers therefore cannot publish an entry and forget a
41
- * second FTS maintenance step.
40
+ * The entries row, FTS projection, declared links, and stale-vector
41
+ * invalidation commit as one SQLite transaction. Callers therefore cannot
42
+ * publish an entry and forget a second FTS or links maintenance step.
42
43
  */
43
44
  export function upsertEntry(db, filePath, entry, provenance, contentHash) {
44
45
  // Hot path during indexing — cache prepared statements per database
@@ -59,6 +60,7 @@ export function upsertEntry(db, filePath, entry, provenance, contentHash) {
59
60
  if (previous?.id === result.id && previous.embed_hash !== hash)
60
61
  deleteEntryVectors(db, result.id);
61
62
  replaceFtsEntry(db, result.id, entry, hasMarkdownFragmentContent(entry) ? (getMarkdownFragmentContent(entry) ?? null) : undefined);
63
+ replaceEntryLinks(db, result.id, entry, provenance);
62
64
  return result.id;
63
65
  };
64
66
  // Always enter the driver's transaction wrapper. Both supported SQLite
@@ -258,8 +260,10 @@ export function rekeyEntryInPlace(db, opts) {
258
260
  }
259
261
  if (row.embed_hash !== hash)
260
262
  deleteEntryVectors(db, row.id);
261
- if (document)
263
+ if (document) {
262
264
  replaceFtsEntry(db, row.id, document, hasMarkdownFragmentContent(document) ? (getMarkdownFragmentContent(document) ?? null) : undefined);
265
+ replaceEntryLinks(db, row.id, document, { bundleId: opts.sourceName, conceptId: opts.newRef });
266
+ }
263
267
  else
264
268
  deleteFtsEntries(db, [row.id]);
265
269
  })();
@@ -396,9 +400,11 @@ function deleteRelatedRows(db, ids, options = {}) {
396
400
  if (ids.length === 0)
397
401
  return;
398
402
  const numericIds = ids.map((r) => r.id);
399
- // FTS is part of the canonical mutation boundary, not a caller-maintained
400
- // dirty queue. Delete it before the parent row inside this transaction.
403
+ // FTS and declared links are part of the canonical mutation boundary, not a
404
+ // caller-maintained dirty queue. Delete them before the parent row inside
405
+ // this transaction.
401
406
  deleteFtsEntries(db, numericIds);
407
+ deleteEntryLinks(db, numericIds);
402
408
  // Process in chunks to stay within SQLITE_MAX_VARIABLE_NUMBER
403
409
  for (let i = 0; i < numericIds.length; i += SQLITE_CHUNK_SIZE) {
404
410
  const chunk = numericIds.slice(i, i + SQLITE_CHUNK_SIZE);
@@ -11,6 +11,10 @@
11
11
  * touching embeddings, utility scores, graph rows, or the LLM enrichment
12
12
  * cache. A newer layout is refused, naming the upgrade.
13
13
  */
14
+ // 26: declared links (#935) live in `asset_links`, one row per link, owned by
15
+ // the entry that declares it. The writable opener derives them in place from
16
+ // each entry's stored `document_json` (`migrateToDeclaredLinks`,
17
+ // index-schema.ts); releases before 26 did not store them.
14
18
  // 25: vectors live only in `embeddings`; the sqlite-vec mirror `entries_vec`
15
19
  // is dropped (`dropVecMirror`, index-schema.ts), and so is the fragment FTS
16
20
  // table `entry_fragments_fts`, which search no longer reads. `entries` keeps
@@ -21,7 +25,7 @@
21
25
  // 23 and earlier stored a second copy of every indexed field in FTS5's own
22
26
  // content shadow tables. `ensureFtsLayout` (index-schema.ts) rebuilds the FTS
23
27
  // tables from the stored entries when it finds the older layout.
24
- export const CANONICAL_INDEX_DB_VERSION = 25;
28
+ export const CANONICAL_INDEX_DB_VERSION = 26;
25
29
  export const CANONICAL_ENTRY_SCHEMA_SQL = `
26
30
  CREATE TABLE IF NOT EXISTS entries (
27
31
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -47,6 +51,19 @@ export const CANONICAL_ENTRY_SCHEMA_SQL = `
47
51
  entry_id INTEGER PRIMARY KEY REFERENCES entries(id) ON DELETE CASCADE,
48
52
  safe_markdown TEXT NOT NULL
49
53
  );
54
+
55
+ -- Declared links (#935). dst_bundle NULL: the declaring entry's own bundle.
56
+ CREATE TABLE IF NOT EXISTS asset_links (
57
+ entry_id INTEGER NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
58
+ ord INTEGER NOT NULL,
59
+ kind TEXT NOT NULL,
60
+ raw TEXT NOT NULL,
61
+ dst_bundle TEXT,
62
+ dst_concept TEXT NOT NULL,
63
+ PRIMARY KEY (entry_id, ord)
64
+ ) WITHOUT ROWID;
65
+
66
+ CREATE INDEX IF NOT EXISTS idx_asset_links_dst ON asset_links(dst_concept);
50
67
  `;
51
68
  // The FTS table is contentless: FTS5 keeps only the inverted index, and a row
52
69
  // is addressed by its rowid (`entries_fts.rowid = entries.id`, see
@@ -0,0 +1,143 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ /**
5
+ * `index.db` declared-links repository (#935): owns every SQL statement
6
+ * against `asset_links`.
7
+ *
8
+ * A row belongs to the entry that declares the link and is written, replaced
9
+ * and deleted with that entry (`upsertEntry`, `deleteRelatedRows`). A target
10
+ * is stored as `dst_bundle` (NULL for a short ref, meaning the declaring
11
+ * entry's own bundle) plus `dst_concept`; whether it exists is a join on
12
+ * `entries.item_ref` at read time, so a target indexed later resolves with
13
+ * no rewrite of its citer and a bundle rename carries short refs along.
14
+ *
15
+ * A memory target whose own file is gone resolves to its `.derived` child, the
16
+ * reachability rule lint applies (#882): consolidation keeps the distilled
17
+ * child after the parent is pruned.
18
+ */
19
+ import { declaredLinks } from "../../indexer/links/declared-links.js";
20
+ import { tableExists } from "./index-entry-schema.js";
21
+ import { SQLITE_CHUNK_SIZE } from "./index-sql.js";
22
+ const statementsByDb = new WeakMap();
23
+ function statements(db) {
24
+ const existing = statementsByDb.get(db);
25
+ if (existing)
26
+ return existing;
27
+ const created = {
28
+ deleteForEntry: db.prepare("DELETE FROM asset_links WHERE entry_id = ?"),
29
+ insert: db.prepare("INSERT INTO asset_links (entry_id, ord, kind, raw, dst_bundle, dst_concept) VALUES (?, ?, ?, ?, ?, ?)"),
30
+ };
31
+ statementsByDb.set(db, created);
32
+ return created;
33
+ }
34
+ /** Replace one entry's declared links with the ones its document names, inside the caller's transaction. */
35
+ export function replaceEntryLinks(db, entryId, document, owner) {
36
+ const { deleteForEntry, insert } = statements(db);
37
+ deleteForEntry.run(entryId);
38
+ declaredLinks(document, owner).forEach((link, ord) => {
39
+ insert.run(entryId, ord, link.kind, link.raw, link.bundle ?? null, link.conceptId);
40
+ });
41
+ }
42
+ /** Delete the declared links of entries that are being removed. */
43
+ export function deleteEntryLinks(db, entryIds) {
44
+ for (let i = 0; i < entryIds.length; i += SQLITE_CHUNK_SIZE) {
45
+ const chunk = entryIds.slice(i, i + SQLITE_CHUNK_SIZE);
46
+ db.prepare(`DELETE FROM asset_links WHERE entry_id IN (${chunk.map(() => "?").join(",")})`).run(...chunk);
47
+ }
48
+ }
49
+ /**
50
+ * Derive every entry's links from its stored `document_json`, replacing
51
+ * whatever the table held — the in-place migration to layout 26, which needs
52
+ * no file read. An entry whose JSON does not parse keeps no links until it is
53
+ * next indexed.
54
+ */
55
+ export function rebuildAllEntryLinks(db) {
56
+ db.exec("DELETE FROM asset_links");
57
+ const page = db.prepare("SELECT id, bundle_id, concept_id, document_json FROM entries WHERE id > ? ORDER BY id LIMIT 500");
58
+ let afterId = -1;
59
+ for (;;) {
60
+ const rows = page.all(afterId);
61
+ if (rows.length === 0)
62
+ break;
63
+ afterId = rows[rows.length - 1].id;
64
+ for (const row of rows) {
65
+ let document;
66
+ try {
67
+ document = JSON.parse(row.document_json);
68
+ }
69
+ catch {
70
+ continue;
71
+ }
72
+ replaceEntryLinks(db, row.id, document, { bundleId: row.bundle_id, conceptId: row.concept_id });
73
+ }
74
+ }
75
+ }
76
+ /**
77
+ * The id of the entry a link row resolves to, or NULL: the exact target, else
78
+ * (for a memory) its `.derived` child, never the declaring entry itself.
79
+ * Expects `l` (the link) and `o` (its owner) in scope.
80
+ */
81
+ const TARGET_ID_SQL = `COALESCE(
82
+ (SELECT id FROM entries WHERE item_ref = COALESCE(l.dst_bundle, o.bundle_id) || '//' || l.dst_concept),
83
+ (SELECT id FROM entries
84
+ WHERE item_ref = COALESCE(l.dst_bundle, o.bundle_id) || '//' || l.dst_concept || '.derived'
85
+ AND substr(l.dst_concept, 1, 9) = 'memories/' AND id <> o.id))`;
86
+ /**
87
+ * The declared links of the entry `itemRef`, both ways: `outgoing` in stored
88
+ * order (unresolved targets carry only `raw`), `incoming` from every other
89
+ * entry that names it, ordered by kind then source. An index that predates
90
+ * the table (an older layout served as-is) has none.
91
+ */
92
+ export function readEntryLinks(db, itemRef) {
93
+ if (!tableExists(db, "asset_links"))
94
+ return { outgoing: [], incoming: [] };
95
+ const owner = db.prepare("SELECT id, bundle_id, concept_id FROM entries WHERE item_ref = ?").get(itemRef);
96
+ if (!owner)
97
+ return { outgoing: [], incoming: [] };
98
+ const outgoing = db
99
+ .prepare(`SELECT l.kind AS kind, l.raw AS raw, t.bundle_id AS bundleId, t.concept_id AS conceptId, t.type AS type
100
+ FROM asset_links l
101
+ JOIN entries o ON o.id = l.entry_id
102
+ LEFT JOIN entries t ON t.id = ${TARGET_ID_SQL}
103
+ WHERE l.entry_id = ?
104
+ ORDER BY l.ord`)
105
+ .all(owner.id);
106
+ // A `.derived` memory also receives the links that name its parent when the
107
+ // parent is not indexed (the resolution rule above).
108
+ const parentConcept = owner.concept_id.startsWith("memories/") && owner.concept_id.endsWith(".derived")
109
+ ? owner.concept_id.slice(0, -".derived".length)
110
+ : owner.concept_id;
111
+ const incoming = db
112
+ .prepare(`SELECT l.kind AS kind, o.bundle_id AS bundleId, o.concept_id AS conceptId, o.type AS type
113
+ FROM asset_links l
114
+ JOIN entries o ON o.id = l.entry_id
115
+ WHERE l.dst_concept IN (?, ?) AND COALESCE(l.dst_bundle, o.bundle_id) = ? AND o.id <> ?
116
+ AND ${TARGET_ID_SQL} = ?
117
+ ORDER BY l.kind, o.item_ref`)
118
+ .all(owner.concept_id, parentConcept, owner.bundle_id, owner.id, owner.id);
119
+ return {
120
+ outgoing: outgoing.map((row) => row.conceptId === null
121
+ ? { kind: row.kind, raw: row.raw }
122
+ : {
123
+ kind: row.kind,
124
+ bundleId: row.bundleId ?? undefined,
125
+ conceptId: row.conceptId,
126
+ type: row.type ?? undefined,
127
+ }),
128
+ incoming,
129
+ };
130
+ }
131
+ /** Stored links per kind with how many name a target that is not indexed; empty when the index has none. */
132
+ export function countLinksByKind(db) {
133
+ if (!tableExists(db, "asset_links"))
134
+ return {};
135
+ const rows = db
136
+ .prepare(`SELECT l.kind AS kind, COUNT(*) AS total, SUM(${TARGET_ID_SQL} IS NULL) AS unresolved
137
+ FROM asset_links l
138
+ JOIN entries o ON o.id = l.entry_id
139
+ GROUP BY l.kind
140
+ ORDER BY l.kind`)
141
+ .all();
142
+ return Object.fromEntries(rows.map((row) => [row.kind, { total: row.total, unresolved: row.unresolved }]));
143
+ }
@@ -18,11 +18,13 @@
18
18
  * ({@link newerIndexLayoutError}).
19
19
  */
20
20
  import { createRequire } from "node:module";
21
+ import path from "node:path";
21
22
  import { ConfigError } from "../../core/errors.js";
22
23
  import { warn } from "../../core/warn.js";
23
24
  import { sha256Hex } from "../../runtime.js";
24
25
  import { CANONICAL_ENTRY_SCHEMA_SQL, CANONICAL_INDEX_DB_VERSION, entriesFtsDdl, isContentlessFtsDdl, missingEntryColumns, readTableSql, supportsContentlessDelete, tableExists, } from "./index-entry-schema.js";
25
26
  import { rebuildFts } from "./index-fts-repository.js";
27
+ import { rebuildAllEntryLinks } from "./index-links-repository.js";
26
28
  import { getMeta, setMeta } from "./index-meta-repository.js";
27
29
  // ── Constants ───────────────────────────────────────────────────────────────
28
30
  export const DB_VERSION = CANONICAL_INDEX_DB_VERSION;
@@ -34,6 +36,8 @@ export const VACUUM_PENDING_META = "vacuumPending";
34
36
  * compares it; the index layout version gates the graph tables' shape.
35
37
  */
36
38
  export const GRAPH_SCHEMA_VERSION = 4;
39
+ /** The layout that added declared links (`asset_links`, #935). */
40
+ const DECLARED_LINKS_LAYOUT = 26;
37
41
  /**
38
42
  * The refusal for an index a newer akm wrote. Readers and the writable opener
39
43
  * both raise it: a newer layout may lack tables or columns this release reads
@@ -170,6 +174,7 @@ function ensureEntriesLayout(db) {
170
174
  for (const table of [
171
175
  "entries_fts",
172
176
  "entry_fragments",
177
+ "asset_links",
173
178
  "embeddings",
174
179
  "utility_scores_scoped",
175
180
  "utility_scores",
@@ -249,6 +254,25 @@ function ensureFtsLayout(db) {
249
254
  rebuildFts(db);
250
255
  })();
251
256
  }
257
+ /**
258
+ * Layout 26 stores declared links (#935). Every relation an older layout
259
+ * indexed already sits in `document_json`, so the links are derived from there
260
+ * in place, with no file read. The exception is a workflow's step targets and
261
+ * a task's target, which no earlier layout stored: the directories holding
262
+ * workflows and tasks lose their incremental cursor, so the next `akm index`
263
+ * re-reads those and nothing else. One transaction.
264
+ */
265
+ function migrateToDeclaredLinks(db) {
266
+ db.transaction(() => {
267
+ rebuildAllEntryLinks(db);
268
+ const rows = db
269
+ .prepare("SELECT DISTINCT file_path FROM entries WHERE type IN ('workflow', 'task')")
270
+ .all();
271
+ const forget = db.prepare("DELETE FROM index_dir_state WHERE dir_path = ?");
272
+ for (const dir of new Set(rows.map((row) => path.dirname(row.file_path))))
273
+ forget.run(dir);
274
+ })();
275
+ }
252
276
  function tableHasColumn(db, table, column) {
253
277
  const columns = db.prepare(`PRAGMA table_info(${table})`).all();
254
278
  return columns.some((existing) => existing.name === column);
@@ -368,6 +392,9 @@ export function ensureSchema(db) {
368
392
  if (!hadFragmentSource && tableExists(db, "entries")) {
369
393
  db.exec("DELETE FROM index_dir_state");
370
394
  }
395
+ if (storedVersion > 0 && storedVersion < DECLARED_LINKS_LAYOUT && tableExists(db, "entries")) {
396
+ migrateToDeclaredLinks(db);
397
+ }
371
398
  // Migrating an existing layout drops tables and columns; the next `akm index`
372
399
  // VACUUMs the pages they leave free (`vacuumIndexDb`, indexer.ts), since a
373
400
  // writable open may run inside a caller's transaction, where VACUUM cannot.