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.
Files changed (79) hide show
  1. package/CHANGELOG.md +144 -0
  2. package/dist/assets/tasks/core/index-refresh.yml +1 -1
  3. package/dist/cli/retired-commands.js +2 -0
  4. package/dist/cli/unknown-flags.js +36 -3
  5. package/dist/commands/improve/collapse-detector.js +2 -2
  6. package/dist/commands/improve/consolidate.js +6 -4
  7. package/dist/commands/improve/improve-cli.js +1 -1
  8. package/dist/commands/proposal/repository.js +12 -3
  9. package/dist/commands/read/curate.js +34 -44
  10. package/dist/commands/read/search.js +50 -2
  11. package/dist/commands/sources/index-status.js +99 -0
  12. package/dist/commands/sources/info.js +8 -8
  13. package/dist/commands/sources/installed-stashes.js +33 -12
  14. package/dist/commands/sources/source-add.js +21 -6
  15. package/dist/commands/sources/stash-cli.js +119 -111
  16. package/dist/core/adapter/adapters/akm-adapter.js +35 -3
  17. package/dist/core/adapter/adapters/akm-metadata.js +11 -1
  18. package/dist/core/asset/asset-placement.js +35 -0
  19. package/dist/core/config/schema/embedding.js +7 -30
  20. package/dist/core/config/schema/search.js +11 -9
  21. package/dist/core/errors.js +5 -2
  22. package/dist/core/hash.js +18 -0
  23. package/dist/core/maintenance-barrier.js +8 -6
  24. package/dist/core/paths.js +0 -11
  25. package/dist/core/run-lock.js +5 -2
  26. package/dist/core/state/migrations.js +26 -1
  27. package/dist/core/state-db.js +63 -27
  28. package/dist/indexer/drain.js +306 -0
  29. package/dist/indexer/embedding-identity.js +20 -0
  30. package/dist/indexer/enrich.js +260 -0
  31. package/dist/indexer/ensure-index.js +5 -0
  32. package/dist/indexer/index-written-assets.js +133 -171
  33. package/dist/indexer/indexer.js +458 -1621
  34. package/dist/indexer/lookup/adapter-concept-owner.js +19 -5
  35. package/dist/indexer/passes/metadata.js +18 -1
  36. package/dist/indexer/reconcile.js +890 -0
  37. package/dist/indexer/scan/drain-dir.js +27 -70
  38. package/dist/indexer/scan/parse-file.js +66 -0
  39. package/dist/indexer/search/db-search.js +373 -89
  40. package/dist/indexer/search/ranking-contributors.js +21 -16
  41. package/dist/indexer/search/ranking.js +135 -57
  42. package/dist/indexer/units/unit.js +159 -0
  43. package/dist/llm/client.js +10 -1
  44. package/dist/llm/embedder.js +10 -3
  45. package/dist/llm/embedders/provider-limits.js +288 -0
  46. package/dist/llm/embedders/remote.js +133 -104
  47. package/dist/llm/feature-gate.js +4 -2
  48. package/dist/llm/rerank-client.js +3 -3
  49. package/dist/output/shapes/passthrough.js +1 -0
  50. package/dist/output/text/command-format.js +19 -13
  51. package/dist/output/text/helpers.js +1 -1
  52. package/dist/output/text/index.js +5 -2
  53. package/dist/scripts/akm-migrate-node.js +1141 -1237
  54. package/dist/scripts/akm-migrate.js +1141 -1237
  55. package/dist/setup/semantic-assets.js +2 -2
  56. package/dist/setup/steps/connection.js +3 -2
  57. package/dist/storage/repositories/files-repository.js +181 -0
  58. package/dist/storage/repositories/index-connection.js +1 -3
  59. package/dist/storage/repositories/index-entries-repository.js +77 -68
  60. package/dist/storage/repositories/index-entry-schema.js +16 -25
  61. package/dist/storage/repositories/index-fts-repository.js +29 -263
  62. package/dist/storage/repositories/index-meta-repository.js +0 -29
  63. package/dist/storage/repositories/index-schema.js +115 -122
  64. package/dist/storage/repositories/index-utility-repository.js +1 -1
  65. package/dist/storage/repositories/index-vec-repository.js +21 -334
  66. package/dist/storage/repositories/units-repository.js +510 -0
  67. package/docs/migration/release-notes/0.9.15.md +34 -36
  68. package/docs/migration/release-notes/0.9.16.md +110 -0
  69. package/docs/migration/release-notes/README.md +5 -0
  70. package/docs/reference/cli.md +93 -87
  71. package/docs/reference/configuration.md +128 -89
  72. package/docs/reference/data-and-telemetry.md +2 -1
  73. package/package.json +1 -1
  74. package/schemas/akm-config.json +2 -58
  75. package/dist/indexer/index-db-contention.js +0 -56
  76. package/dist/indexer/index-rebuild-lock.js +0 -73
  77. package/dist/indexer/materialize-embeddings.js +0 -771
  78. package/dist/indexer/passes/dir-staleness.js +0 -161
  79. package/dist/storage/repositories/embedding-salvage-repository.js +0 -184
