akm-cli 0.9.15 → 0.9.16-alpha.1
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/CHANGELOG.md +144 -0
- package/dist/assets/tasks/core/index-refresh.yml +1 -1
- package/dist/cli/retired-commands.js +2 -0
- package/dist/cli/unknown-flags.js +36 -3
- package/dist/commands/improve/collapse-detector.js +2 -2
- package/dist/commands/improve/consolidate.js +6 -4
- package/dist/commands/improve/improve-cli.js +1 -1
- package/dist/commands/proposal/repository.js +12 -3
- package/dist/commands/read/curate.js +34 -44
- package/dist/commands/read/search.js +50 -2
- package/dist/commands/sources/index-status.js +99 -0
- package/dist/commands/sources/info.js +8 -8
- package/dist/commands/sources/installed-stashes.js +33 -12
- package/dist/commands/sources/source-add.js +21 -6
- package/dist/commands/sources/stash-cli.js +119 -111
- package/dist/core/adapter/adapters/akm-adapter.js +35 -3
- package/dist/core/adapter/adapters/akm-metadata.js +11 -1
- package/dist/core/asset/asset-placement.js +35 -0
- package/dist/core/config/schema/embedding.js +7 -30
- package/dist/core/config/schema/search.js +11 -9
- package/dist/core/errors.js +5 -2
- package/dist/core/hash.js +18 -0
- package/dist/core/maintenance-barrier.js +8 -6
- package/dist/core/paths.js +0 -11
- package/dist/core/run-lock.js +5 -2
- package/dist/core/state/migrations.js +26 -1
- package/dist/core/state-db.js +63 -27
- package/dist/indexer/drain.js +306 -0
- package/dist/indexer/embedding-identity.js +20 -0
- package/dist/indexer/enrich.js +260 -0
- package/dist/indexer/ensure-index.js +5 -0
- package/dist/indexer/index-written-assets.js +133 -171
- package/dist/indexer/indexer.js +458 -1621
- package/dist/indexer/lookup/adapter-concept-owner.js +19 -5
- package/dist/indexer/passes/metadata.js +18 -1
- package/dist/indexer/reconcile.js +890 -0
- package/dist/indexer/scan/drain-dir.js +27 -70
- package/dist/indexer/scan/parse-file.js +66 -0
- package/dist/indexer/search/db-search.js +373 -89
- package/dist/indexer/search/ranking-contributors.js +21 -16
- package/dist/indexer/search/ranking.js +135 -57
- package/dist/indexer/units/unit.js +159 -0
- package/dist/llm/client.js +10 -1
- package/dist/llm/embedder.js +10 -3
- package/dist/llm/embedders/provider-limits.js +288 -0
- package/dist/llm/embedders/remote.js +133 -104
- package/dist/llm/feature-gate.js +4 -2
- package/dist/llm/rerank-client.js +3 -3
- package/dist/output/shapes/passthrough.js +1 -0
- package/dist/output/text/command-format.js +19 -13
- package/dist/output/text/helpers.js +1 -1
- package/dist/output/text/index.js +5 -2
- package/dist/scripts/akm-migrate-node.js +1141 -1237
- package/dist/scripts/akm-migrate.js +1141 -1237
- package/dist/setup/semantic-assets.js +2 -2
- package/dist/setup/steps/connection.js +3 -2
- package/dist/storage/repositories/files-repository.js +181 -0
- package/dist/storage/repositories/index-connection.js +1 -3
- package/dist/storage/repositories/index-entries-repository.js +77 -68
- package/dist/storage/repositories/index-entry-schema.js +16 -25
- package/dist/storage/repositories/index-fts-repository.js +29 -263
- package/dist/storage/repositories/index-meta-repository.js +0 -29
- package/dist/storage/repositories/index-schema.js +115 -122
- package/dist/storage/repositories/index-utility-repository.js +1 -1
- package/dist/storage/repositories/index-vec-repository.js +21 -334
- package/dist/storage/repositories/units-repository.js +510 -0
- package/docs/migration/release-notes/0.9.15.md +34 -36
- package/docs/migration/release-notes/0.9.16.md +110 -0
- package/docs/migration/release-notes/README.md +5 -0
- package/docs/reference/cli.md +93 -87
- package/docs/reference/configuration.md +128 -89
- package/docs/reference/data-and-telemetry.md +2 -1
- package/package.json +1 -1
- package/schemas/akm-config.json +2 -58
- package/dist/indexer/index-db-contention.js +0 -56
- package/dist/indexer/index-rebuild-lock.js +0 -73
- package/dist/indexer/materialize-embeddings.js +0 -771
- package/dist/indexer/passes/dir-staleness.js +0 -161
- package/dist/storage/repositories/embedding-salvage-repository.js +0 -184
|
@@ -78,12 +78,12 @@ export async function prepareSemanticSearchAssets(config) {
|
|
|
78
78
|
p.log.info("sqlite-vec is available for fast vector search.");
|
|
79
79
|
}
|
|
80
80
|
else {
|
|
81
|
-
p.log.info("sqlite-vec is not available. Semantic search will
|
|
81
|
+
p.log.info("sqlite-vec is not available. Semantic search will stay unavailable (keyword search still works) until the optional extension is installed.");
|
|
82
82
|
}
|
|
83
83
|
}
|
|
84
84
|
catch (error) {
|
|
85
85
|
const message = error instanceof Error ? error.message : String(error);
|
|
86
|
-
p.log.warn(`Could not open the local database or check for sqlite-vec. Semantic search will
|
|
86
|
+
p.log.warn(`Could not open the local database or check for sqlite-vec. Semantic search will stay unavailable. (${message})\n` +
|
|
87
87
|
"Check file permissions and available disk space in the cache directory, or run `akm index --full --verbose` to diagnose.");
|
|
88
88
|
}
|
|
89
89
|
finally {
|
|
@@ -89,8 +89,9 @@ export async function stepOllama(current) {
|
|
|
89
89
|
" • qwen3-embedding-0.6b — fast and lightweight (ollama pull qwen3-embedding-0.6b)",
|
|
90
90
|
" • qwen3-embedding-4b — higher quality (ollama pull qwen3-embedding-4b)",
|
|
91
91
|
"",
|
|
92
|
-
"
|
|
93
|
-
"
|
|
92
|
+
"akm index probes Ollama for this model's own context window and sends it as",
|
|
93
|
+
"num_ctx automatically — no config needed. To override, set",
|
|
94
|
+
"embedding.ollamaOptions.num_ctx explicitly.",
|
|
94
95
|
].join("\n"), "Embedding tips");
|
|
95
96
|
}
|
|
96
97
|
// else: undefined → use built-in local
|
|
@@ -0,0 +1,181 @@
|
|
|
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
|
+
import { SQLITE_CHUNK_SIZE } from "./index-sql.js";
|
|
5
|
+
// ── Schema ──────────────────────────────────────────────────────────────────
|
|
6
|
+
/**
|
|
7
|
+
* Create `files`, `unit_texts` and `units_fts` if they do not already exist.
|
|
8
|
+
* Idempotent; never drops or rewrites an existing row. Safe to call on every
|
|
9
|
+
* schema ensure, same as `units-repository.ts`'s `ensureUnitTables`.
|
|
10
|
+
*/
|
|
11
|
+
export function ensureFileAndUnitTextTables(db) {
|
|
12
|
+
db.exec(`
|
|
13
|
+
CREATE TABLE IF NOT EXISTS files (
|
|
14
|
+
path TEXT PRIMARY KEY,
|
|
15
|
+
bundle_id TEXT NOT NULL,
|
|
16
|
+
size INTEGER NOT NULL,
|
|
17
|
+
mtime_ms REAL NOT NULL,
|
|
18
|
+
ctime_ms REAL NOT NULL DEFAULT -1,
|
|
19
|
+
blob_hash TEXT NOT NULL,
|
|
20
|
+
adapter_id TEXT NOT NULL DEFAULT ''
|
|
21
|
+
);
|
|
22
|
+
CREATE INDEX IF NOT EXISTS files_bundle ON files(bundle_id);
|
|
23
|
+
|
|
24
|
+
CREATE TABLE IF NOT EXISTS unit_texts (
|
|
25
|
+
unit_hash TEXT PRIMARY KEY,
|
|
26
|
+
kind TEXT NOT NULL CHECK (kind IN ('card','fragment')),
|
|
27
|
+
text TEXT NOT NULL
|
|
28
|
+
);
|
|
29
|
+
-- index-redesign-contract.md B5f item 2: search runs the lexical query as
|
|
30
|
+
-- two kind-scoped lists (card, fragment) so a name/description match
|
|
31
|
+
-- ranks in its own small pool instead of competing with body text in one
|
|
32
|
+
-- BM25 pool. That kind filter runs on every search, so it needs an index.
|
|
33
|
+
CREATE INDEX IF NOT EXISTS unit_texts_kind ON unit_texts(kind);
|
|
34
|
+
|
|
35
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS units_fts USING fts5(
|
|
36
|
+
unit_hash UNINDEXED, text, tokenize='porter unicode61'
|
|
37
|
+
);
|
|
38
|
+
`);
|
|
39
|
+
ensureFilesAdapterIdColumn(db);
|
|
40
|
+
ensureFilesCtimeColumn(db);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* `adapter_id` was added after `files`' first release, so a database created
|
|
44
|
+
* before it needs an `ALTER TABLE` (`CREATE TABLE IF NOT EXISTS` only shapes
|
|
45
|
+
* a fresh table). Idempotent. A pre-existing row's default `''` matches no
|
|
46
|
+
* real adapter id, so the very next reconcile sees it as a mismatch and
|
|
47
|
+
* re-parses that one file under its current adapter — a one-time,
|
|
48
|
+
* self-healing cost, not a correctness gap.
|
|
49
|
+
*/
|
|
50
|
+
function ensureFilesAdapterIdColumn(db) {
|
|
51
|
+
const columns = db.prepare("PRAGMA table_info(files)").all();
|
|
52
|
+
if (!columns.some((column) => column.name === "adapter_id")) {
|
|
53
|
+
db.exec("ALTER TABLE files ADD COLUMN adapter_id TEXT NOT NULL DEFAULT ''");
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* `ctime_ms` was added after `files`' first release, the same additive,
|
|
58
|
+
* self-healing way `adapter_id` was (see {@link ensureFilesAdapterIdColumn}):
|
|
59
|
+
* a database created before it needs an `ALTER TABLE`. A pre-existing row's
|
|
60
|
+
* default `-1` matches no real `ctimeMs` (always >= 0), so the very next
|
|
61
|
+
* reconcile sees it as a mismatch and re-parses that one file — a one-time,
|
|
62
|
+
* self-healing cost, not a correctness gap.
|
|
63
|
+
*/
|
|
64
|
+
function ensureFilesCtimeColumn(db) {
|
|
65
|
+
const columns = db.prepare("PRAGMA table_info(files)").all();
|
|
66
|
+
if (!columns.some((column) => column.name === "ctime_ms")) {
|
|
67
|
+
db.exec("ALTER TABLE files ADD COLUMN ctime_ms REAL NOT NULL DEFAULT -1");
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
// ── files ───────────────────────────────────────────────────────────────────
|
|
71
|
+
function rowToFileState(row) {
|
|
72
|
+
return {
|
|
73
|
+
path: row.path,
|
|
74
|
+
bundleId: row.bundle_id,
|
|
75
|
+
size: row.size,
|
|
76
|
+
mtimeMs: row.mtime_ms,
|
|
77
|
+
ctimeMs: row.ctime_ms,
|
|
78
|
+
blobHash: row.blob_hash,
|
|
79
|
+
adapterId: row.adapter_id,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
/** The stored stat/hash row for one path, or `undefined` if it has never been reconciled. */
|
|
83
|
+
export function getFileState(db, path) {
|
|
84
|
+
const row = db
|
|
85
|
+
.prepare("SELECT path, bundle_id, size, mtime_ms, ctime_ms, blob_hash, adapter_id FROM files WHERE path = ?")
|
|
86
|
+
.get(path);
|
|
87
|
+
return row ? rowToFileState(row) : undefined;
|
|
88
|
+
}
|
|
89
|
+
/** Every stored `files` row for one bundle — the stat cache `reconcileRoots` diffs one root's walk against. */
|
|
90
|
+
export function getFileStatesByBundle(db, bundleId) {
|
|
91
|
+
const rows = db
|
|
92
|
+
.prepare("SELECT path, bundle_id, size, mtime_ms, ctime_ms, blob_hash, adapter_id FROM files WHERE bundle_id = ?")
|
|
93
|
+
.all(bundleId);
|
|
94
|
+
return rows.map(rowToFileState);
|
|
95
|
+
}
|
|
96
|
+
/** Insert or replace one file's stat/hash row. */
|
|
97
|
+
export function upsertFileState(db, row) {
|
|
98
|
+
db.prepare(`INSERT INTO files (path, bundle_id, size, mtime_ms, ctime_ms, blob_hash, adapter_id) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
99
|
+
ON CONFLICT(path) DO UPDATE SET bundle_id = excluded.bundle_id, size = excluded.size,
|
|
100
|
+
mtime_ms = excluded.mtime_ms, ctime_ms = excluded.ctime_ms, blob_hash = excluded.blob_hash,
|
|
101
|
+
adapter_id = excluded.adapter_id`).run(row.path, row.bundleId, row.size, row.mtimeMs, row.ctimeMs, row.blobHash, row.adapterId);
|
|
102
|
+
}
|
|
103
|
+
/** Remove `files` rows for paths that no longer have an entry (gone, or the adapter no longer recognizes them). */
|
|
104
|
+
export function deleteFileStates(db, paths) {
|
|
105
|
+
for (let offset = 0; offset < paths.length; offset += SQLITE_CHUNK_SIZE) {
|
|
106
|
+
const chunk = paths.slice(offset, offset + SQLITE_CHUNK_SIZE);
|
|
107
|
+
const placeholders = chunk.map(() => "?").join(",");
|
|
108
|
+
db.prepare(`DELETE FROM files WHERE path IN (${placeholders})`).run(...chunk);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
// ── unit_texts / units_fts ───────────────────────────────────────────────────
|
|
112
|
+
/**
|
|
113
|
+
* Write the text for every hash in `units` reconcile has not stored before.
|
|
114
|
+
* `unit_texts` dedupes itself (`INSERT OR IGNORE` on its `unit_hash` PRIMARY
|
|
115
|
+
* KEY); a `units_fts` mirror row is written ONLY when the `unit_texts` insert
|
|
116
|
+
* actually happened (FTS5 has no PRIMARY KEY to `OR IGNORE` against, so
|
|
117
|
+
* inserting unconditionally would duplicate a hash already indexed).
|
|
118
|
+
*/
|
|
119
|
+
export function insertNewUnitTexts(db, units) {
|
|
120
|
+
if (units.length === 0)
|
|
121
|
+
return { inserted: 0 };
|
|
122
|
+
const insertText = db.prepare("INSERT OR IGNORE INTO unit_texts (unit_hash, kind, text) VALUES (?, ?, ?)");
|
|
123
|
+
const insertFts = db.prepare("INSERT INTO units_fts (unit_hash, text) VALUES (?, ?)");
|
|
124
|
+
let inserted = 0;
|
|
125
|
+
for (const unit of units) {
|
|
126
|
+
const result = insertText.run(unit.hash, unit.kind, unit.text);
|
|
127
|
+
if (Number(result.changes) > 0) {
|
|
128
|
+
insertFts.run(unit.hash, unit.text);
|
|
129
|
+
inserted++;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return { inserted };
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Delete `unit_texts`/`units_fts` rows no `entry_units` row references any
|
|
136
|
+
* more — a single `NOT EXISTS` sweep, run once at the end of `reconcileRoots`
|
|
137
|
+
* (never `reconcilePaths`, whose small known-paths scope makes a full-table
|
|
138
|
+
* sweep wasteful for the write-path caller it serves). Vectors
|
|
139
|
+
* (`units`/`units_vec`) are never touched here — dropping the text does not
|
|
140
|
+
* drop the embedding, so a hash that comes back later resumes serving search
|
|
141
|
+
* without re-embedding.
|
|
142
|
+
*/
|
|
143
|
+
export function pruneOrphanUnitTexts(db) {
|
|
144
|
+
const before = db.prepare("SELECT COUNT(*) AS n FROM unit_texts").get().n;
|
|
145
|
+
db.exec("DELETE FROM units_fts WHERE unit_hash IN (SELECT unit_hash FROM unit_texts WHERE NOT EXISTS " +
|
|
146
|
+
"(SELECT 1 FROM entry_units WHERE entry_units.unit_hash = unit_texts.unit_hash))");
|
|
147
|
+
db.exec("DELETE FROM unit_texts WHERE NOT EXISTS (SELECT 1 FROM entry_units WHERE entry_units.unit_hash = unit_texts.unit_hash)");
|
|
148
|
+
const after = db.prepare("SELECT COUNT(*) AS n FROM unit_texts").get().n;
|
|
149
|
+
return { removed: before - after };
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Delete `unit_texts`/`units_fts` rows for exactly the hashes in `hashes`
|
|
153
|
+
* that no `entry_units` row references any more — the narrow counterpart to
|
|
154
|
+
* {@link pruneOrphanUnitTexts}'s whole-table sweep, for a caller that already
|
|
155
|
+
* knows precisely which hashes its own write just orphaned (`reconcile.ts`'s
|
|
156
|
+
* `applyChange`, run after every write — including `reconcilePaths`, which
|
|
157
|
+
* previously never pruned anything and so grew `unit_texts`/`units_fts`
|
|
158
|
+
* without bound across repeated edits) rather than sweeping every row in the
|
|
159
|
+
* table on every single write. Vectors (`units`/`units_vec`) are never
|
|
160
|
+
* touched here either, for the same reason `pruneOrphanUnitTexts` leaves
|
|
161
|
+
* them alone: dropping a unit's text must not drop its vector, so a hash
|
|
162
|
+
* that returns resumes serving search without re-embedding.
|
|
163
|
+
*/
|
|
164
|
+
export function pruneOrphanUnitTextsForHashes(db, hashes) {
|
|
165
|
+
const unique = [...new Set(hashes)];
|
|
166
|
+
if (unique.length === 0)
|
|
167
|
+
return { removed: 0 };
|
|
168
|
+
let removed = 0;
|
|
169
|
+
for (let offset = 0; offset < unique.length; offset += SQLITE_CHUNK_SIZE) {
|
|
170
|
+
const chunk = unique.slice(offset, offset + SQLITE_CHUNK_SIZE);
|
|
171
|
+
const placeholders = chunk.map(() => "?").join(",");
|
|
172
|
+
db.prepare(`DELETE FROM units_fts WHERE unit_hash IN (${placeholders}) AND NOT EXISTS ` +
|
|
173
|
+
`(SELECT 1 FROM entry_units WHERE entry_units.unit_hash = units_fts.unit_hash)`).run(...chunk);
|
|
174
|
+
const result = db
|
|
175
|
+
.prepare(`DELETE FROM unit_texts WHERE unit_hash IN (${placeholders}) AND NOT EXISTS ` +
|
|
176
|
+
`(SELECT 1 FROM entry_units WHERE entry_units.unit_hash = unit_texts.unit_hash)`)
|
|
177
|
+
.run(...chunk);
|
|
178
|
+
removed += Number(result.changes);
|
|
179
|
+
}
|
|
180
|
+
return { removed };
|
|
181
|
+
}
|
|
@@ -22,7 +22,7 @@ import { SQLITE_BUSY_TIMEOUT_MS } from "../sqlite-pragmas.js";
|
|
|
22
22
|
import { openSqliteReadSnapshot, SqliteReadSnapshotUnavailableError } from "../sqlite-read-snapshot.js";
|
|
23
23
|
import { CANONICAL_INDEX_DB_VERSION, classifyIndexGeneration, isCanonicalIndexGeneration } from "./index-entry-schema.js";
|
|
24
24
|
import { ensureSchema } from "./index-schema.js";
|
|
25
|
-
import { loadVecExtension
|
|
25
|
+
import { loadVecExtension } from "./index-vec-repository.js";
|
|
26
26
|
/**
|
|
27
27
|
* Whether `error` is SQLite reporting on-disk corruption (`SQLITE_CORRUPT`,
|
|
28
28
|
* "database disk image is malformed") rather than a permission, lock, or
|
|
@@ -54,8 +54,6 @@ export function openIndexDatabase(dbPath, options) {
|
|
|
54
54
|
// ensureSchema from touching `index_meta.embeddingDim` at all.
|
|
55
55
|
const resolvedDim = options?.embeddingDim ?? resolveConfiguredEmbeddingDim();
|
|
56
56
|
ensureSchema(db, resolvedDim);
|
|
57
|
-
// Warn once at init if using JS fallback with many entries
|
|
58
|
-
warnIfVecMissing(db, { once: true });
|
|
59
57
|
},
|
|
60
58
|
};
|
|
61
59
|
try {
|
|
@@ -14,22 +14,24 @@ import { parseBundleRef } from "../../core/asset/asset-ref.js";
|
|
|
14
14
|
import { conceptIdFromTypeName } from "../../core/asset/resolve-ref.js";
|
|
15
15
|
import { bestEffort } from "../../core/best-effort.js";
|
|
16
16
|
import { isPathAbsent } from "../../core/path-access.js";
|
|
17
|
-
import { getStateDbPath, withStateDb } from "../../core/state-db.js";
|
|
17
|
+
import { getStateDbPath, withImmediateTransaction, withStateDb } from "../../core/state-db.js";
|
|
18
18
|
import { warn } from "../../core/warn.js";
|
|
19
19
|
import { getMarkdownFragmentContent, hasMarkdownFragmentContent, } from "../../indexer/passes/metadata.js";
|
|
20
20
|
import { buildSearchText } from "../../indexer/search/search-fields.js";
|
|
21
21
|
import { ENTRY_COLUMNS, rowToIndexedEntry } from "./index-entry-mapper.js";
|
|
22
|
-
import {
|
|
22
|
+
import { deleteFragmentSource, replaceFragmentSource } from "./index-fts-repository.js";
|
|
23
23
|
import { SQLITE_CHUNK_SIZE } from "./index-sql.js";
|
|
24
|
-
import { deleteEntryVectors, isVecAvailable } from "./index-vec-repository.js";
|
|
25
24
|
// ── Entry operations ────────────────────────────────────────────────────────
|
|
26
25
|
/**
|
|
27
26
|
* Insert or update one canonical entry and all synchronously derived search
|
|
28
27
|
* state. Returns the stable row id.
|
|
29
28
|
*
|
|
30
|
-
* The entries row
|
|
31
|
-
*
|
|
32
|
-
*
|
|
29
|
+
* The entries row and its safe-Markdown fragment source commit as one SQLite
|
|
30
|
+
* transaction. Callers therefore cannot publish an entry and forget the
|
|
31
|
+
* fragment-source write. (Unit derivation — `unit_texts`/`units_fts`/
|
|
32
|
+
* `entry_units` — is the caller's job, driven by reconcile: a changed
|
|
33
|
+
* `search_text` needs no explicit vector invalidation here, since a new
|
|
34
|
+
* document simply derives new content-addressed unit hashes.)
|
|
33
35
|
*/
|
|
34
36
|
export function upsertEntry(db, filePath, entry, searchText, provenance, contentHash) {
|
|
35
37
|
// Hot path during indexing — cache prepared statements per database
|
|
@@ -42,20 +44,37 @@ export function upsertEntry(db, filePath, entry, searchText, provenance, content
|
|
|
42
44
|
// `content_hash` is optional on the LLM-enrichment re-upsert; a missing hash
|
|
43
45
|
// preserves the scan writer's current value.
|
|
44
46
|
const apply = () => {
|
|
45
|
-
const previous = stmts.findByItemRef.get(provenance.itemRef);
|
|
46
47
|
const result = stmts.upsert.get(provenance.itemRef, provenance.bundleId, provenance.componentId, provenance.conceptId, provenance.adapterId, entry.type, filePath, contentHash ?? null, JSON.stringify(entry), searchText, derivedFrom);
|
|
47
48
|
if (!result)
|
|
48
49
|
throw new Error("upsertEntry: item_ref not found after upsert");
|
|
49
|
-
|
|
50
|
-
deleteEntryVectors(db, result.id);
|
|
51
|
-
replaceFtsEntry(db, result.id, entry, hasMarkdownFragmentContent(entry) ? (getMarkdownFragmentContent(entry) ?? null) : undefined);
|
|
50
|
+
replaceFragmentSource(db, result.id, hasMarkdownFragmentContent(entry) ? (getMarkdownFragmentContent(entry) ?? null) : undefined);
|
|
52
51
|
return result.id;
|
|
53
52
|
};
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
|
|
53
|
+
// Both production callers (reconcile.ts's `applyChange`, enrich.ts's
|
|
54
|
+
// `applyEnrichmentToEntry`) always invoke this from inside their OWN outer
|
|
55
|
+
// `withImmediateTransaction`, so `db.inTransaction` is already true and a
|
|
56
|
+
// standalone top-level call never happens today — but a caller that DOES
|
|
57
|
+
// call this standalone (no outer transaction) must still get a proper
|
|
58
|
+
// `BEGIN IMMEDIATE`, not a bare deferred `db.transaction()` that fails
|
|
59
|
+
// instantly with SQLITE_BUSY under a competing writer instead of honouring
|
|
60
|
+
// `busy_timeout` (docs/plans/index-redesign.md rule 5). Route ONLY that
|
|
61
|
+
// standalone case through `withImmediateTransaction`.
|
|
62
|
+
//
|
|
63
|
+
// The nested case deliberately keeps the driver's own `db.transaction()`
|
|
64
|
+
// instead of `withImmediateTransaction`: the driver lowers a transaction
|
|
65
|
+
// opened inside an already-open one to a SAVEPOINT, so a caller that
|
|
66
|
+
// catches this mutation's error and continues its OWN outer (bare)
|
|
67
|
+
// transaction still cannot commit a partial entries row — the exact
|
|
68
|
+
// contract tests/integration/indexer/index-mutation-boundary.test.ts pins
|
|
69
|
+
// (#820). `withImmediateTransaction`'s join-if-open guard runs `fn`
|
|
70
|
+
// directly with NO isolation boundary of its own (by design, #686 — see
|
|
71
|
+
// its doc comment), which would let such a caught, partial write escape
|
|
72
|
+
// through the outer transaction's COMMIT instead of rolling back to a
|
|
73
|
+
// savepoint. Both real production callers already hold their OWN
|
|
74
|
+
// immediate transaction before calling this, so this nested branch is a
|
|
75
|
+
// savepoint under an already-immediate lock, not a fresh deferred BEGIN —
|
|
76
|
+
// no busy_timeout race to lose here either way.
|
|
77
|
+
return db.inTransaction ? db.transaction(apply)() : withImmediateTransaction(db, apply, "index");
|
|
59
78
|
}
|
|
60
79
|
const upsertStmtsByDb = new WeakMap();
|
|
61
80
|
// item_ref is the sole durable conflict target. `content_hash` COALESCEs so a
|
|
@@ -88,7 +107,6 @@ function getUpsertStmts(db) {
|
|
|
88
107
|
ON CONFLICT(item_ref) DO UPDATE ${UPSERT_SET_CLAUSE}
|
|
89
108
|
RETURNING id
|
|
90
109
|
`),
|
|
91
|
-
findByItemRef: db.prepare("SELECT id, search_text FROM entries WHERE item_ref = ?"),
|
|
92
110
|
};
|
|
93
111
|
upsertStmtsByDb.set(db, stmts);
|
|
94
112
|
return stmts;
|
|
@@ -164,16 +182,18 @@ export function getBaseBeliefStatesForDerivedTwins(db, twinIds) {
|
|
|
164
182
|
/**
|
|
165
183
|
* Re-key an entries row in place for the opt-in source-maintenance script.
|
|
166
184
|
*
|
|
167
|
-
* The row id is preserved on purpose — `utility_scores
|
|
168
|
-
* `utility_scores_scoped
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
185
|
+
* The row id is preserved on purpose — `utility_scores` and
|
|
186
|
+
* `utility_scores_scoped` are keyed by `entry_id`, so an UPDATE (rather than
|
|
187
|
+
* a delete + insert under the new `item_ref`) is what keeps the asset's
|
|
188
|
+
* accumulated usage-ranking history attached across a rename.
|
|
189
|
+
* (`asset_salience` / `asset_outcome` live in state.db keyed by `asset_ref`
|
|
190
|
+
* TEXT and are re-keyed separately by `akm mv` — see the state rekey
|
|
191
|
+
* helper.) `document_json.name` (and `filename`, when present) is patched
|
|
192
|
+
* and `search_text` rebuilt so search reflects the new name. Its
|
|
193
|
+
* safe-Markdown fragment source is updated in the same transaction as the
|
|
194
|
+
* canonical identity; the new `search_text` needs no explicit vector
|
|
195
|
+
* invalidation — content-addressed units simply derive new hashes on the
|
|
196
|
+
* next reconcile.
|
|
177
197
|
*
|
|
178
198
|
* Bundle-qualified `usage_events.entry_ref` rows for the old conceptId are
|
|
179
199
|
* rewritten to the new item ref. Without this, events keep the old
|
|
@@ -187,10 +207,8 @@ export function getBaseBeliefStatesForDerivedTwins(db, twinIds) {
|
|
|
187
207
|
* A stale row already occupying the new item ref (the caller has verified no
|
|
188
208
|
* FILE exists at the target, so such a row can only be a leftover for a
|
|
189
209
|
* deleted file) is evicted first — through {@link deleteRelatedRows}, so its
|
|
190
|
-
* child rows (
|
|
191
|
-
*
|
|
192
|
-
* FK under `PRAGMA foreign_keys = ON` and roll back the whole re-key.
|
|
193
|
-
* The moved row keeps its id.
|
|
210
|
+
* child rows (utility scores, usage events) go with it. The moved row keeps
|
|
211
|
+
* its id.
|
|
194
212
|
*
|
|
195
213
|
* Returns the surviving row id, or `null` when no row matches the old item ref
|
|
196
214
|
* (nothing indexed under the old name — the caller falls open and the next
|
|
@@ -232,14 +250,12 @@ export function rekeyEntryInPlace(db, opts) {
|
|
|
232
250
|
throw new Error(`Refusing to re-key entry ${oldItemRef}: target ref does not match the entry type and name.`);
|
|
233
251
|
}
|
|
234
252
|
const newItemRef = `${opts.sourceName}//${opts.newRef}`;
|
|
235
|
-
db
|
|
253
|
+
withImmediateTransaction(db, () => {
|
|
236
254
|
const stale = db.prepare("SELECT id FROM entries WHERE item_ref = ?").get(newItemRef);
|
|
237
255
|
if (stale && stale.id !== row.id) {
|
|
238
|
-
// Full child-row cleanup (
|
|
239
|
-
//
|
|
240
|
-
//
|
|
241
|
-
// entries delete would throw and roll back the entire re-key; and
|
|
242
|
-
// without it the FK-less child rows would orphan permanently.
|
|
256
|
+
// Full child-row cleanup (utility scores, usage events, fragment
|
|
257
|
+
// source) BEFORE the entries delete — the FK-less child rows would
|
|
258
|
+
// otherwise orphan permanently.
|
|
243
259
|
deleteRelatedRows(db, [{ id: stale.id }]);
|
|
244
260
|
db.prepare("DELETE FROM entries WHERE id = ?").run(stale.id);
|
|
245
261
|
}
|
|
@@ -247,13 +263,11 @@ export function rekeyEntryInPlace(db, opts) {
|
|
|
247
263
|
if (opts.newDerivedFrom !== undefined) {
|
|
248
264
|
db.prepare("UPDATE entries SET derived_from = ? WHERE id = ?").run(opts.newDerivedFrom, row.id);
|
|
249
265
|
}
|
|
250
|
-
if (row.search_text !== searchText)
|
|
251
|
-
deleteEntryVectors(db, row.id);
|
|
252
266
|
if (document)
|
|
253
|
-
|
|
267
|
+
replaceFragmentSource(db, row.id, hasMarkdownFragmentContent(document) ? (getMarkdownFragmentContent(document) ?? null) : undefined);
|
|
254
268
|
else
|
|
255
|
-
|
|
256
|
-
})
|
|
269
|
+
deleteFragmentSource(db, [row.id]);
|
|
270
|
+
}, "index");
|
|
257
271
|
// Re-point usage history at the new ref. Chunk-8 WI-8.3: usage_events lives in
|
|
258
272
|
// state.db now, so this is a SEPARATE cross-DB transaction (best-effort — the
|
|
259
273
|
// rename itself already committed above; on failure the next full index's
|
|
@@ -283,9 +297,9 @@ function rewriteUsageEventRefForMove(opts) {
|
|
|
283
297
|
};
|
|
284
298
|
try {
|
|
285
299
|
withStateDb((stateDb) => {
|
|
286
|
-
stateDb
|
|
300
|
+
withImmediateTransaction(stateDb, () => {
|
|
287
301
|
rename(stateDb, `${opts.sourceName}//${opts.oldRef}`, `${opts.sourceName}//${opts.newRef}`);
|
|
288
|
-
})
|
|
302
|
+
});
|
|
289
303
|
});
|
|
290
304
|
}
|
|
291
305
|
catch (error) {
|
|
@@ -372,14 +386,14 @@ function deleteEntryRows(db, rows, options = {}) {
|
|
|
372
386
|
return rows.map((row) => row.id);
|
|
373
387
|
}
|
|
374
388
|
export function deleteEntriesByDirAndBundle(db, dirPath, bundleId, options = {}) {
|
|
375
|
-
return db
|
|
389
|
+
return withImmediateTransaction(db, () => deleteEntryRows(db, rowsInDirectory(db, dirPath, bundleId), options), "index");
|
|
376
390
|
}
|
|
377
391
|
/** Delete every entry and child row belonging to one canonical bundle. */
|
|
378
392
|
export function deleteEntriesByBundle(db, bundleId) {
|
|
379
|
-
db
|
|
393
|
+
withImmediateTransaction(db, () => {
|
|
380
394
|
const rows = db.prepare("SELECT id FROM entries WHERE bundle_id = ?").all(bundleId);
|
|
381
395
|
deleteEntryRows(db, rows);
|
|
382
|
-
})
|
|
396
|
+
}, "index");
|
|
383
397
|
}
|
|
384
398
|
/**
|
|
385
399
|
* Delete the complete regenerable entry generation through the same child-row
|
|
@@ -387,10 +401,10 @@ export function deleteEntriesByBundle(db, bundleId) {
|
|
|
387
401
|
* usage events so the finalize pass can relink them to the new row ids.
|
|
388
402
|
*/
|
|
389
403
|
export function deleteAllEntries(db, options = {}) {
|
|
390
|
-
return db
|
|
404
|
+
return withImmediateTransaction(db, () => {
|
|
391
405
|
const rows = db.prepare("SELECT id FROM entries").all();
|
|
392
406
|
return deleteEntryRows(db, rows, options);
|
|
393
|
-
})
|
|
407
|
+
}, "index");
|
|
394
408
|
}
|
|
395
409
|
/**
|
|
396
410
|
* Diff-persist orphan delete: remove every entry under `dirPath` whose durable
|
|
@@ -398,7 +412,7 @@ export function deleteAllEntries(db, options = {}) {
|
|
|
398
412
|
*
|
|
399
413
|
* Replaces the old per-dir `deleteEntriesByDir` + full re-insert: the caller
|
|
400
414
|
* upserts the current file set first (ON CONFLICT preserving `entries.id`, so
|
|
401
|
-
*
|
|
415
|
+
* utility / usage stay attached to unchanged rows), then calls this
|
|
402
416
|
* to prune only the departed rows. The net row-state for the directory is identical
|
|
403
417
|
* to delete-then-reinsert; the win is that unchanged rows keep their id.
|
|
404
418
|
*
|
|
@@ -406,27 +420,25 @@ export function deleteAllEntries(db, options = {}) {
|
|
|
406
420
|
* prune one another's rows.
|
|
407
421
|
*/
|
|
408
422
|
export function deleteEntriesByDirExceptRefs(db, dirPath, bundleId, keepRefs, options = {}) {
|
|
409
|
-
return db
|
|
423
|
+
return withImmediateTransaction(db, () => {
|
|
410
424
|
const doomed = rowsInDirectory(db, dirPath, bundleId).filter((row) => !keepRefs.has(row.item_ref));
|
|
411
425
|
return deleteEntryRows(db, doomed, options);
|
|
412
|
-
})
|
|
426
|
+
}, "index");
|
|
413
427
|
}
|
|
414
428
|
function deleteRelatedRows(db, ids, options = {}) {
|
|
415
429
|
if (ids.length === 0)
|
|
416
430
|
return;
|
|
417
431
|
const numericIds = ids.map((r) => r.id);
|
|
418
|
-
|
|
419
|
-
//
|
|
420
|
-
//
|
|
421
|
-
|
|
432
|
+
// The safe-Markdown fragment source is part of the canonical mutation
|
|
433
|
+
// boundary, not a caller-maintained dirty queue. Delete it before the
|
|
434
|
+
// parent row inside this transaction (redundant with entry_fragments' own
|
|
435
|
+
// ON DELETE CASCADE, but explicit here alongside the other child-row
|
|
436
|
+
// cleanup this function owns).
|
|
437
|
+
deleteFragmentSource(db, numericIds);
|
|
422
438
|
// Process in chunks to stay within SQLITE_MAX_VARIABLE_NUMBER
|
|
423
439
|
for (let i = 0; i < numericIds.length; i += SQLITE_CHUNK_SIZE) {
|
|
424
440
|
const chunk = numericIds.slice(i, i + SQLITE_CHUNK_SIZE);
|
|
425
441
|
const placeholders = chunk.map(() => "?").join(",");
|
|
426
|
-
bestEffort(() => db.prepare(`DELETE FROM embeddings WHERE id IN (${placeholders})`).run(...chunk), "delete embeddings for entries");
|
|
427
|
-
if (vecAvail) {
|
|
428
|
-
bestEffort(() => db.prepare(`DELETE FROM entries_vec WHERE id IN (${placeholders})`).run(...chunk), "delete entries_vec for entries");
|
|
429
|
-
}
|
|
430
442
|
// Clean up utility scores before deleting entries
|
|
431
443
|
bestEffort(() => db.prepare(`DELETE FROM utility_scores WHERE entry_id IN (${placeholders})`).run(...chunk), "delete utility_scores for entries");
|
|
432
444
|
bestEffort(() => db.prepare(`DELETE FROM utility_scores_scoped WHERE entry_id IN (${placeholders})`).run(...chunk), "delete utility_scores_scoped for entries");
|
|
@@ -481,15 +493,14 @@ export function deleteUsageEventsByEntryIds(entryIds) {
|
|
|
481
493
|
}
|
|
482
494
|
/**
|
|
483
495
|
* Delete entries by their primary key IDs, along with all related rows
|
|
484
|
-
* (
|
|
496
|
+
* (entry_fragments, entry_units, utility scores, usage_events).
|
|
485
497
|
*
|
|
486
|
-
* Used by
|
|
487
|
-
* verification to remove stale entries whose source files no longer exist.
|
|
498
|
+
* Used by `reconcile.ts` to remove entries whose source files are gone.
|
|
488
499
|
*/
|
|
489
500
|
export function deleteEntriesByIds(db, ids) {
|
|
490
501
|
if (ids.length === 0)
|
|
491
502
|
return;
|
|
492
|
-
db
|
|
503
|
+
withImmediateTransaction(db, () => {
|
|
493
504
|
const idObjs = ids.map((id) => ({ id }));
|
|
494
505
|
deleteRelatedRows(db, idObjs);
|
|
495
506
|
for (let i = 0; i < ids.length; i += SQLITE_CHUNK_SIZE) {
|
|
@@ -497,7 +508,7 @@ export function deleteEntriesByIds(db, ids) {
|
|
|
497
508
|
const placeholders = chunk.map(() => "?").join(",");
|
|
498
509
|
db.prepare(`DELETE FROM entries WHERE id IN (${placeholders})`).run(...chunk);
|
|
499
510
|
}
|
|
500
|
-
})
|
|
511
|
+
}, "index");
|
|
501
512
|
}
|
|
502
513
|
// ── All entries ─────────────────────────────────────────────────────────────
|
|
503
514
|
function parseEntryRows(rows, context) {
|
|
@@ -792,11 +803,10 @@ export function relinkUsageEvents(indexDb, stateDb, options = {}) {
|
|
|
792
803
|
});
|
|
793
804
|
if (staleLinks.length > 0) {
|
|
794
805
|
const nullOut = stateDb.prepare(`UPDATE ${usageEvents} SET entry_id = NULL WHERE entry_id = ? AND entry_ref IS ?`);
|
|
795
|
-
|
|
806
|
+
withImmediateTransaction(stateDb, () => {
|
|
796
807
|
for (const { id, ref } of staleLinks)
|
|
797
808
|
nullOut.run(id, ref);
|
|
798
809
|
});
|
|
799
|
-
nullTx();
|
|
800
810
|
}
|
|
801
811
|
// Step 2: re-resolve each fully-qualified ref. Bare rows are not current
|
|
802
812
|
// durable identities and remain detached.
|
|
@@ -804,7 +814,7 @@ export function relinkUsageEvents(indexDb, stateDb, options = {}) {
|
|
|
804
814
|
.prepare(`SELECT DISTINCT entry_ref AS ref FROM ${usageEvents} WHERE entry_id IS NULL AND entry_ref IS NOT NULL`)
|
|
805
815
|
.all();
|
|
806
816
|
const update = stateDb.prepare(`UPDATE ${usageEvents} SET entry_id = ? WHERE entry_ref = ? AND entry_id IS NULL`);
|
|
807
|
-
|
|
817
|
+
withImmediateTransaction(stateDb, () => {
|
|
808
818
|
for (const { ref } of refs) {
|
|
809
819
|
let id;
|
|
810
820
|
try {
|
|
@@ -819,6 +829,5 @@ export function relinkUsageEvents(indexDb, stateDb, options = {}) {
|
|
|
819
829
|
update.run(id, ref);
|
|
820
830
|
}
|
|
821
831
|
});
|
|
822
|
-
relinkTx();
|
|
823
832
|
}, "usage_events table may not exist yet during entry_id re-resolution");
|
|
824
833
|
}
|
|
@@ -12,7 +12,18 @@
|
|
|
12
12
|
*/
|
|
13
13
|
// v23 adds an isolated fragment FTS population. v22 is the last shipped
|
|
14
14
|
// generation and is intentionally rebuilt rather than migrated in place.
|
|
15
|
-
|
|
15
|
+
//
|
|
16
|
+
// v23→v24 (index-redesign B5c): `entries_fts` and `entry_fragments_fts` are
|
|
17
|
+
// dropped from the canonical shape. Lexical search runs entirely over
|
|
18
|
+
// `units_fts` now (index-redesign-contract.md B1/B3) — the card unit already
|
|
19
|
+
// carries name/description/tags/hints, so an entry-level lexical query is a
|
|
20
|
+
// units query grouped by entry, and a fragment-level lexical query is the
|
|
21
|
+
// same `units_fts` table filtered to fragment-kind units. `entry_fragments`
|
|
22
|
+
// (the safe-rendered Markdown source, NOT an FTS index) stays: it is what a
|
|
23
|
+
// matched fragment hit's display metadata is projected from, and what `akm
|
|
24
|
+
// show <ref>#<fragmentId>` resolves an opaque fragment selector through —
|
|
25
|
+
// both are consumers independent of which table search itself queries.
|
|
26
|
+
export const CANONICAL_INDEX_DB_VERSION = 24;
|
|
16
27
|
export const CANONICAL_ENTRY_SCHEMA_SQL = `
|
|
17
28
|
CREATE TABLE IF NOT EXISTS entries (
|
|
18
29
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
@@ -34,30 +45,14 @@ export const CANONICAL_ENTRY_SCHEMA_SQL = `
|
|
|
34
45
|
CREATE INDEX IF NOT EXISTS idx_entries_file_path ON entries(file_path);
|
|
35
46
|
CREATE INDEX IF NOT EXISTS idx_entries_derived_from ON entries(derived_from);
|
|
36
47
|
|
|
37
|
-
--
|
|
38
|
-
--
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
name,
|
|
42
|
-
description,
|
|
43
|
-
tags,
|
|
44
|
-
hints,
|
|
45
|
-
content,
|
|
46
|
-
tokenize='porter unicode61'
|
|
47
|
-
);
|
|
48
|
-
|
|
48
|
+
-- The safe-rendered Markdown source a matched fragment hit's display
|
|
49
|
+
-- metadata is projected from (index-fts-repository.ts's
|
|
50
|
+
-- getIndexedMarkdownFragment(s)) and that akm show's opaque fragment
|
|
51
|
+
-- selectors resolve through. Not a search index — units_fts is.
|
|
49
52
|
CREATE TABLE IF NOT EXISTS entry_fragments (
|
|
50
53
|
entry_id INTEGER PRIMARY KEY REFERENCES entries(id) ON DELETE CASCADE,
|
|
51
54
|
safe_markdown TEXT NOT NULL
|
|
52
55
|
);
|
|
53
|
-
|
|
54
|
-
CREATE VIRTUAL TABLE IF NOT EXISTS entry_fragments_fts USING fts5(
|
|
55
|
-
entry_id UNINDEXED,
|
|
56
|
-
fragment_id UNINDEXED,
|
|
57
|
-
fragment_ordinal UNINDEXED,
|
|
58
|
-
content,
|
|
59
|
-
tokenize='porter unicode61'
|
|
60
|
-
);
|
|
61
56
|
`;
|
|
62
57
|
const CANONICAL_ENTRY_SCHEMA_FINGERPRINT = {
|
|
63
58
|
tableSql: "CREATE TABLE entries ( id INTEGER PRIMARY KEY AUTOINCREMENT, item_ref TEXT NOT NULL UNIQUE, bundle_id TEXT NOT NULL, component_id TEXT NOT NULL, concept_id TEXT NOT NULL, adapter_id TEXT NOT NULL, type TEXT NOT NULL, file_path TEXT NOT NULL, content_hash TEXT, document_json TEXT NOT NULL, search_text TEXT NOT NULL, derived_from TEXT )",
|
|
@@ -210,9 +205,7 @@ const CANONICAL_ENTRY_SCHEMA_FINGERPRINT = {
|
|
|
210
205
|
},
|
|
211
206
|
],
|
|
212
207
|
searchSurfaces: {
|
|
213
|
-
entriesFtsSql: "CREATE VIRTUAL TABLE entries_fts USING fts5( entry_id UNINDEXED, name, description, tags, hints, content, tokenize='porter unicode61' )",
|
|
214
208
|
fragmentSourceSql: "CREATE TABLE entry_fragments ( entry_id INTEGER PRIMARY KEY REFERENCES entries(id) ON DELETE CASCADE, safe_markdown TEXT NOT NULL )",
|
|
215
|
-
fragmentsFtsSql: "CREATE VIRTUAL TABLE entry_fragments_fts USING fts5( entry_id UNINDEXED, fragment_id UNINDEXED, fragment_ordinal UNINDEXED, content, tokenize='porter unicode61' )",
|
|
216
209
|
},
|
|
217
210
|
};
|
|
218
211
|
function sqlString(value) {
|
|
@@ -270,9 +263,7 @@ export function readEntrySchemaFingerprint(db) {
|
|
|
270
263
|
columns,
|
|
271
264
|
indexes,
|
|
272
265
|
searchSurfaces: {
|
|
273
|
-
entriesFtsSql: readNamedTableSql(db, "entries_fts"),
|
|
274
266
|
fragmentSourceSql: readNamedTableSql(db, "entry_fragments"),
|
|
275
|
-
fragmentsFtsSql: readNamedTableSql(db, "entry_fragments_fts"),
|
|
276
267
|
},
|
|
277
268
|
};
|
|
278
269
|
}
|