@malloy-publisher/server 0.0.230 → 0.0.232

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/README.docker.md +4 -0
  2. package/dist/app/api-doc.yaml +74 -3
  3. package/dist/app/assets/{EnvironmentPage-wa_EPkwK.js → EnvironmentPage-DXEaZIPx.js} +1 -1
  4. package/dist/app/assets/{HomePage-jnCrupQp.js → HomePage-kofsqpZt.js} +1 -1
  5. package/dist/app/assets/{LightMode-DYbwNULZ.js → LightMode-CNhIlIlJ.js} +1 -1
  6. package/dist/app/assets/{MainPage-CuJLrPNI.js → MainPage-Bgqo8jCy.js} +1 -1
  7. package/dist/app/assets/{MaterializationsPage-D_67x2ee.js → MaterializationsPage-CgBlgGz2.js} +1 -1
  8. package/dist/app/assets/{ModelPage-D5JtAWqR.js → ModelPage-B0TjoDtf.js} +1 -1
  9. package/dist/app/assets/{PackagePage-BRwtqUSG.js → PackagePage-BL8vnFj1.js} +1 -1
  10. package/dist/app/assets/{RouteError-CBNNrnSD.js → RouteError-BzPby0X2.js} +1 -1
  11. package/dist/app/assets/{ThemeEditorPage-CTCeBneA.js → ThemeEditorPage-CTEP_9r3.js} +1 -1
  12. package/dist/app/assets/{WorkbookPage-SN6f1RBm.js → WorkbookPage-BwM3BmKw.js} +1 -1
  13. package/dist/app/assets/{core-Dp3q5Ieu.es-CD5FvM2s.js → core-CK68iv6w.es-CpRxXBt7.js} +1 -1
  14. package/dist/app/assets/{index-C_tJstcx.js → index-B33zGctF.js} +15 -15
  15. package/dist/app/assets/{index-DU4r7GdU.js → index-BabP-V-S.js} +346 -321
  16. package/dist/app/assets/{index-B3Nn8Vm2.js → index-BkiWKaAF.js} +266 -265
  17. package/dist/app/assets/{index-BLCx1EdC.js → index-CmkW1MiE.js} +1 -1
  18. package/dist/app/assets/{index-CfmBVB6M.js → index-tXJXwdyj.js} +1 -1
  19. package/dist/app/index.html +1 -1
  20. package/dist/instrumentation.mjs +2 -0
  21. package/dist/package_load_worker.mjs +11 -1
  22. package/dist/server.mjs +3186 -433
  23. package/package.json +12 -12
  24. package/scripts/bake-duckdb-extensions.js +4 -1
  25. package/src/config.spec.ts +39 -0
  26. package/src/config.ts +135 -0
  27. package/src/controller/materialization.controller.spec.ts +62 -0
  28. package/src/controller/materialization.controller.ts +15 -0
  29. package/src/controller/package.controller.spec.ts +6 -0
  30. package/src/controller/package.controller.ts +7 -2
  31. package/src/errors.ts +19 -0
  32. package/src/logger.spec.ts +18 -1
  33. package/src/logger.ts +3 -1
  34. package/src/materialization_metrics.spec.ts +89 -4
  35. package/src/materialization_metrics.ts +155 -5
  36. package/src/mcp/skills/skills_bundle.json +1 -1
  37. package/src/mcp/tools/embedding_index.spec.ts +1236 -0
  38. package/src/mcp/tools/embedding_index.ts +808 -0
  39. package/src/mcp/tools/get_context_eval.ts +194 -45
  40. package/src/mcp/tools/get_context_tool.spec.ts +295 -2
  41. package/src/mcp/tools/get_context_tool.ts +159 -10
  42. package/src/pg_helpers.spec.ts +201 -0
  43. package/src/pg_helpers.ts +44 -5
  44. package/src/server.ts +24 -0
  45. package/src/service/build_plan.spec.ts +128 -2
  46. package/src/service/build_plan.ts +239 -17
  47. package/src/service/connection.ts +263 -7
  48. package/src/service/connection_config.spec.ts +48 -0
  49. package/src/service/connection_config.ts +21 -8
  50. package/src/service/connection_federation.spec.ts +184 -0
  51. package/src/service/embedding_provider.spec.ts +329 -0
  52. package/src/service/embedding_provider.ts +236 -0
  53. package/src/service/environment.ts +274 -12
  54. package/src/service/environment_store.spec.ts +678 -3
  55. package/src/service/environment_store.ts +449 -33
  56. package/src/service/environment_store_clone.spec.ts +350 -0
  57. package/src/service/manifest_loader.spec.ts +68 -13
  58. package/src/service/manifest_loader.ts +67 -19
  59. package/src/service/materialization_build_session.spec.ts +435 -0
  60. package/src/service/materialization_build_session.ts +681 -0
  61. package/src/service/materialization_eligibility.spec.ts +158 -0
  62. package/src/service/materialization_eligibility.ts +305 -0
  63. package/src/service/materialization_serve_transform.spec.ts +1003 -0
  64. package/src/service/materialization_serve_transform.ts +779 -0
  65. package/src/service/materialization_service.spec.ts +774 -7
  66. package/src/service/materialization_service.ts +1107 -42
  67. package/src/service/materialization_test_fixtures.ts +7 -0
  68. package/src/service/model.spec.ts +207 -0
  69. package/src/service/model.ts +540 -52
  70. package/src/service/model_storage_serve.spec.ts +193 -0
  71. package/src/service/model_storage_serve_joins.spec.ts +193 -0
  72. package/src/service/package.spec.ts +196 -0
  73. package/src/service/package.ts +385 -17
  74. package/src/service/persistence_policy.spec.ts +109 -0
  75. package/src/storage/duckdb/schema.ts +37 -0
  76. package/tests/fixtures/xlsx/database.xlsx +0 -0
  77. package/tests/integration/first_boot/readiness_line.integration.spec.ts +177 -0
  78. package/tests/integration/materialization/manifest_binding.integration.spec.ts +104 -0
  79. package/tests/integration/mcp/mcp_get_context_semantic.integration.spec.ts +235 -0