@@ -6,16 +6,18 @@
6
6
  * storage layer. This isolates the one genuinely risky area (schema
7
7
  * evolution) from the CRUD/FTS/vector queries.
8
8
  *
9
- * The meta accessors, embedding purge, and vec-availability probe that
10
- * `ensureSchema` leans on live in the sibling `index-meta-repository` /
11
- * `index-vec-repository` modules.
9
+ * The meta accessors and vec-availability probe that `ensureSchema` leans on
10
+ * live in the sibling `index-meta-repository` / `index-vec-repository`
11
+ * modules.
12
12
  */
13
13
  import { ConfigError } from "../../core/errors.js";
14
+ import { withImmediateTransaction } from "../../core/state-db.js";
14
15
  import { warn } from "../../core/warn.js";
15
- import { ensureEmbeddingSalvageTable, salvageEmbeddingsBeforeDiscard } from "./embedding-salvage-repository.js";
16
+ import { ensureFileAndUnitTextTables } from "./files-repository.js";
16
17
  import { CANONICAL_ENTRY_SCHEMA_SQL, CANONICAL_INDEX_DB_VERSION, classifyIndexGeneration, isCanonicalIndexGeneration, } from "./index-entry-schema.js";
17
18
  import { getMeta, setMeta } from "./index-meta-repository.js";
18
- import { isVecAvailable, purgeEmbeddings } from "./index-vec-repository.js";
19
+ import { isVecAvailable } from "./index-vec-repository.js";
20
+ import { ensureUnitTables } from "./units-repository.js";
19
21
  // ── Constants ───────────────────────────────────────────────────────────────
20
22
  // index.db is a regenerable cache. Incompatible entry-schema changes advance
21
23
  // this generation and discard only derived index tables; durable state remains
@@ -167,8 +169,10 @@ function ensureGraphTables(db) {
167
169
  /**
168
170
  * Cross the incompatible entry-schema boundary by discarding the derived index
169
171
  * generation. No row conversion or dual-schema compatibility is attempted:
170
- * the next index run rebuilds entries, FTS, embeddings, utility aggregates,
171
- * graph extraction, and enrichment caches from current sources/state.
172
+ * the next index run rebuilds entries, FTS, utility aggregates, graph
173
+ * extraction, and enrichment caches from current sources/state. Vectors are
174
+ * content-addressed (`units`/`units_vec`) and are never part of this
175
+ * discard — see `ensureUnitTables`.
172
176
  */
173
177
  function rebuildIncompatibleIndexGeneration(db) {
174
178
  const version = getMeta(db, "version");
@@ -184,26 +188,22 @@ function rebuildIncompatibleIndexGeneration(db) {
184
188
  "to use this index.", "INDEX_SCHEMA_INCOMPATIBLE", "Upgrade akm to a version that understands this index generation.");
185
189
  }
186
190
  warn(`Index database generation ${classification.storedVersion ?? "unknown"} is older than this akm's generation ` +
187
- `${CANONICAL_INDEX_DB_VERSION} — rebuilding the derived index (entries, FTS, embeddings, graph tables, ` +
188
- "utility scores, and the LLM enrichment cache). This re-walks and re-indexes every source on the next run.");
189
- let vecResetPending = false;
190
- try {
191
- db.exec("DROP TABLE IF EXISTS entries_vec");
192
- }
193
- catch {
194
- // A vec0 table cannot be dropped while sqlite-vec is unavailable. It does
195
- // not reference entries, so leave a marker and drop it on the first later
196
- // open where the extension is available.
197
- vecResetPending = true;
198
- }
199
- db.transaction(() => {
200
- // #955: copy embeddings about to be discarded wholesale into
201
- // `embedding_salvage` (keyed by content hash + the fingerprint they were
202
- // generated under) BEFORE dropping `embeddings`, in the same transaction
203
- // as the drop, so the copy and the discard commit or roll back together.
204
- // The next embedding pass hands salvaged vectors back to unchanged
205
- // content instead of re-embedding the whole corpus after this bump.
206
- salvageEmbeddingsBeforeDiscard(db);
191
+ `${CANONICAL_INDEX_DB_VERSION} — rebuilding the derived index (entries, FTS, graph tables, utility scores, ` +
192
+ "and the LLM enrichment cache). This re-walks and re-indexes every source on the next run.");
193
+ // A pre-redesign (v23 or older) index can still carry the legacy
194
+ // `embeddings` table, declared `FOREIGN KEY (id) REFERENCES entries(id)`
195
+ // with no cascade. Under `foreign_keys=ON`, `DROP TABLE entries` below
196
+ // fails with "FOREIGN KEY constraint failed" unless `embeddings` (and its
197
+ // vec0 mirror `entries_vec`, dropped alongside it for symmetry) is gone
198
+ // first — so both must be dropped here, inside this transaction, BEFORE
199
+ // `entries`. `ensureSchema` also drops them unconditionally on every open
200
+ // (it is what cleans up a v24 index built before the tables were retired,
201
+ // and clears `entries_vec` when sqlite-vec was unavailable here), but it
202
+ // runs AFTER this rebuild, so it cannot be the only place the drop
203
+ // happens. Vectors themselves live only in the content-addressed
204
+ // `units`/`units_vec` store, which a generation rebuild never drops
205
+ // (ensureUnitTables' contract).
206
+ withImmediateTransaction(db, () => {
207
207
  db.exec("DROP TABLE IF EXISTS graph_file_relations");
208
208
  db.exec("DROP TABLE IF EXISTS graph_file_entities");
209
209
  db.exec("DROP TABLE IF EXISTS graph_files");
@@ -214,32 +214,53 @@ function rebuildIncompatibleIndexGeneration(db) {
214
214
  db.exec("DROP TABLE IF EXISTS entry_fragments");
215
215
  db.exec("DROP TABLE IF EXISTS entries_fts");
216
216
  db.exec("DROP TABLE IF EXISTS embeddings");
217
+ if (isVecAvailable(db))
218
+ db.exec("DROP TABLE IF EXISTS entries_vec");
217
219
  db.exec("DROP TABLE IF EXISTS utility_scores_scoped");
218
220
  db.exec("DROP TABLE IF EXISTS utility_scores");
219
221
  db.exec("DROP TABLE IF EXISTS llm_enrichment_cache");
220
222
  db.exec("DROP TABLE IF EXISTS index_dir_state");
221
223
  db.exec("DROP TABLE IF EXISTS entries");
222
224
  db.exec("DELETE FROM index_meta");
223
- // embedding_salvage is deliberately absent from the drop list above —
224
- // it is the ONE piece of derived state a generation rebuild must not
225
- // discard.
226
- })();
227
- if (vecResetPending)
228
- setMeta(db, "vecResetPending", "1");
225
+ }, "index");
229
226
  }