@@ -0,0 +1,808 @@
1
+ import { createHash } from "crypto";
2
+ import { E_ALREADY_LOCKED, Mutex, tryAcquire } from "async-mutex";
3
+ import { logger } from "../../logger";
4
+ import { DuckDBConnection } from "../../storage/duckdb/DuckDBConnection";
5
+ import type { Package } from "../../service/package";
6
+ import {
7
+ EmbeddingProvider,
8
+ EMBEDDING_BATCH_TIMEOUT_MS,
9
+ EMBEDDING_QUERY_TIMEOUT_MS,
10
+ prepareEmbeddingInput,
11
+ } from "../../service/embedding_provider";
12
+
13
+ /**
14
+ * Minimum cosine similarity for a semantic hit. Below this the entity is
15
+ * dropped, so a query about something the package does not model returns
16
+ * an empty result rather than the k least-unrelated entities. Agents are
17
+ * taught to treat an empty result as "not in this package"; unfiltered
18
+ * top-k would destroy that signal. 0.20 matches the hosted retrieval
19
+ * pipeline's min_score; tune against get_context_eval.ts.
20
+ */
21
+ export const MIN_SIMILARITY = 0.2;
22
+ /**
23
+ * Packages with more entities than this stay lexical: the first embed of
24
+ * such a package would take minutes of provider calls and rate limits.
25
+ * The bundled examples sit around a few hundred entities.
26
+ */
27
+ export const MAX_EMBEDDED_ENTITIES = 5_000;
28
+ /**
29
+ * After a provider failure the semantic path short-circuits to lexical
30
+ * for this long, so a down or misconfigured endpoint costs one timeout
31
+ * per window, not one per call.
32
+ */
33
+ export const PROVIDER_FAILURE_COOLDOWN_MS = 60_000;
34
+ /**
35
+ * After a dims-mismatch purge, suppress further purges for this long. It
36
+ * MUST exceed PROVIDER_FAILURE_COOLDOWN_MS. A dims-mismatch backoff arms
37
+ * the cooldown, which then blocks every heal-reaching call for one
38
+ * cooldown window; if the two windows were equal, the first call past the
39
+ * cooldown would always find the purge guard expired too and re-purge,
40
+ * re-embedding the whole package once per cooldown window forever. With a
41
+ * longer window a durably dims-inconsistent provider (e.g. mid-migration
42
+ * replicas serving different dims under one model name) is throttled to
43
+ * at most one re-embed per this interval, staying lexical between, while
44
+ * still re-adopting a genuinely-new stable dimensionality within the
45
+ * window. Deliberately not "never re-purge": that would strand the cache
46
+ * on the old dims if the provider later settles on a new one.
47
+ */
48
+ export const HEAL_PURGE_SUPPRESSION_MS = 10 * PROVIDER_FAILURE_COOLDOWN_MS;
49
+
50
+ /** The subset of the tool's Entity shape the index needs. */
51
+ export interface EmbeddableEntity {
52
+ kind: string;
53
+ name: string;
54
+ source: string | undefined;
55
+ modelPath: string;
56
+ // #(doc)-only text (see get_context_tool docOnlyText). Deliberately NOT
57
+ // the response `doc`, which can fall back to raw annotation lines and
58
+ // would leak #(authorize) predicates to the embedding provider.
59
+ embedDoc: string;
60
+ }
61
+
62
+ export interface SemanticHit {
63
+ kind: string;
64
+ name: string;
65
+ source: string | undefined;
66
+ score: number;
67
+ }
68
+
69
+ export type SemanticUnavailableReason =
70
+ | "cooldown"
71
+ | "too-many-entities"
72
+ | "indexing"
73
+ | "error";
74
+
75
+ export type SemanticSearchResult =
76
+ | { hits: SemanticHit[] }
77
+ | { unavailable: SemanticUnavailableReason };
78
+
79
+ /**
80
+ * Turn an identifier into the words a person would search with:
81
+ * `dep_delay` -> "dep delay", `totalSales` -> "total sales". This is the
82
+ * heart of closing the lexical token gap; the embedding model then maps
83
+ * "departure delay" near "dep delay" where BM25 could not.
84
+ */
85
+ export function humanizeName(name: string): string {
86
+ return name
87
+ .replace(/[_\-./]+/g, " ")
88
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
89
+ .toLowerCase()
90
+ .replace(/\s+/g, " ")
91
+ .trim();
92
+ }
93
+
94
+ /**
95
+ * The text embedded per entity: humanized name plus its `#(doc)` text.
96
+ * Deliberately minimal (no kind, no parent source); the recipe is
97
+ * eval-tunable via get_context_eval.ts. A punctuation-only identifier
98
+ * (`_` is legal Malloy) humanizes to nothing; fall back to the raw name
99
+ * so the embedded text stays meaningful. prepareEmbeddingInput (which
100
+ * both the hash and the request path apply) separately guarantees that
101
+ * even a whitespace-only name never reaches the provider as an empty
102
+ * input.
103
+ */
104
+ export function embeddingText(entity: EmbeddableEntity): string {
105
+ const name = humanizeName(entity.name) || entity.name;
106
+ return entity.embedDoc ? `${name}: ${entity.embedDoc}` : name;
107
+ }
108
+
109
+ function contentHash(text: string): string {
110
+ return createHash("sha256").update(text).digest("hex");
111
+ }
112
+
113
+ /** '' in the entity_source column encodes "no parent source". */
114
+ function sourceColumn(source: string | undefined): string {
115
+ return source ?? "";
116
+ }
117
+
118
+ /**
119
+ * The stable key for one entity: `kind|source|name`. Shared by the index
120
+ * dedup, the WeakMap-free row identity, and the tool's result mapping so
121
+ * the format lives in exactly one place. Pass "" for a sourceless entity.
122
+ */
123
+ export function entityRowKey(
124
+ kind: string,
125
+ source: string,
126
+ name: string,
127
+ ): string {
128
+ return `${kind}|${source}|${name}`;
129
+ }
130
+
131
+ // Sync state, two layers.
132
+ //
133
+ // Per package NAME (`syncMeta`): a mutex serializing every read-diff-write
134
+ // section (sync AND the heal's purge) so a reload racing an in-flight sync
135
+ // cannot tear rows; a `generation` counter bumped by every purge, so a
136
+ // purge invalidates the memo of EVERY Package instance, not just the
137
+ // caller's (a reloaded instance's `done` memo must not survive a purge
138
+ // over a now-empty table); `lastPurgeAtMs`, which bounds how often the
139
+ // heal may purge (a backend serving inconsistent dimensionalities
140
+ // otherwise causes an unbounded purge / full-re-embed loop); and
141
+ // `failureAtMs`, the per-package provider cool-down. The cool-down is
142
+ // scoped per package, NOT global: a query timeout or dims-mismatch on
143
+ // one package must not force every other healthy, correctly-cached
144
+ // package to lexical for the window. If the endpoint is genuinely down,
145
+ // each package cools itself on its own first failed probe (one wasted
146
+ // probe per package per window, negligible at the entity counts a single
147
+ // Publisher serves).
148
+ //
149
+ // Per package INSTANCE (`syncState`, WeakMap): memoizes "this instance is
150
+ // synced" (reload swaps the instance, so entity-set staleness clears
151
+ // itself, same contract as the tool's lunr cache). A rejected sync
152
+ // promise is evicted so one transient failure is not permanent.
153
+ // `providerKey` records which model/dims request-config the sync used, so
154
+ // switching EMBEDDING_MODEL or EMBEDDING_DIMENSIONS re-syncs promptly.
155
+ interface PackageSyncMeta {
156
+ mutex: Mutex;
157
+ generation: number;
158
+ lastPurgeAtMs: number;
159
+ failureAtMs: number;
160
+ }
161
+ interface SyncState {
162
+ done: boolean;
163
+ providerKey: string;
164
+ generation: number;
165
+ }
166
+ const syncState = new WeakMap<Package, SyncState>();
167
+ const syncMeta = new Map<string, PackageSyncMeta>();
168
+ // Every generation value ever issued is globally unique (drawn from this
169
+ // counter, never incremented locally). That makes deleting a syncMeta
170
+ // entry safe: a re-minted meta for the same name can never coincide with
171
+ // a generation some live memo recorded under the old meta, which would
172
+ // let that memo be trusted over a table the deletion just emptied.
173
+ let generationCounter = 0;
174
+ const oversizeWarned = new Set<string>();
175
+
176
+ // Effective timing windows. Mutable only so tests can drive real expiry
177
+ // deterministically with small sleeps (the cooldown-vs-suppression
178
+ // relationship is real-time and not otherwise unit-observable); production
179
+ // always uses the exported constants.
180
+ let cooldownMs = PROVIDER_FAILURE_COOLDOWN_MS;
181
+ let purgeSuppressionMs = HEAL_PURGE_SUPPRESSION_MS;
182
+
183
+ function metaKey(environmentName: string, packageName: string): string {
184
+ return `${environmentName}\x00${packageName}`;
185
+ }
186
+
187
+ function metaFor(
188
+ environmentName: string,
189
+ packageName: string,
190
+ ): PackageSyncMeta {
191
+ const key = metaKey(environmentName, packageName);
192
+ let meta = syncMeta.get(key);
193
+ if (!meta) {
194
+ meta = {
195
+ mutex: new Mutex(),
196
+ generation: ++generationCounter,
197
+ lastPurgeAtMs: 0,
198
+ failureAtMs: 0,
199
+ };
200
+ syncMeta.set(key, meta);
201
+ }
202
+ return meta;
203
+ }
204
+
205
+ function markProviderFailure(meta: PackageSyncMeta): void {
206
+ meta.failureAtMs = Date.now();
207
+ }
208
+
209
+ function inCooldown(meta: PackageSyncMeta): boolean {
210
+ return Date.now() - meta.failureAtMs < cooldownMs;
211
+ }
212
+
213
+ /** Test seam: forget cool-down, purge, timing, and oversize state. */
214
+ export function _resetEmbeddingIndexStateForTests(): void {
215
+ oversizeWarned.clear();
216
+ syncMeta.clear();
217
+ cooldownMs = PROVIDER_FAILURE_COOLDOWN_MS;
218
+ purgeSuppressionMs = HEAL_PURGE_SUPPRESSION_MS;
219
+ }
220
+
221
+ /** Test seam: shrink the timing windows to drive real expiry in tests. */
222
+ export function _setTimingForTests(t: {
223
+ cooldownMs?: number;
224
+ purgeSuppressionMs?: number;
225
+ }): void {
226
+ if (t.cooldownMs !== undefined) cooldownMs = t.cooldownMs;
227
+ if (t.purgeSuppressionMs !== undefined)
228
+ purgeSuppressionMs = t.purgeSuppressionMs;
229
+ }
230
+
231
+ /** Test seam: clear the per-package cool-downs, keeping sync metas. */
232
+ export function _clearProviderCooldownForTests(): void {
233
+ for (const meta of syncMeta.values()) {
234
+ meta.failureAtMs = 0;
235
+ }
236
+ }
237
+
238
+ /** Test seam: observe syncMeta growth (the churn-leak pin). */
239
+ export function _syncMetaSizeForTests(): number {
240
+ return syncMeta.size;
241
+ }
242
+
243
+ /** Test seam: read a package's lastPurgeAtMs (the heal-terminal pin). */
244
+ export function _lastPurgeAtMsForTests(
245
+ environmentName: string,
246
+ packageName: string,
247
+ ): number | undefined {
248
+ return syncMeta.get(metaKey(environmentName, packageName))?.lastPurgeAtMs;
249
+ }
250
+
251
+ interface ExistingRow {
252
+ entity_kind: string;
253
+ entity_source: string;
254
+ entity_name: string;
255
+ content_hash: string;
256
+ embedding_model: string;
257
+ dims: number;
258
+ }
259
+
260
+ /**
261
+ * Remove a deleted package's cached embeddings so package churn does not
262
+ * grow publisher.db forever. Called best-effort from the deletion paths;
263
+ * a missed cleanup is inert (every read is scoped by environment and
264
+ * package) and `--init` reclaims it. Runs under the package mutex so it
265
+ * cannot tear an in-flight sync, and bumps the generation so any live
266
+ * instance memo stops trusting its rows.
267
+ */
268
+ export async function deletePackageEmbeddings(
269
+ db: DuckDBConnection,
270
+ environmentName: string,
271
+ packageName: string,
272
+ ): Promise<void> {
273
+ const meta = metaFor(environmentName, packageName);
274
+ await meta.mutex.runExclusive(async () => {
275
+ // Same orphan guard as every other mutexed writer: a second delete
276
+ // queued on the old meta must not run again (its map removal would
277
+ // hit a re-minted meta's entry and orphan a live sync).
278
+ if (syncMeta.get(metaKey(environmentName, packageName)) !== meta) {
279
+ return;
280
+ }
281
+ await db.run(
282
+ `DELETE FROM entity_embeddings
283
+ WHERE environment_name = ? AND package_name = ?`,
284
+ [environmentName, packageName],
285
+ );
286
+ meta.generation = ++generationCounter;
287
+ // Removing the entry keeps package churn from growing the map for
288
+ // the process lifetime; it is safe because generations are
289
+ // globally unique (a re-minted meta can never match a memo issued
290
+ // under this one). It MUST happen inside the mutexed section: the
291
+ // mutex hands off to the next queued waiter the moment this
292
+ // callback returns, before any outer continuation runs, and a
293
+ // queued sync's orphan guard has to see the entry already gone or
294
+ // it will re-embed rows for the deleted package.
295
+ syncMeta.delete(metaKey(environmentName, packageName));
296
+ });
297
+ }
298
+
299
+ /**
300
+ * Remove a deleted environment's cached embeddings. Packages with live
301
+ * sync state are cleaned under their mutex; a final environment-wide
302
+ * sweep covers packages never queried in this process. The mutex gives
303
+ * mutual exclusion, not ordering: a getContext call already in flight at
304
+ * deletion time can cold-kick a sync that lands after the cleanup and
305
+ * rewrites its package's rows. Accepted: such rows are inert (the
306
+ * package is gone from every serving path), are reconciled if the name
307
+ * returns, and are reclaimed by --init.
308
+ */
309
+ export async function deleteEnvironmentEmbeddings(
310
+ db: DuckDBConnection,
311
+ environmentName: string,
312
+ ): Promise<void> {
313
+ const prefix = `${environmentName}\x00`;
314
+ for (const [key] of syncMeta) {
315
+ if (key.startsWith(prefix)) {
316
+ await deletePackageEmbeddings(
317
+ db,
318
+ environmentName,
319
+ key.slice(prefix.length),
320
+ );
321
+ }
322
+ }
323
+ await db.run(`DELETE FROM entity_embeddings WHERE environment_name = ?`, [
324
+ environmentName,
325
+ ]);
326
+ }
327
+
328
+ /**
329
+ * Bring the entity_embeddings rows for one package in line with the
330
+ * current entity set: embed new/changed entities (content-hash diff, so
331
+ * unchanged entities never re-embed, across restarts too), upsert them,
332
+ * and delete rows for entities that no longer exist. Runs under the
333
+ * package-name mutex. Returns the package generation the sync ran under.
334
+ * Throws on provider or storage failure; partial writes are safe because
335
+ * the hash diff self-heals on the next sync.
336
+ */
337
+ async function syncPackageEmbeddings(
338
+ db: DuckDBConnection,
339
+ provider: EmbeddingProvider,
340
+ environmentName: string,
341
+ packageName: string,
342
+ entities: EmbeddableEntity[],
343
+ ): Promise<number> {
344
+ const meta = metaFor(environmentName, packageName);
345
+ return meta.mutex.runExclusive(async () => {
346
+ // The meta may have been orphaned while this sync waited on the
347
+ // mutex (deletePackageEmbeddings removes the map entry, e.g. the
348
+ // package was deleted with this call already in flight). A sync
349
+ // under an orphaned meta is no longer serialized against syncs
350
+ // under a re-minted meta for the same name, so it must not write.
351
+ // Aborting is safe: the caller's memo records this orphaned
352
+ // generation, which can never match a fresh meta's (generations
353
+ // are globally unique), so the next call re-syncs under the fresh
354
+ // meta.
355
+ if (syncMeta.get(metaKey(environmentName, packageName)) !== meta) {
356
+ logger.debug(
357
+ "[MCP Tool getContext] Skipping embedding sync for a deleted package",
358
+ { environmentName, packageName },
359
+ );
360
+ return meta.generation;
361
+ }
362
+
363
+ // Everything here runs under the package mutex: a purge (same
364
+ // mutex) either finished before this sync started or starts after
365
+ // it ends, so the generation moves mid-sync only via the bump at
366
+ // the bottom of this function.
367
+ const existingRows = await db.all<ExistingRow>(
368
+ `SELECT entity_kind, entity_source, entity_name, content_hash,
369
+ embedding_model, CAST(dims AS INTEGER) AS dims
370
+ FROM entity_embeddings
371
+ WHERE environment_name = ? AND package_name = ?`,
372
+ [environmentName, packageName],
373
+ );
374
+ const existing = new Map(
375
+ existingRows.map((r) => [
376
+ entityRowKey(r.entity_kind, r.entity_source, r.entity_name),
377
+ r,
378
+ ]),
379
+ );
380
+
381
+ const desired = entities.map((entity) => {
382
+ const text = prepareEmbeddingInput(embeddingText(entity));
383
+ return { entity, text, hash: contentHash(text) };
384
+ });
385
+ const desiredKeys = new Set(
386
+ desired.map((d) =>
387
+ entityRowKey(
388
+ d.entity.kind,
389
+ sourceColumn(d.entity.source),
390
+ d.entity.name,
391
+ ),
392
+ ),
393
+ );
394
+
395
+ // A row is current when its text hash and model match; a model
396
+ // switch re-embeds in place (upsert) rather than colliding on the
397
+ // primary key. Dimensionality is deliberately NOT part of this
398
+ // check: `dims` stores the ACTUAL response vector length, and a
399
+ // provider that ignores the `dimensions` request parameter (e.g.
400
+ // Ollama) would otherwise mismatch the configured value forever
401
+ // and re-embed the whole package on every instance swap. A real
402
+ // dims change is caught at query time by the stale-row heal.
403
+ const toEmbed = desired.filter((d) => {
404
+ const row = existing.get(
405
+ entityRowKey(
406
+ d.entity.kind,
407
+ sourceColumn(d.entity.source),
408
+ d.entity.name,
409
+ ),
410
+ );
411
+ if (!row) return true;
412
+ if (row.content_hash !== d.hash) return true;
413
+ if (row.embedding_model !== provider.model) return true;
414
+ return false;
415
+ });
416
+
417
+ // A sync that CHANGED rows invalidates every other instance's
418
+ // snapshot the same way a purge does: without the bump below, a
419
+ // call blocked behind this sync on the mutex could return its
420
+ // pre-sync cosine snapshot (possibly empty) marked semantic. The
421
+ // bump runs in a finally: a write failure mid-loop (disk full)
422
+ // has already changed rows, and those torn writes must invalidate
423
+ // snapshots too, even though this sync rejects.
424
+ let rowsChanged = false;
425
+ let deleted = 0;
426
+ try {
427
+ if (toEmbed.length > 0) {
428
+ const vectors = await provider.embedBatch(
429
+ toEmbed.map((d) => d.text),
430
+ EMBEDDING_BATCH_TIMEOUT_MS,
431
+ );
432
+ const now = new Date().toISOString();
433
+ for (let i = 0; i < toEmbed.length; i++) {
434
+ const d = toEmbed[i];
435
+ const vector = vectors[i];
436
+ await db.run(
437
+ `INSERT INTO entity_embeddings (
438
+ environment_name, package_name, entity_kind, entity_source,
439
+ entity_name, model_path, content_hash, embedding_model,
440
+ dims, embedding, updated_at
441
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CAST(? AS FLOAT[]), ?)
442
+ ON CONFLICT (environment_name, package_name, entity_kind, entity_source, entity_name)
443
+ DO UPDATE SET
444
+ model_path = EXCLUDED.model_path,
445
+ content_hash = EXCLUDED.content_hash,
446
+ embedding_model = EXCLUDED.embedding_model,
447
+ dims = EXCLUDED.dims,
448
+ embedding = EXCLUDED.embedding,
449
+ updated_at = EXCLUDED.updated_at`,
450
+ [
451
+ environmentName,
452
+ packageName,
453
+ d.entity.kind,
454
+ sourceColumn(d.entity.source),
455
+ d.entity.name,
456
+ d.entity.modelPath,
457
+ d.hash,
458
+ provider.model,
459
+ vector.length,
460
+ JSON.stringify(vector),
461
+ now,
462
+ ],
463
+ );
464
+ rowsChanged = true;
465
+ }
466
+ }
467
+
468
+ for (const [rowKey, row] of existing) {
469
+ if (!desiredKeys.has(rowKey)) {
470
+ await db.run(
471
+ `DELETE FROM entity_embeddings
472
+ WHERE environment_name = ? AND package_name = ?
473
+ AND entity_kind = ? AND entity_source = ? AND entity_name = ?`,
474
+ [
475
+ environmentName,
476
+ packageName,
477
+ row.entity_kind,
478
+ row.entity_source,
479
+ row.entity_name,
480
+ ],
481
+ );
482
+ rowsChanged = true;
483
+ deleted++;
484
+ }
485
+ }
486
+ } finally {
487
+ if (rowsChanged) {
488
+ meta.generation = ++generationCounter;
489
+ }
490
+ }
491
+
492
+ logger.debug("[MCP Tool getContext] Synced entity embeddings", {
493
+ environmentName,
494
+ packageName,
495
+ entityCount: entities.length,
496
+ embedded: toEmbed.length,
497
+ deleted,
498
+ });
499
+ return meta.generation;
500
+ });
501
+ }
502
+
503
+ /**
504
+ * Semantic retrieval for tier 4 of malloy_getContext. Returns ranked
505
+ * hits, or a reason the semantic path is unavailable so the caller can
506
+ * fall back to lexical. Never throws.
507
+ *
508
+ * Cold-start contract: the first call for a Package instance kicks off
509
+ * the embedding sync in the background and reports `indexing`, so no
510
+ * call ever waits on a bulk embed; subsequent calls are semantic once the
511
+ * sync lands.
512
+ */
513
+ export async function trySemanticSearch(args: {
514
+ db: DuckDBConnection;
515
+ provider: EmbeddingProvider;
516
+ pkg: Package;
517
+ environmentName: string;
518
+ packageName: string;
519
+ entities: EmbeddableEntity[];
520
+ query: string;
521
+ limit: number;
522
+ sourceName?: string;
523
+ }): Promise<SemanticSearchResult> {
524
+ const {
525
+ db,
526
+ provider,
527
+ pkg,
528
+ environmentName,
529
+ packageName,
530
+ entities,
531
+ query,
532
+ limit,
533
+ sourceName,
534
+ } = args;
535
+
536
+ if (entities.length > MAX_EMBEDDED_ENTITIES) {
537
+ const key = `${environmentName}\x00${packageName}`;
538
+ if (!oversizeWarned.has(key)) {
539
+ oversizeWarned.add(key);
540
+ logger.warn(
541
+ "[MCP Tool getContext] Package exceeds the semantic index entity cap; using lexical ranking",
542
+ {
543
+ environmentName,
544
+ packageName,
545
+ entityCount: entities.length,
546
+ cap: MAX_EMBEDDED_ENTITIES,
547
+ },
548
+ );
549
+ }
550
+ return { unavailable: "too-many-entities" };
551
+ }
552
+
553
+ const providerKey = `${provider.model}\x00${provider.dimensions ?? ""}`;
554
+ const meta = metaFor(environmentName, packageName);
555
+ // Per-package cool-down: a recent provider failure for THIS package
556
+ // (sync, query embed, or a dims-mismatch backoff) keeps it lexical for
557
+ // the window without touching any other package.
558
+ if (inCooldown(meta)) {
559
+ return { unavailable: "cooldown" };
560
+ }
561
+ // Captured at entry: if a concurrent heal purges rows while this call
562
+ // is searching, the generation moves and this call must not assert
563
+ // anything about the (now-changed) table; see the re-check below.
564
+ const entryGeneration = meta.generation;
565
+ let state = syncState.get(pkg);
566
+ if (
567
+ !state ||
568
+ state.providerKey !== providerKey ||
569
+ // A purge bumped the generation after this instance synced: its
570
+ // rows are gone, so a `done` memo must not be trusted.
571
+ (state.done && state.generation !== meta.generation)
572
+ ) {
573
+ // No await between the get above and the set below: single-threaded
574
+ // JS therefore guarantees concurrent calls cannot both kick a sync
575
+ // for the same instance. (The per-name mutex still guards the
576
+ // cross-instance reload race.)
577
+ const tracked: SyncState = {
578
+ done: false,
579
+ providerKey,
580
+ generation: meta.generation,
581
+ };
582
+ // The sync promise is deliberately not stored: nothing may ever
583
+ // await it (cold starts answer lexically); completion is observed
584
+ // through `done` and failure through the handler below.
585
+ syncPackageEmbeddings(
586
+ db,
587
+ provider,
588
+ environmentName,
589
+ packageName,
590
+ entities,
591
+ ).then(
592
+ (generation) => {
593
+ tracked.generation = generation;
594
+ tracked.done = true;
595
+ },
596
+ (error: unknown) => {
597
+ if (syncState.get(pkg) === tracked) {
598
+ syncState.delete(pkg);
599
+ }
600
+ markProviderFailure(meta);
601
+ logger.warn(
602
+ "[MCP Tool getContext] Embedding sync failed; semantic ranking cooling down",
603
+ {
604
+ environmentName,
605
+ packageName,
606
+ error: error instanceof Error ? error.message : String(error),
607
+ },
608
+ );
609
+ },
610
+ );
611
+ syncState.set(pkg, tracked);
612
+ state = tracked;
613
+ }
614
+ if (!state.done) {
615
+ return { unavailable: "indexing" };
616
+ }
617
+
618
+ let queryVector: number[];
619
+ try {
620
+ [queryVector] = await provider.embedBatch(
621
+ [query],
622
+ EMBEDDING_QUERY_TIMEOUT_MS,
623
+ );
624
+ } catch (error) {
625
+ markProviderFailure(meta);
626
+ const message = error instanceof Error ? error.message : String(error);
627
+ logger.warn(
628
+ "[MCP Tool getContext] Query embedding failed; falling back to lexical ranking",
629
+ { environmentName, packageName, error: message },
630
+ );
631
+ return { unavailable: "error" };
632
+ }
633
+
634
+ try {
635
+ const rows = await db.all<{
636
+ entity_kind: string;
637
+ entity_source: string;
638
+ entity_name: string;
639
+ score: number;
640
+ }>(
641
+ `SELECT * FROM (
642
+ SELECT entity_kind, entity_source, entity_name,
643
+ list_cosine_similarity(embedding, CAST(? AS FLOAT[])) AS score
644
+ FROM entity_embeddings
645
+ WHERE environment_name = ? AND package_name = ?
646
+ AND embedding_model = ? AND dims = ?
647
+ ${sourceName !== undefined ? "AND entity_source = ?" : ""}
648
+ )
649
+ WHERE score >= ?
650
+ ORDER BY score DESC, entity_name
651
+ LIMIT ?`,
652
+ [
653
+ JSON.stringify(queryVector),
654
+ environmentName,
655
+ packageName,
656
+ provider.model,
657
+ queryVector.length,
658
+ ...(sourceName !== undefined ? [sourceName] : []),
659
+ MIN_SIMILARITY,
660
+ limit,
661
+ ],
662
+ );
663
+ // A sync or heal holding the package mutex right now may be
664
+ // rewriting rows underneath the query that just ran: that snapshot
665
+ // is unreliable (it can be partial or empty mid-write) and must
666
+ // not be served as semantic. Completed writers are caught by the
667
+ // generation re-check below; this catches the in-flight ones.
668
+ if (meta.mutex.isLocked()) {
669
+ return { unavailable: "indexing" };
670
+ }
671
+
672
+ // Invariant check, every call: no row may exist that does not
673
+ // match the provider's current (model, dims). The sync diff cannot
674
+ // see a dims change (row hashes still match), so stale-dims rows
675
+ // are detected here. Detecting STALE rows directly, rather than
676
+ // inferring from an empty result, also heals the mixed state where
677
+ // a partial sync wrote some rows at the new dimensionality and the
678
+ // rest are stranded at the old one (an empty-result trigger would
679
+ // never fire there, silently hiding the stranded entities forever).
680
+ const staleRows = await db.get<{ n: number }>(
681
+ `SELECT CAST(COUNT(*) AS INTEGER) AS n FROM entity_embeddings
682
+ WHERE environment_name = ? AND package_name = ?
683
+ AND NOT (embedding_model = ? AND dims = ?)`,
684
+ [environmentName, packageName, provider.model, queryVector.length],
685
+ );
686
+ if ((staleRows?.n ?? 0) > 0) {
687
+ // The check-and-purge runs under the package-name mutex so it
688
+ // cannot interleave with a sync, and the generation bump
689
+ // invalidates every instance's memo, not just this caller's.
690
+ // Acquired WITHOUT waiting: a held mutex means a bulk embed or
691
+ // another heal is mid-flight, and this call must answer as
692
+ // indexing rather than queue minutes behind it (the docstring's
693
+ // no-call-waits-on-a-bulk-embed contract).
694
+ let outcome: "none" | "purged" | "backoff" | "busy";
695
+ try {
696
+ outcome = await tryAcquire(meta.mutex).runExclusive(async () => {
697
+ // Same orphaned-meta guard as the sync: never write under
698
+ // a meta that deletePackageEmbeddings removed.
699
+ if (
700
+ syncMeta.get(metaKey(environmentName, packageName)) !== meta
701
+ ) {
702
+ return "none";
703
+ }
704
+ const again = await db.get<{ n: number }>(
705
+ `SELECT CAST(COUNT(*) AS INTEGER) AS n FROM entity_embeddings
706
+ WHERE environment_name = ? AND package_name = ?
707
+ AND NOT (embedding_model = ? AND dims = ?)`,
708
+ [
709
+ environmentName,
710
+ packageName,
711
+ provider.model,
712
+ queryVector.length,
713
+ ],
714
+ );
715
+ if ((again?.n ?? 0) === 0) return "none";
716
+ // Backoff: at most one purge per suppression window. Within
717
+ // it, a fresh mismatch means the endpoint is serving
718
+ // inconsistent dimensionalities; re-purging would re-embed
719
+ // the whole package, so cool down and stay lexical instead.
720
+ // lastPurgeAtMs is NOT advanced here: the window is measured
721
+ // from the last real PURGE, and because the suppression
722
+ // window is longer than the cooldown (see
723
+ // HEAL_PURGE_SUPPRESSION_MS), a call arriving after the
724
+ // cooldown clears still lands inside the suppression window
725
+ // and backs off, so a durably-inconsistent provider does
726
+ // NOT re-purge once per cooldown window.
727
+ const now = Date.now();
728
+ if (now - meta.lastPurgeAtMs < purgeSuppressionMs) {
729
+ markProviderFailure(meta);
730
+ logger.warn(
731
+ "[MCP Tool getContext] Repeated embedding dimensionality mismatch; the endpoint looks inconsistent, cooling down",
732
+ {
733
+ environmentName,
734
+ packageName,
735
+ model: provider.model,
736
+ queryDims: queryVector.length,
737
+ },
738
+ );
739
+ return "backoff";
740
+ }
741
+ logger.warn(
742
+ "[MCP Tool getContext] Cached embeddings do not match the provider's current model/dimensions; purging stale rows and re-syncing",
743
+ {
744
+ environmentName,
745
+ packageName,
746
+ model: provider.model,
747
+ queryDims: queryVector.length,
748
+ staleRows: again?.n,
749
+ },
750
+ );
751
+ // Only the stale rows: current-dims rows stay, so the
752
+ // follow-up sync re-embeds just what was stranded.
753
+ await db.run(
754
+ `DELETE FROM entity_embeddings
755
+ WHERE environment_name = ? AND package_name = ?
756
+ AND NOT (embedding_model = ? AND dims = ?)`,
757
+ [
758
+ environmentName,
759
+ packageName,
760
+ provider.model,
761
+ queryVector.length,
762
+ ],
763
+ );
764
+ meta.lastPurgeAtMs = now;
765
+ meta.generation = ++generationCounter;
766
+ return "purged";
767
+ });
768
+ } catch (error) {
769
+ if (error !== E_ALREADY_LOCKED) throw error;
770
+ outcome = "busy";
771
+ }
772
+ if (outcome === "purged" || outcome === "busy") {
773
+ if (outcome === "purged") {
774
+ syncState.delete(pkg);
775
+ }
776
+ return { unavailable: "indexing" };
777
+ }
778
+ if (outcome === "backoff") {
779
+ return { unavailable: "cooldown" };
780
+ }
781
+ }
782
+
783
+ // A purge moved the generation while this call was searching: the
784
+ // rows snapshot above is unreliable (possibly empty because a
785
+ // concurrent heal deleted mid-search), and an unreliable empty
786
+ // result must never be served as semantic "nothing relevant here".
787
+ // Answer as indexing (marked lexical); the next call is consistent.
788
+ if (meta.generation !== entryGeneration) {
789
+ return { unavailable: "indexing" };
790
+ }
791
+
792
+ return {
793
+ hits: rows.map((row) => ({
794
+ kind: row.entity_kind,
795
+ source: row.entity_source === "" ? undefined : row.entity_source,
796
+ name: row.entity_name,
797
+ score: row.score,
798
+ })),
799
+ };
800
+ } catch (error) {
801
+ const message = error instanceof Error ? error.message : String(error);
802
+ logger.warn(
803
+ "[MCP Tool getContext] Semantic search failed; falling back to lexical ranking",
804
+ { environmentName, packageName, error: message },
805
+ );
806
+ return { unavailable: "error" };
807
+ }
808
+ }