230
227
  export function ensureSchema(db, embeddingDim) {
231
- // Create meta table first so we can check version
228
+ // Create meta table first (outside the transaction below): it must
229
+ // survive even a rolled-back schema attempt, since
230
+ // "does not stamp a generation until every required DDL surface succeeds"
231
+ // (canonical-entry-schema.test.ts) opens index_meta on a failed partial
232
+ // schema and expects the table to exist with no version row, not to be
233
+ // missing entirely.
232
234
  db.exec(`
233
235
  CREATE TABLE IF NOT EXISTS index_meta (
234
236
  key TEXT PRIMARY KEY,
235
237
  value TEXT NOT NULL
236
238
  );
237
239
  `);
238
- // #955: created before the generation-rebuild check below so a discard
239
- // has somewhere to copy vectors to. Additive-only — it carries no bearing
240
- // on the `entries` generation fingerprint (`hasCanonicalEntrySchema`), so
241
- // adding it does not require a `CANONICAL_INDEX_DB_VERSION` bump.
242
- ensureEmbeddingSalvageTable(db);
240
+ // First open of a brand-new index.db used to be racy: a concurrent second
241
+ // `ensureSchema` could see `entries` created but `index_meta.version` not
242
+ // yet stamped, which `rebuildIncompatibleIndexGeneration` reads as an old
243
+ // generation and drops `entries` from under the first process mid-run
244
+ // (exit 70, "no such table: entries"). Only THAT case needs a lock — an
245
+ // already-canonical database (the common case) takes the plain path below,
246
+ // unchanged from before: wrapping every open unconditionally was tried
247
+ // first and reintroduced a lock on ordinary index runs, turning a single
248
+ // `busy_timeout` wait under a held writer into one per retry attempt
249
+ // (timed out `index-db-contention.test.ts`). This read can never race a
250
+ // genuine in-progress rebuild — WAL readers only see committed state, and a
251
+ // rebuild only runs when the last-committed state was itself non-canonical.
252
+ if (isCanonicalIndexGeneration(db)) {
253
+ applySchemaBody(db, embeddingDim);
254
+ return;
255
+ }
256
+ // Not (yet) canonical — a fresh database or a genuine stale generation.
257
+ // `withImmediateTransaction(db, ..., "index")` makes a concurrent opener
258
+ // block on `BEGIN IMMEDIATE` until this commits, so it only ever observes
259
+ // fully-fresh or fully-canonical. A real generation mismatch still
260
+ // rebuilds, via the nested call joining this same transaction.
261
+ withImmediateTransaction(db, () => applySchemaBody(db, embeddingDim), "index");
262
+ }
263
+ function applySchemaBody(db, embeddingDim) {
243
264
  rebuildIncompatibleIndexGeneration(db);
244
265
  db.exec(CANONICAL_ENTRY_SCHEMA_SQL);
245
266
  // Workflow source is compiled directly into source IR at each command
@@ -247,14 +268,32 @@ export function ensureSchema(db, embeddingDim) {
247
268
  // second persisted representation and was never used by current execution.
248
269
  // index.db is derived state, so remove the obsolete table on every open.
249
270
  db.exec("DROP TABLE IF EXISTS workflow_documents");
250
- // BLOB-based embedding storage (always available, no sqlite-vec needed)
251
- db.exec(`
252
- CREATE TABLE IF NOT EXISTS embeddings (
253
- id INTEGER PRIMARY KEY,
254
- embedding BLOB NOT NULL,
255
- FOREIGN KEY (id) REFERENCES entries(id)
256
- );
257
- `);
271
+ // #955's embedding-salvage cache (`embedding-salvage-repository.ts`) is
272
+ // retired (index redesign, B5) — units are content-addressed, so a
273
+ // generation bump keeps whatever vectors are still keyed by an
274
+ // unchanged unit hash instead of needing a copy-aside step. Drop the
275
+ // table on every open so an install upgrading past this release does not
276
+ // carry the now-unreferenced rows forever.
277
+ db.exec("DROP TABLE IF EXISTS embedding_salvage");
278
+ // The legacy per-entry vector tables (`embeddings`, a plain BLOB table, and
279
+ // `entries_vec`, its vec0 mirror) are retired (index redesign, B5h) —
280
+ // vectors live only in the content-addressed `units`/`units_vec` store
281
+ // now. Drop both unconditionally on every open, not gated on a generation
282
+ // bump: a v24 index built before this change may still carry them, and v24
283
+ // tolerates either shape. `embeddings` is a plain table, always safe to
284
+ // drop; `entries_vec` is a vec0 virtual table that can only be dropped
285
+ // while the extension is loaded, so a drop that cannot run yet is deferred
286
+ // the same way a generation rebuild used to defer it — a `vecResetPending`
287
+ // marker, finished the first later open where sqlite-vec is available.
288
+ db.exec("DROP TABLE IF EXISTS embeddings");
289
+ if (isVecAvailable(db)) {
290
+ db.exec("DROP TABLE IF EXISTS entries_vec");
291
+ if (getMeta(db, "vecResetPending") === "1")
292
+ setMeta(db, "vecResetPending", "0");
293
+ }
294
+ else if (db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'entries_vec'").get()) {
295
+ setMeta(db, "vecResetPending", "1");
296
+ }
258
297
  // usage_events lives in state.db. utility_scores remains a regenerable
259
298
  // index.db cache.
260
299
  // Utility scores table (aggregated per-entry utility metrics)
@@ -284,17 +323,6 @@ export function ensureSchema(db, embeddingDim) {
284
323
  CREATE INDEX IF NOT EXISTS idx_utility_scores_scoped_entry_id
285
324
  ON utility_scores_scoped(entry_id);
286
325
  `);
287
- db.exec(`
288
- CREATE TABLE IF NOT EXISTS index_dir_state (
289
- dir_path TEXT PRIMARY KEY,
290
- file_set_hash TEXT NOT NULL,
291
- file_mtime_max_ms REAL NOT NULL,
292
- reason TEXT NOT NULL,
293
- updated_at TEXT NOT NULL,
294
- row_count INTEGER
295
- );
296
- `);
297
- ensureIndexDirStateRowCountColumn(db);
298
326
  // LLM enrichment result cache. Stores a SHA-256 body hash and the JSON
299
327
  // result for each asset so that subsequent `akm index --enrich` runs can
300
328
  // skip the LLM call when the body hasn't changed. The cache is keyed by
@@ -329,68 +357,46 @@ export function ensureSchema(db, embeddingDim) {
329
357
  // child rows are removed when a graph_files row is replaced.
330
358
  //
331
359
  ensureGraphTables(db);
332
- // If a generation rebuild could not drop a vec0 table while the extension
333
- // was unavailable, finish that reset as soon as vec0 can be loaded again.
334
- if (isVecAvailable(db) && getMeta(db, "vecResetPending") === "1") {
335
- db.exec("DROP TABLE IF EXISTS entries_vec");
336
- setMeta(db, "vecResetPending", "0");
337
- }
338
- // sqlite-vec table
360
+ // Effective embedding vector width for `units_vec` (docs/plans/index-fragment-vectors.md).
339
361
  //
340
362
  // Dimension contract:
341
363
  // - When `embeddingDim` is `undefined`, the caller did NOT request a
342
- // specific dim. Do not touch `index_meta.embeddingDim` and do not run
343
- // the dim-change wipe — fall back to the stored dim (or the static
344
- // default) only when we have to materialise the vec table for the
345
- // first time. Without this guard, registry-side and other dim-unaware
346
- // `openDatabase()` callers would silently overwrite the dim-aware
347
- // improve/index value and oscillate the stored dim.
364
+ // specific dim. Do not touch `index_meta.embeddingDim` — fall back to
365
+ // the stored dim (or the static default). Without this guard,
366
+ // registry-side and other dim-unaware `openDatabase()` callers would
367
+ // silently overwrite the dim-aware improve/index value and oscillate
368
+ // the stored dim.
348
369
  // - When `embeddingDim` is a number, the caller explicitly asked for
349
- // that dim and owns the dim-change/backup/wipe semantics.
370
+ // that dim; it is stamped into `index_meta.embeddingDim`.
371
+ //
372
+ // A genuine dimension change (a real model swap) is NOT handled here: it
373
+ // surfaces as a different observed embedding identity
374
+ // (`deriveObservedEmbeddingIdentity` folds the observed vector width into
375
+ // the identity string), and `dropOtherIdentities` — called from the
376
+ // embedding loop when the active identity changes — recreates `units_vec`
377
+ // at the new width then. `units_vec` itself is never dropped or purged
378
+ // here; ensureUnitTables only creates it if missing, at whatever width is
379
+ // effective the first time that happens.
350
380
  const dimExplicit = embeddingDim !== undefined;
351
381
  const requestedDim = embeddingDim ?? (Number(getMeta(db, "embeddingDim")) || EMBEDDING_DIM);
352
382
  const effectiveDim = Number.isInteger(requestedDim) && requestedDim > 0 ? requestedDim : EMBEDDING_DIM;
353
383
  if (effectiveDim !== requestedDim) {
354
384
  warn(`Invalid embedding dimension ${requestedDim} — falling back to the default (${EMBEDDING_DIM}).`);
355
385
  }
356
- if (isVecAvailable(db)) {
357
- // Check if stored embedding dimension differs from configured one
358
- if (dimExplicit) {
359
- const storedDim = getMeta(db, "embeddingDim");
360
- if (storedDim && storedDim !== String(effectiveDim)) {
361
- // Stored vectors are incompatible with the new dimension. Drop the vec
362
- // table so the block below recreates it at the new width; the BLOB rows
363
- // go too. Regenerable from markdown — re-embedded by the next index.
364
- purgeEmbeddings(db, { dropVecTable: true });
365
- }
366
- }
367
- const vecExists = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='entries_vec'").get();
368
- if (!vecExists) {
369
- db.exec(`
370
- CREATE VIRTUAL TABLE entries_vec USING vec0(
371
- id INTEGER PRIMARY KEY,
372
- embedding FLOAT[${effectiveDim}]
373
- );
374
- `);
375
- }
376
- if (dimExplicit) {
377
- setMeta(db, "embeddingDim", String(effectiveDim));
378
- }
379
- }
380
- else {
381
- // Also purge BLOB embeddings on dimension change (JS fallback path).
382
- // When sqlite-vec is unavailable, entries_vec doesn't exist but the BLOB
383
- // embeddings table still stores vectors. If the configured dimension
384
- // changes, those stored BLOBs become silently incompatible.
385
- if (dimExplicit) {
386
- const storedDim = getMeta(db, "embeddingDim");
387
- if (storedDim && storedDim !== String(effectiveDim)) {
388
- // JS-fallback path: no vec table, just clear the stale BLOB vectors.
389
- purgeEmbeddings(db);
390
- }
391
- setMeta(db, "embeddingDim", String(effectiveDim));
392
- }
386
+ if (dimExplicit) {
387
+ setMeta(db, "embeddingDim", String(effectiveDim));
393
388
  }
389
+ // units / units_vec / entry_units (docs/plans/index-fragment-vectors.md):
390
+ // created if missing, and NEVER dropped by the generation rebuild above or
391
+ // by any purge — only dropOtherIdentities (called from the embedding loop
392
+ // on a real identity change) removes rows. ensureUnitTables is idempotent,
393
+ // so this runs on every ensureSchema call, not just the first.
394
+ ensureUnitTables(db, effectiveDim);
395
+ // files / unit_texts / units_fts (docs/plans/index-redesign-contract.md, B1):
396
+ // the reconcile engine's stat cache and content-addressed unit text store.
397
+ // Created if missing; reconcile.ts and pruneOrphanUnitTexts own all row-level
398
+ // writes and deletes, never this ensure path.
399
+ ensureFileAndUnitTextTables(db);
394
400
  // Usage telemetry (usage_events) lives in state.db since Chunk-8 WI-8.3 —
395
401
  // no longer created here.
396
402
  // Registry index cache table — caches remote registry index documents so
@@ -408,16 +414,3 @@ function tableExists(db, name) {
408
414
  const row = db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=? LIMIT 1").get(name);
409
415
  return row !== undefined && row !== null;
410
416
  }
411
- /**
412
- * #900: `row_count` was added after the table's first release, so a database
413
- * created before it needs an `ALTER TABLE` (`CREATE TABLE IF NOT EXISTS` only
414
- * shapes a fresh table). Idempotent. Pre-existing rows keep NULL until their
415
- * directory is next drained; index.db is a regenerable cache, so nothing is
416
- * backfilled.
417
- */
418
- function ensureIndexDirStateRowCountColumn(db) {
419
- const columns = db.prepare("PRAGMA table_info(index_dir_state)").all();
420
- if (!columns.some((column) => column.name === "row_count")) {
421
- db.exec("ALTER TABLE index_dir_state ADD COLUMN row_count INTEGER");
422
- }
423
- }
@@ -81,7 +81,7 @@ export function getUtilityScoresByIds(db, ids, scopeKey) {
81
81
  * Insert or update a utility score for an entry.
82
82
  */
83
83
  export function upsertUtilityScore(db, entryId, data) {
84
- // Pre-flight FK guard (mirrors `upsertEmbedding`): when an entry is
84
+ // Pre-flight FK guard: when an entry is
85
85
  // deleted between when its id is aggregated from usage_events and when
86
86
  // this INSERT runs, the FK constraint fails and rolls back the entire
87
87
  // finalize transaction. A cheap SELECT here turns the race into a