@malloy-publisher/server 0.0.235 → 0.0.237

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 (319) hide show
  1. package/dist/app/api-doc.yaml +83 -27
  2. package/dist/app/assets/{EnvironmentPage-BsAnavYN.js → EnvironmentPage-DaP6CpgK.js} +1 -1
  3. package/dist/app/assets/{HomePage-CADE138j.js → HomePage-C9yNw3iu.js} +1 -1
  4. package/dist/app/assets/{LightMode-Cfh7KzN8.js → LightMode-Be4-G-dX.js} +1 -1
  5. package/dist/app/assets/{MainPage-CO3pRlnV.js → MainPage-J_rU2SgI.js} +2 -2
  6. package/dist/app/assets/{MaterializationsPage-p9YjkRXZ.js → MaterializationsPage-DHgPI4MO.js} +1 -1
  7. package/dist/app/assets/ModelPage-BzXfjTu5.js +1 -0
  8. package/dist/app/assets/{PackagePage-e4kN75YR.js → PackagePage-ByoSTLJ4.js} +1 -1
  9. package/dist/app/assets/RouteError-CrSeKN2r.js +1 -0
  10. package/dist/app/assets/{ThemeEditorPage-CciagFTq.js → ThemeEditorPage-Btxnmhtk.js} +1 -1
  11. package/dist/app/assets/{WorkbookPage-DNWmkCXa.js → WorkbookPage-D6JLPnWO.js} +1 -1
  12. package/dist/app/assets/{core-Rj_4rRnA.es-BZyvITuO.js → core-BSRgt1mG.es-Bc_D2Lq4.js} +1 -1
  13. package/dist/app/assets/{index-CH2AcDzc.js → index-BRijWj6K.js} +15 -15
  14. package/dist/app/assets/{index-DQa463gC.js → index-BX4D94xw.js} +1 -1
  15. package/dist/app/assets/{index-VBbcc8s6.js → index-Cvy0xtb7.js} +1 -1
  16. package/dist/app/assets/{index-DQpV7MyA.js → index-DMAhIJNC.js} +1 -1
  17. package/dist/app/index.html +1 -1
  18. package/dist/package_load_worker.mjs +59 -20
  19. package/dist/server.mjs +1342 -616
  20. package/package.json +8 -2
  21. package/.eslintrc.json +0 -44
  22. package/.prettierignore +0 -1
  23. package/.prettierrc +0 -4
  24. package/build.ts +0 -97
  25. package/dist/app/assets/ModelPage-C1OSTv-x.js +0 -1
  26. package/dist/app/assets/RouteError-CzbfOkng.js +0 -1
  27. package/eslint.config.mjs +0 -76
  28. package/k6-tests/README.md +0 -144
  29. package/k6-tests/bun.lock +0 -797
  30. package/k6-tests/load-test/load-test-crud-connections.ts +0 -263
  31. package/k6-tests/load-test/load-test-crud-packages.ts +0 -461
  32. package/k6-tests/load-test/load-test-crud-projects.ts +0 -160
  33. package/k6-tests/load-test/load-test-crud.ts +0 -142
  34. package/k6-tests/load-test/load-test.ts +0 -784
  35. package/k6-tests/package.json +0 -27
  36. package/k6-tests/scripts/run-with-otel.sh +0 -50
  37. package/k6-tests/smoke-test/smoke-test.ts +0 -158
  38. package/k6-tests/tsconfig.json +0 -29
  39. package/k6-tests/utils/client_factory.ts +0 -128
  40. package/k6-tests/utils/common.ts +0 -489
  41. package/k6-tests/utils/logger.ts +0 -62
  42. package/k6-tests/utils/types.d.ts +0 -35
  43. package/malloy_mcp.dxt +0 -0
  44. package/publisher.config.example.bigquery.json +0 -33
  45. package/publisher.config.example.duckdb.json +0 -23
  46. package/publisher.config.json +0 -23
  47. package/scripts/bake-duckdb-extensions.js +0 -110
  48. package/src/config.spec.ts +0 -1535
  49. package/src/config.theme.spec.ts +0 -178
  50. package/src/config.ts +0 -1142
  51. package/src/constants.ts +0 -105
  52. package/src/controller/compile.controller.ts +0 -40
  53. package/src/controller/connection.controller.spec.ts +0 -849
  54. package/src/controller/connection.controller.ts +0 -901
  55. package/src/controller/database.controller.ts +0 -24
  56. package/src/controller/materialization.controller.spec.ts +0 -293
  57. package/src/controller/materialization.controller.ts +0 -319
  58. package/src/controller/model.controller.ts +0 -164
  59. package/src/controller/package.controller.spec.ts +0 -273
  60. package/src/controller/package.controller.ts +0 -306
  61. package/src/controller/query.controller.ts +0 -152
  62. package/src/controller/theme.controller.ts +0 -83
  63. package/src/controller/watch-mode.controller.ts +0 -243
  64. package/src/data_styles.ts +0 -71
  65. package/src/default-publisher.config.json +0 -23
  66. package/src/ducklake_version.spec.ts +0 -163
  67. package/src/ducklake_version.ts +0 -153
  68. package/src/errors.spec.ts +0 -119
  69. package/src/errors.ts +0 -237
  70. package/src/filter_deprecation.spec.ts +0 -64
  71. package/src/filter_deprecation.ts +0 -42
  72. package/src/health.spec.ts +0 -123
  73. package/src/health.ts +0 -233
  74. package/src/heap_check.spec.ts +0 -144
  75. package/src/heap_check.ts +0 -144
  76. package/src/instrumentation.ts +0 -214
  77. package/src/json_utils.spec.ts +0 -51
  78. package/src/json_utils.ts +0 -33
  79. package/src/logger.spec.ts +0 -210
  80. package/src/logger.ts +0 -251
  81. package/src/malloy_pin_prereqs.spec.ts +0 -25
  82. package/src/materialization_metrics.spec.ts +0 -169
  83. package/src/materialization_metrics.ts +0 -346
  84. package/src/mcp/error_messages.spec.ts +0 -35
  85. package/src/mcp/error_messages.ts +0 -183
  86. package/src/mcp/handler_utils.spec.ts +0 -108
  87. package/src/mcp/handler_utils.ts +0 -251
  88. package/src/mcp/mcp_constants.ts +0 -6
  89. package/src/mcp/query_envelope.spec.ts +0 -229
  90. package/src/mcp/query_envelope.ts +0 -240
  91. package/src/mcp/server.protocol.spec.ts +0 -250
  92. package/src/mcp/server.ts +0 -87
  93. package/src/mcp/skills/build_skills_bundle.spec.ts +0 -51
  94. package/src/mcp/skills/build_skills_bundle.ts +0 -176
  95. package/src/mcp/skills/skills_bundle.json +0 -1
  96. package/src/mcp/skills/skills_bundle.spec.ts +0 -171
  97. package/src/mcp/tool_response.spec.ts +0 -108
  98. package/src/mcp/tool_response.ts +0 -138
  99. package/src/mcp/tools/compile_tool.spec.ts +0 -315
  100. package/src/mcp/tools/compile_tool.ts +0 -208
  101. package/src/mcp/tools/docs_search/build_docs_index.ts +0 -132
  102. package/src/mcp/tools/docs_search/malloy_docs_index.json +0 -1
  103. package/src/mcp/tools/docs_search_tool.spec.ts +0 -32
  104. package/src/mcp/tools/docs_search_tool.ts +0 -128
  105. package/src/mcp/tools/embedding_index.spec.ts +0 -1236
  106. package/src/mcp/tools/embedding_index.ts +0 -808
  107. package/src/mcp/tools/execute_query_tool.spec.ts +0 -294
  108. package/src/mcp/tools/execute_query_tool.ts +0 -316
  109. package/src/mcp/tools/get_context_eval.ts +0 -275
  110. package/src/mcp/tools/get_context_tool.spec.ts +0 -592
  111. package/src/mcp/tools/get_context_tool.ts +0 -585
  112. package/src/mcp/tools/reload_package_tool.spec.ts +0 -232
  113. package/src/mcp/tools/reload_package_tool.ts +0 -132
  114. package/src/mcp_config.spec.ts +0 -919
  115. package/src/mcp_config.ts +0 -425
  116. package/src/oom_guards.integration.spec.ts +0 -269
  117. package/src/package_load/package_load_pool.spec.ts +0 -252
  118. package/src/package_load/package_load_pool.ts +0 -931
  119. package/src/package_load/package_load_worker.ts +0 -1034
  120. package/src/package_load/protocol.ts +0 -364
  121. package/src/package_load/rpc_wait_accountant.spec.ts +0 -109
  122. package/src/package_load/rpc_wait_accountant.ts +0 -76
  123. package/src/package_load_metrics.spec.ts +0 -114
  124. package/src/package_load_metrics.ts +0 -127
  125. package/src/path_safety.spec.ts +0 -158
  126. package/src/path_safety.ts +0 -146
  127. package/src/pg_helpers.spec.ts +0 -223
  128. package/src/pg_helpers.ts +0 -52
  129. package/src/query_cap_metrics.spec.ts +0 -89
  130. package/src/query_cap_metrics.ts +0 -116
  131. package/src/query_concurrency.spec.ts +0 -247
  132. package/src/query_concurrency.ts +0 -237
  133. package/src/query_metadata_metrics.ts +0 -49
  134. package/src/query_param_utils.ts +0 -29
  135. package/src/query_timeout.spec.ts +0 -224
  136. package/src/query_timeout.ts +0 -179
  137. package/src/runtime/publisher.js +0 -328
  138. package/src/server-old.ts +0 -988
  139. package/src/server.ts +0 -2059
  140. package/src/service/annotations.spec.ts +0 -118
  141. package/src/service/annotations.ts +0 -91
  142. package/src/service/authorize.spec.ts +0 -154
  143. package/src/service/authorize.ts +0 -498
  144. package/src/service/authorize_integration.spec.ts +0 -2041
  145. package/src/service/build_plan.spec.ts +0 -685
  146. package/src/service/build_plan.ts +0 -797
  147. package/src/service/compile_authorize.spec.ts +0 -85
  148. package/src/service/compile_fragment_techniques.spec.ts +0 -156
  149. package/src/service/connection.spec.ts +0 -2360
  150. package/src/service/connection.ts +0 -2187
  151. package/src/service/connection_config.spec.ts +0 -734
  152. package/src/service/connection_config.ts +0 -899
  153. package/src/service/connection_federation.spec.ts +0 -184
  154. package/src/service/connection_fingerprint.spec.ts +0 -102
  155. package/src/service/connection_service.spec.ts +0 -711
  156. package/src/service/connection_service.ts +0 -291
  157. package/src/service/cron_evaluator.spec.ts +0 -79
  158. package/src/service/cron_evaluator.ts +0 -105
  159. package/src/service/db_utils.spec.ts +0 -1085
  160. package/src/service/db_utils.ts +0 -1502
  161. package/src/service/duckdb_instance_isolation.spec.ts +0 -137
  162. package/src/service/ducklake_lazy_attach.spec.ts +0 -110
  163. package/src/service/embedding_provider.spec.ts +0 -329
  164. package/src/service/embedding_provider.ts +0 -236
  165. package/src/service/environment.ts +0 -2191
  166. package/src/service/environment_admission.spec.ts +0 -344
  167. package/src/service/environment_compile.spec.ts +0 -197
  168. package/src/service/environment_store.spec.ts +0 -2274
  169. package/src/service/environment_store.ts +0 -2434
  170. package/src/service/environment_store_anchoring.spec.ts +0 -107
  171. package/src/service/environment_store_clone.spec.ts +0 -350
  172. package/src/service/explore_visibility.spec.ts +0 -434
  173. package/src/service/exports_probe.spec.ts +0 -107
  174. package/src/service/extension_fetch_policy.spec.ts +0 -256
  175. package/src/service/filter.spec.ts +0 -458
  176. package/src/service/filter.ts +0 -341
  177. package/src/service/filter_bypass.spec.ts +0 -418
  178. package/src/service/filter_integration.spec.ts +0 -1004
  179. package/src/service/freshness.spec.ts +0 -183
  180. package/src/service/freshness.ts +0 -112
  181. package/src/service/gcs_s3_utils.ts +0 -427
  182. package/src/service/given.ts +0 -105
  183. package/src/service/givens_integration.spec.ts +0 -219
  184. package/src/service/manifest_loader.spec.ts +0 -189
  185. package/src/service/manifest_loader.ts +0 -156
  186. package/src/service/materialization_build_session.spec.ts +0 -435
  187. package/src/service/materialization_build_session.ts +0 -681
  188. package/src/service/materialization_config_validation.spec.ts +0 -99
  189. package/src/service/materialization_config_validation.ts +0 -120
  190. package/src/service/materialization_eligibility.spec.ts +0 -158
  191. package/src/service/materialization_eligibility.ts +0 -305
  192. package/src/service/materialization_schedule_surface.spec.ts +0 -256
  193. package/src/service/materialization_scheduler.spec.ts +0 -289
  194. package/src/service/materialization_scheduler.ts +0 -304
  195. package/src/service/materialization_serve_transform.spec.ts +0 -1003
  196. package/src/service/materialization_serve_transform.ts +0 -779
  197. package/src/service/materialization_service.spec.ts +0 -2373
  198. package/src/service/materialization_service.ts +0 -2342
  199. package/src/service/materialization_test_fixtures.ts +0 -249
  200. package/src/service/model.spec.ts +0 -1157
  201. package/src/service/model.ts +0 -3370
  202. package/src/service/model_limits.spec.ts +0 -209
  203. package/src/service/model_limits.ts +0 -131
  204. package/src/service/model_storage_serve.spec.ts +0 -193
  205. package/src/service/model_storage_serve_joins.spec.ts +0 -193
  206. package/src/service/package.spec.ts +0 -612
  207. package/src/service/package.ts +0 -1799
  208. package/src/service/package_manifest.spec.ts +0 -230
  209. package/src/service/package_manifest.ts +0 -241
  210. package/src/service/package_memory_governor.spec.ts +0 -173
  211. package/src/service/package_memory_governor.ts +0 -233
  212. package/src/service/package_quote_bound_tables.spec.ts +0 -171
  213. package/src/service/package_race.spec.ts +0 -208
  214. package/src/service/package_reload_safety.spec.ts +0 -193
  215. package/src/service/package_rollback.spec.ts +0 -190
  216. package/src/service/package_worker_path.spec.ts +0 -645
  217. package/src/service/path_injection.spec.ts +0 -39
  218. package/src/service/persist_annotation_validation.spec.ts +0 -89
  219. package/src/service/persist_annotation_validation.ts +0 -59
  220. package/src/service/persistence_policy.spec.ts +0 -374
  221. package/src/service/proxy.spec.ts +0 -414
  222. package/src/service/proxy.ts +0 -248
  223. package/src/service/query_boundary.spec.ts +0 -470
  224. package/src/service/query_metadata.spec.ts +0 -408
  225. package/src/service/query_metadata.ts +0 -492
  226. package/src/service/query_metadata_identity.spec.ts +0 -149
  227. package/src/service/query_text.spec.ts +0 -79
  228. package/src/service/query_text.ts +0 -43
  229. package/src/service/quoting.spec.ts +0 -135
  230. package/src/service/quoting.ts +0 -78
  231. package/src/service/resolve_environment.ts +0 -15
  232. package/src/service/restricted_mode.spec.ts +0 -299
  233. package/src/service/source_extraction.ts +0 -225
  234. package/src/service/theme_store.ts +0 -199
  235. package/src/storage/DatabaseInterface.ts +0 -198
  236. package/src/storage/StorageManager.ts +0 -113
  237. package/src/storage/duckdb/ConnectionRepository.ts +0 -143
  238. package/src/storage/duckdb/DuckDBConnection.ts +0 -123
  239. package/src/storage/duckdb/DuckDBRepository.ts +0 -208
  240. package/src/storage/duckdb/EnvironmentRepository.ts +0 -148
  241. package/src/storage/duckdb/MaterializationRepository.spec.ts +0 -191
  242. package/src/storage/duckdb/MaterializationRepository.ts +0 -336
  243. package/src/storage/duckdb/PackageRepository.ts +0 -135
  244. package/src/storage/duckdb/schema.ts +0 -229
  245. package/src/stream_helpers.spec.ts +0 -280
  246. package/src/stream_helpers.ts +0 -162
  247. package/src/telemetry.ts +0 -20
  248. package/src/test_helpers/metrics_harness.ts +0 -166
  249. package/src/theme_key_parity.spec.ts +0 -57
  250. package/src/utils.ts +0 -29
  251. package/tests/fixtures/authorize-compile/model.malloy +0 -9
  252. package/tests/fixtures/authorize-compile/publisher.json +0 -4
  253. package/tests/fixtures/html-pages-nopublic/model.malloy +0 -1
  254. package/tests/fixtures/html-pages-nopublic/publisher.json +0 -5
  255. package/tests/fixtures/html-pages-test/data.csv +0 -3
  256. package/tests/fixtures/html-pages-test/public/assets/app.css +0 -3
  257. package/tests/fixtures/html-pages-test/public/barehtml.html +0 -4
  258. package/tests/fixtures/html-pages-test/public/bodymeta.html +0 -8
  259. package/tests/fixtures/html-pages-test/public/data.json +0 -1
  260. package/tests/fixtures/html-pages-test/public/fitcomment.html +0 -11
  261. package/tests/fixtures/html-pages-test/public/index.html +0 -9
  262. package/tests/fixtures/html-pages-test/public/nohead.html +0 -8
  263. package/tests/fixtures/html-pages-test/public/notfit.html +0 -13
  264. package/tests/fixtures/html-pages-test/public/slides.html +0 -12
  265. package/tests/fixtures/html-pages-test/public/sub/page2.html +0 -9
  266. package/tests/fixtures/html-pages-test/public/unterminated.html +0 -10
  267. package/tests/fixtures/html-pages-test/publisher.json +0 -5
  268. package/tests/fixtures/html-pages-test/report.malloy +0 -1
  269. package/tests/fixtures/persist-multi-level/data/orders.csv +0 -5
  270. package/tests/fixtures/persist-multi-level/multi_level.malloy +0 -18
  271. package/tests/fixtures/persist-multi-level/publisher.json +0 -5
  272. package/tests/fixtures/persist-schedule-test/data/orders.csv +0 -5
  273. package/tests/fixtures/persist-schedule-test/persist_schedule_test.malloy +0 -11
  274. package/tests/fixtures/persist-schedule-test/publisher.json +0 -7
  275. package/tests/fixtures/persist-schedule-warn-test/data/orders.csv +0 -5
  276. package/tests/fixtures/persist-schedule-warn-test/persist_warn_test.malloy +0 -11
  277. package/tests/fixtures/persist-schedule-warn-test/publisher.json +0 -6
  278. package/tests/fixtures/persist-test/data/orders.csv +0 -5
  279. package/tests/fixtures/persist-test/persist_test.malloy +0 -11
  280. package/tests/fixtures/persist-test/publisher.json +0 -5
  281. package/tests/fixtures/publisher.config.json +0 -15
  282. package/tests/fixtures/query-givens/data/orders.csv +0 -7
  283. package/tests/fixtures/query-givens/model.malloy +0 -34
  284. package/tests/fixtures/query-givens/publisher.json +0 -5
  285. package/tests/fixtures/xlsx/database.xlsx +0 -0
  286. package/tests/harness/mcp_test_setup.ts +0 -332
  287. package/tests/harness/rest_e2e.ts +0 -68
  288. package/tests/integration/authorize/compile_authorize_http.integration.spec.ts +0 -92
  289. package/tests/integration/concurrent_package/concurrent_package.integration.spec.ts +0 -280
  290. package/tests/integration/duckdb_storage/duckdb_storage.integration.spec.ts +0 -138
  291. package/tests/integration/first_boot/readiness_line.integration.spec.ts +0 -177
  292. package/tests/integration/html_pages/html_pages.integration.spec.ts +0 -440
  293. package/tests/integration/legacy_routes/legacy_routes.integration.spec.ts +0 -259
  294. package/tests/integration/materialization/environment_list.integration.spec.ts +0 -133
  295. package/tests/integration/materialization/freshness_gate.integration.spec.ts +0 -292
  296. package/tests/integration/materialization/manifest_binding.integration.spec.ts +0 -430
  297. package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +0 -441
  298. package/tests/integration/materialization/orchestrated_rebind.integration.spec.ts +0 -150
  299. package/tests/integration/materialization/reference_manifest.integration.spec.ts +0 -251
  300. package/tests/integration/materialization/schedule_edit.integration.spec.ts +0 -126
  301. package/tests/integration/materialization/scheduler.integration.spec.ts +0 -200
  302. package/tests/integration/materialization/scheduler_recovery.integration.spec.ts +0 -172
  303. package/tests/integration/materialization/scheduler_transitions.integration.spec.ts +0 -256
  304. package/tests/integration/materialization/scheduler_wiring.integration.spec.ts +0 -143
  305. package/tests/integration/mcp/mcp_execute_query_tool.integration.spec.ts +0 -438
  306. package/tests/integration/mcp/mcp_get_context_semantic.integration.spec.ts +0 -235
  307. package/tests/integration/mcp/mcp_transport.integration.spec.ts +0 -98
  308. package/tests/integration/query_givens/query_givens.integration.spec.ts +0 -146
  309. package/tests/integration/query_givens/query_givens_authorize.integration.spec.ts +0 -121
  310. package/tests/integration/sdk_givens/sdk_givens.integration.spec.ts +0 -110
  311. package/tests/integration/watch-mode/watch_mode.integration.spec.ts +0 -421
  312. package/tests/unit/controller/theme.controller.spec.ts +0 -141
  313. package/tests/unit/duckdb/attached_databases.test.ts +0 -1243
  314. package/tests/unit/duckdb/duckdb_connection.test.ts +0 -181
  315. package/tests/unit/duckdb/legacy_schema_migration.test.ts +0 -197
  316. package/tests/unit/duckdb/repositories.test.ts +0 -208
  317. package/tests/unit/ducklake/ducklake.test.ts +0 -952
  318. package/tests/unit/service/theme_store.spec.ts +0 -139
  319. package/tsconfig.json +0 -32
@@ -1,1236 +0,0 @@
1
- import {
2
- afterAll,
3
- beforeAll,
4
- beforeEach,
5
- describe,
6
- expect,
7
- it,
8
- } from "bun:test";
9
- import * as fs from "fs";
10
- import * as os from "os";
11
- import * as path from "path";
12
- import { DuckDBConnection } from "../../storage/duckdb/DuckDBConnection";
13
- import { createEntityEmbeddingsTable } from "../../storage/duckdb/schema";
14
- import { EmbeddingProvider } from "../../service/embedding_provider";
15
- import type { Package } from "../../service/package";
16
- import {
17
- EmbeddableEntity,
18
- MIN_SIMILARITY,
19
- SemanticSearchResult,
20
- _clearProviderCooldownForTests,
21
- _lastPurgeAtMsForTests,
22
- _resetEmbeddingIndexStateForTests,
23
- _setTimingForTests,
24
- _syncMetaSizeForTests,
25
- deleteEnvironmentEmbeddings,
26
- deletePackageEmbeddings,
27
- embeddingText,
28
- humanizeName,
29
- trySemanticSearch,
30
- } from "./embedding_index";
31
-
32
- let tempDir: string;
33
- let db: DuckDBConnection;
34
-
35
- beforeAll(async () => {
36
- tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "embedding-index-spec-"));
37
- db = new DuckDBConnection(path.join(tempDir, "test.db"));
38
- await db.initialize();
39
- await createEntityEmbeddingsTable(db);
40
- });
41
-
42
- afterAll(async () => {
43
- // Close before removing: DuckDB holds an exclusive file handle and
44
- // Windows refuses to delete a directory containing an open file.
45
- await db.close();
46
- fs.rmSync(tempDir, { recursive: true, force: true });
47
- });
48
-
49
- beforeEach(async () => {
50
- _resetEmbeddingIndexStateForTests();
51
- await db.run("DELETE FROM entity_embeddings");
52
- });
53
-
54
- /**
55
- * A provider whose "embeddings" come from an explicit text -> vector map,
56
- * so cosine ranking in the tests is hand-computable. Also counts how
57
- * often each text was embedded, to pin the hash-diff behavior.
58
- */
59
- function mapProvider(
60
- vectors: Record<string, number[]>,
61
- options: {
62
- model?: string;
63
- dimensions?: number;
64
- fail?: () => boolean;
65
- // Requests whose input includes this text block until the promise
66
- // resolves, so a test can hold one call mid-flight deterministically.
67
- gate?: { forText: string; until: Promise<void> };
68
- } = {},
69
- ): { provider: EmbeddingProvider; counts: Map<string, number> } {
70
- const counts = new Map<string, number>();
71
- const fetchStub = (async (_url: RequestInfo | URL, init?: RequestInit) => {
72
- if (options.fail?.()) {
73
- return new Response("stub failure", { status: 500 });
74
- }
75
- const body = JSON.parse(String(init?.body)) as { input: string[] };
76
- if (options.gate && body.input.includes(options.gate.forText)) {
77
- await options.gate.until;
78
- }
79
- const data = body.input.map((text, index) => {
80
- counts.set(text, (counts.get(text) ?? 0) + 1);
81
- const embedding = vectors[text];
82
- if (!embedding) throw new Error(`no stub vector for "${text}"`);
83
- return { index, embedding };
84
- });
85
- return new Response(JSON.stringify({ data }), { status: 200 });
86
- }) as typeof fetch;
87
- const provider = new EmbeddingProvider(
88
- {
89
- apiKey: "test",
90
- model: options.model ?? "stub-model",
91
- baseUrl: "https://stub.example.com/v1",
92
- dimensions: options.dimensions,
93
- },
94
- fetchStub,
95
- );
96
- return { provider, counts };
97
- }
98
-
99
- function entity(
100
- name: string,
101
- source: string | undefined,
102
- embedDoc = "",
103
- ): EmbeddableEntity {
104
- return { kind: "measure", name, source, modelPath: "m.malloy", embedDoc };
105
- }
106
-
107
- /** Poll through the cold-start "indexing" response until the sync lands. */
108
- async function searchReady(
109
- args: Parameters<typeof trySemanticSearch>[0],
110
- ): Promise<SemanticSearchResult> {
111
- for (let i = 0; i < 200; i++) {
112
- const result = await trySemanticSearch(args);
113
- if (!("unavailable" in result) || result.unavailable !== "indexing") {
114
- return result;
115
- }
116
- await new Promise((resolve) => setTimeout(resolve, 5));
117
- }
118
- throw new Error("sync never completed");
119
- }
120
-
121
- function isCooldown(result: SemanticSearchResult): boolean {
122
- return "unavailable" in result && result.unavailable === "cooldown";
123
- }
124
-
125
- const QUERY_VECTORS = {
126
- "find alpha": [1, 0, 0],
127
- };
128
-
129
- const ENTITY_VECTORS = {
130
- alpha: [1, 0, 0], // cosine 1.0 vs the query
131
- beta: [0.8, 0.6, 0], // cosine 0.8
132
- gamma: [0, 0, 1], // cosine 0 -> below MIN_SIMILARITY, dropped
133
- };
134
-
135
- describe("humanizeName / embeddingText", () => {
136
- it("splits snake_case, kebab, dots, and camelCase into words", () => {
137
- expect(humanizeName("dep_delay")).toBe("dep delay");
138
- expect(humanizeName("totalSales")).toBe("total sales");
139
- expect(humanizeName("sales-by.region")).toBe("sales by region");
140
- });
141
-
142
- it("appends the doc text when present", () => {
143
- expect(embeddingText(entity("total_sales", "s", "Total revenue"))).toBe(
144
- "total sales: Total revenue",
145
- );
146
- expect(embeddingText(entity("total_sales", "s"))).toBe("total sales");
147
- });
148
-
149
- it("never produces empty embed text for punctuation-only names", () => {
150
- // `_` is a legal Malloy identifier; an empty input would 400 the
151
- // whole package's embedding batch at the provider.
152
- expect(humanizeName("_")).toBe("");
153
- expect(embeddingText(entity("_", "s"))).toBe("_");
154
- expect(embeddingText(entity("_", "s", "odd but documented"))).toBe(
155
- "_: odd but documented",
156
- );
157
- });
158
- });
159
-
160
- describe("trySemanticSearch", () => {
161
- it("cold start reports indexing, then ranks by cosine with a floor", async () => {
162
- const { provider } = mapProvider({ ...ENTITY_VECTORS, ...QUERY_VECTORS });
163
- const pkg = {} as unknown as Package;
164
- const args = {
165
- db,
166
- provider,
167
- pkg,
168
- environmentName: "env",
169
- packageName: "pkg",
170
- entities: [
171
- entity("alpha", "src"),
172
- entity("beta", "src"),
173
- entity("gamma", "src"),
174
- ],
175
- query: "find alpha",
176
- limit: 10,
177
- };
178
-
179
- const first = await trySemanticSearch(args);
180
- expect(first).toEqual({ unavailable: "indexing" });
181
-
182
- const ready = await searchReady(args);
183
- if (!("hits" in ready)) throw new Error("expected hits");
184
- expect(ready.hits.map((h) => h.name)).toEqual(["alpha", "beta"]);
185
- expect(ready.hits[0].score).toBeCloseTo(1.0, 3);
186
- expect(ready.hits[1].score).toBeCloseTo(0.8, 3);
187
- expect(ready.hits.every((h) => h.score >= MIN_SIMILARITY)).toBe(true);
188
- });
189
-
190
- it("does not re-embed unchanged entities for a new package instance", async () => {
191
- const { provider, counts } = mapProvider({
192
- ...ENTITY_VECTORS,
193
- ...QUERY_VECTORS,
194
- });
195
- const entities = [entity("alpha", "src"), entity("beta", "src")];
196
- const base = {
197
- db,
198
- provider,
199
- environmentName: "env",
200
- packageName: "pkg",
201
- entities,
202
- query: "find alpha",
203
- limit: 10,
204
- };
205
-
206
- await searchReady({ ...base, pkg: {} as unknown as Package });
207
- expect(counts.get("alpha")).toBe(1);
208
-
209
- // A "reload": same package name, new instance. The sync re-runs but
210
- // the content hashes match, so nothing re-embeds.
211
- await searchReady({ ...base, pkg: {} as unknown as Package });
212
- expect(counts.get("alpha")).toBe(1);
213
- expect(counts.get("beta")).toBe(1);
214
- });
215
-
216
- it("re-embeds only the entity whose text changed, via upsert", async () => {
217
- const vectors = {
218
- ...ENTITY_VECTORS,
219
- ...QUERY_VECTORS,
220
- "alpha: now documented": [0, 1, 0],
221
- };
222
- const { provider, counts } = mapProvider(vectors);
223
- const base = {
224
- db,
225
- provider,
226
- environmentName: "env",
227
- packageName: "pkg",
228
- query: "find alpha",
229
- limit: 10,
230
- };
231
-
232
- await searchReady({
233
- ...base,
234
- pkg: {} as unknown as Package,
235
- entities: [entity("alpha", "src"), entity("beta", "src")],
236
- });
237
-
238
- const changed = await searchReady({
239
- ...base,
240
- pkg: {} as unknown as Package,
241
- entities: [
242
- entity("alpha", "src", "now documented"),
243
- entity("beta", "src"),
244
- ],
245
- });
246
- expect(counts.get("alpha: now documented")).toBe(1);
247
- expect(counts.get("beta")).toBe(1);
248
- if (!("hits" in changed)) throw new Error("expected hits");
249
- // alpha's new vector is orthogonal to the query, so beta leads now.
250
- expect(changed.hits.map((h) => h.name)).toEqual(["beta"]);
251
-
252
- const rows = await db.all<{ n: number }>(
253
- "SELECT CAST(COUNT(*) AS INTEGER) AS n FROM entity_embeddings WHERE environment_name = 'env'",
254
- );
255
- expect(rows[0].n).toBe(2);
256
- });
257
-
258
- it("re-embeds everything on a model switch without a key collision", async () => {
259
- const first = mapProvider({ ...ENTITY_VECTORS, ...QUERY_VECTORS });
260
- const base = {
261
- db,
262
- environmentName: "env",
263
- packageName: "pkg",
264
- entities: [entity("alpha", "src"), entity("beta", "src")],
265
- query: "find alpha",
266
- limit: 10,
267
- };
268
- await searchReady({
269
- ...base,
270
- provider: first.provider,
271
- pkg: {} as unknown as Package,
272
- });
273
-
274
- const second = mapProvider(
275
- { ...ENTITY_VECTORS, ...QUERY_VECTORS },
276
- { model: "other-model" },
277
- );
278
- const result = await searchReady({
279
- ...base,
280
- provider: second.provider,
281
- pkg: {} as unknown as Package,
282
- });
283
- if (!("hits" in result)) throw new Error("expected hits");
284
- expect(result.hits.map((h) => h.name)).toEqual(["alpha", "beta"]);
285
- expect(second.counts.get("alpha")).toBe(1);
286
-
287
- const rows = await db.all<{ n: number }>(
288
- "SELECT CAST(COUNT(*) AS INTEGER) AS n FROM entity_embeddings WHERE embedding_model = 'other-model'",
289
- );
290
- expect(rows[0].n).toBe(2);
291
- const oldRows = await db.all<{ n: number }>(
292
- "SELECT CAST(COUNT(*) AS INTEGER) AS n FROM entity_embeddings WHERE embedding_model = 'stub-model'",
293
- );
294
- expect(oldRows[0].n).toBe(0);
295
- });
296
-
297
- it("re-syncs an already-synced instance when the model changes", async () => {
298
- const pkg = {} as unknown as Package;
299
- const first = mapProvider({ ...ENTITY_VECTORS, ...QUERY_VECTORS });
300
- const base = {
301
- db,
302
- pkg,
303
- environmentName: "env",
304
- packageName: "pkg",
305
- entities: [entity("alpha", "src")],
306
- query: "find alpha",
307
- limit: 10,
308
- };
309
- await searchReady({ ...base, provider: first.provider });
310
-
311
- // Same Package instance, new model: the provider-key mismatch must
312
- // trigger a re-sync instead of matching zero rows forever.
313
- const second = mapProvider(
314
- { ...ENTITY_VECTORS, ...QUERY_VECTORS },
315
- { model: "other-model" },
316
- );
317
- const result = await searchReady({ ...base, provider: second.provider });
318
- if (!("hits" in result)) throw new Error("expected hits");
319
- expect(result.hits.map((h) => h.name)).toEqual(["alpha"]);
320
- });
321
-
322
- it("deletes rows for entities that no longer exist", async () => {
323
- const { provider } = mapProvider({ ...ENTITY_VECTORS, ...QUERY_VECTORS });
324
- const base = {
325
- db,
326
- provider,
327
- environmentName: "env",
328
- packageName: "pkg",
329
- query: "find alpha",
330
- limit: 10,
331
- };
332
- await searchReady({
333
- ...base,
334
- pkg: {} as unknown as Package,
335
- entities: [entity("alpha", "src"), entity("gamma", "src")],
336
- });
337
- await searchReady({
338
- ...base,
339
- pkg: {} as unknown as Package,
340
- entities: [entity("alpha", "src")],
341
- });
342
- const rows = await db.all<{ entity_name: string }>(
343
- "SELECT entity_name FROM entity_embeddings WHERE environment_name = 'env' ORDER BY entity_name",
344
- );
345
- expect(rows.map((r) => r.entity_name)).toEqual(["alpha"]);
346
- });
347
-
348
- it("narrows to one source with sourceName", async () => {
349
- const { provider } = mapProvider({ ...ENTITY_VECTORS, ...QUERY_VECTORS });
350
- const args = {
351
- db,
352
- provider,
353
- pkg: {} as unknown as Package,
354
- environmentName: "env",
355
- packageName: "pkg",
356
- entities: [entity("alpha", "orders"), entity("beta", "customers")],
357
- query: "find alpha",
358
- limit: 10,
359
- sourceName: "customers",
360
- };
361
- const result = await searchReady(args);
362
- if (!("hits" in result)) throw new Error("expected hits");
363
- expect(result.hits.map((h) => h.name)).toEqual(["beta"]);
364
- });
365
-
366
- it("scopes rows per package", async () => {
367
- const { provider } = mapProvider({ ...ENTITY_VECTORS, ...QUERY_VECTORS });
368
- const base = {
369
- db,
370
- provider,
371
- environmentName: "env",
372
- query: "find alpha",
373
- limit: 10,
374
- };
375
- await searchReady({
376
- ...base,
377
- packageName: "pkg-a",
378
- pkg: {} as unknown as Package,
379
- entities: [entity("alpha", "src")],
380
- });
381
- const other = await searchReady({
382
- ...base,
383
- packageName: "pkg-b",
384
- pkg: {} as unknown as Package,
385
- entities: [entity("beta", "src")],
386
- });
387
- if (!("hits" in other)) throw new Error("expected hits");
388
- expect(other.hits.map((h) => h.name)).toEqual(["beta"]);
389
- });
390
-
391
- it("purges and re-syncs when the provider's dimensionality changes under the same model", async () => {
392
- // Sync at 3 dims (EMBEDDING_DIMENSIONS unset everywhere here).
393
- const first = mapProvider({ ...ENTITY_VECTORS, ...QUERY_VECTORS });
394
- const base = {
395
- db,
396
- environmentName: "env",
397
- packageName: "pkg",
398
- entities: [entity("alpha", "src")],
399
- query: "find alpha",
400
- limit: 10,
401
- };
402
- await searchReady({
403
- ...base,
404
- provider: first.provider,
405
- pkg: {} as unknown as Package,
406
- });
407
-
408
- // The same model name now returns 4-dim vectors (a different
409
- // backend behind EMBEDDING_API_BASE). The hash diff cannot see
410
- // this: a fresh instance's sync embeds nothing, and the search
411
- // finds zero compatible rows. The empty-result heal must purge
412
- // and re-sync instead of returning empty "semantic" results
413
- // forever.
414
- const wide = mapProvider({
415
- alpha: [1, 0, 0, 0],
416
- "find alpha": [1, 0, 0, 0],
417
- });
418
- const pkg = {} as unknown as Package;
419
- const result = await searchReady({
420
- ...base,
421
- provider: wide.provider,
422
- pkg,
423
- });
424
- if (!("hits" in result)) throw new Error("expected hits");
425
- expect(result.hits.map((h) => h.name)).toEqual(["alpha"]);
426
- const rows = await db.all<{ dims: number }>(
427
- "SELECT CAST(dims AS INTEGER) AS dims FROM entity_embeddings WHERE environment_name = 'env'",
428
- );
429
- expect(rows.map((r) => r.dims)).toEqual([4]);
430
- });
431
-
432
- it("a purge invalidates every instance's memo, not just the caller's", async () => {
433
- // Instance A syncs at 3 dims and stays around with done=true.
434
- const narrow = mapProvider({ ...ENTITY_VECTORS, ...QUERY_VECTORS });
435
- const base = {
436
- db,
437
- environmentName: "env",
438
- packageName: "pkg",
439
- entities: [entity("alpha", "src")],
440
- query: "find alpha",
441
- limit: 10,
442
- };
443
- const pkgA = {} as unknown as Package;
444
- await searchReady({ ...base, provider: narrow.provider, pkg: pkgA });
445
-
446
- // Instance B (a reload) triggers the dims heal with a 4-dim
447
- // provider. Drive B ONLY until the purge lands (row count drops
448
- // to zero) and then stop, so no re-sync repopulates the table:
449
- // this reproduces the exact race window where a stale done memo
450
- // sits over an empty table.
451
- const wide = mapProvider({
452
- alpha: [1, 0, 0, 0],
453
- "find alpha": [1, 0, 0, 0],
454
- });
455
- const pkgB = {} as unknown as Package;
456
- for (let i = 0; i < 200; i++) {
457
- await trySemanticSearch({
458
- ...base,
459
- provider: wide.provider,
460
- pkg: pkgB,
461
- });
462
- const rows = await db.all<{ n: number }>(
463
- "SELECT CAST(COUNT(*) AS INTEGER) AS n FROM entity_embeddings WHERE environment_name = 'env'",
464
- );
465
- if (rows[0].n === 0) break;
466
- await new Promise((resolve) => setTimeout(resolve, 5));
467
- }
468
- const purged = await db.all<{ n: number }>(
469
- "SELECT CAST(COUNT(*) AS INTEGER) AS n FROM entity_embeddings WHERE environment_name = 'env'",
470
- );
471
- expect(purged[0].n).toBe(0);
472
-
473
- // A's memo says done, but the table is empty. Without generation
474
- // tracking, A would trust its memo, search zero rows, find no
475
- // stale rows to heal, and serve `{hits: []}` marked semantic
476
- // forever. With it, A's stale generation forces a re-sync (the
477
- // table is empty, so A re-embeds at its own 3 dims) and A answers
478
- // with real hits again.
479
- const back = await searchReady({
480
- ...base,
481
- provider: narrow.provider,
482
- pkg: pkgA,
483
- });
484
- if (!("hits" in back))
485
- throw new Error("expected hits, got " + JSON.stringify(back));
486
- expect(back.hits.map((h) => h.name)).toEqual(["alpha"]);
487
- });
488
-
489
- it("heals the mixed-dims state where a partial sync stranded old rows", async () => {
490
- // alpha indexed at 3 dims.
491
- const narrow = mapProvider({ ...ENTITY_VECTORS, ...QUERY_VECTORS });
492
- const base = {
493
- db,
494
- environmentName: "env",
495
- packageName: "pkg",
496
- query: "find alpha",
497
- limit: 10,
498
- };
499
- await searchReady({
500
- ...base,
501
- provider: narrow.provider,
502
- pkg: {} as unknown as Package,
503
- entities: [entity("alpha", "src")],
504
- });
505
-
506
- // The endpoint now returns 4-dim vectors for the same model, and a
507
- // new entity (beta) arrives. The sync diff skips alpha (hash and
508
- // model match) and embeds only beta, leaving a MIXED table: alpha
509
- // at 3 dims, beta at 4. An empty-result heal trigger would never
510
- // fire here (compatible rows exist), stranding alpha invisibly.
511
- const wide = mapProvider({
512
- alpha: [1, 0, 0, 0],
513
- beta: [0.8, 0.6, 0, 0],
514
- "find alpha": [1, 0, 0, 0],
515
- });
516
- const pkg = {} as unknown as Package;
517
- const args = {
518
- ...base,
519
- provider: wide.provider,
520
- pkg,
521
- entities: [entity("alpha", "src"), entity("beta", "src")],
522
- };
523
-
524
- const result = await searchReady(args);
525
- if (!("hits" in result)) throw new Error("expected hits");
526
- // Both entities retrievable: the stale-row heal purged alpha's
527
- // 3-dim row and the follow-up sync re-embedded it at 4 dims.
528
- expect(result.hits.map((h) => h.name)).toEqual(["alpha", "beta"]);
529
- const dims = await db.all<{ dims: number }>(
530
- "SELECT DISTINCT CAST(dims AS INTEGER) AS dims FROM entity_embeddings WHERE environment_name = 'env'",
531
- );
532
- expect(dims.map((d) => d.dims)).toEqual([4]);
533
- // The purge deleted ONLY the stale row: beta was embedded exactly
534
- // once. A delete-all purge would re-embed it and fail this pin.
535
- expect(wide.counts.get("beta")).toBe(1);
536
- });
537
-
538
- it("a sync that changes rows invalidates other instances' memos", async () => {
539
- const { provider } = mapProvider({ ...ENTITY_VECTORS, ...QUERY_VECTORS });
540
- const base = {
541
- db,
542
- provider,
543
- environmentName: "env",
544
- packageName: "pkg",
545
- query: "find alpha",
546
- limit: 10,
547
- };
548
- const pkgA = {} as unknown as Package;
549
- await searchReady({
550
- ...base,
551
- pkg: pkgA,
552
- entities: [entity("alpha", "src")],
553
- });
554
-
555
- // A reload's instance syncs with a CHANGED entity text: the rows
556
- // pkgA's snapshot logic trusts have been rewritten.
557
- const changedVectors = {
558
- ...ENTITY_VECTORS,
559
- ...QUERY_VECTORS,
560
- "alpha: reworded": [0, 1, 0],
561
- };
562
- const changed = mapProvider(changedVectors);
563
- await searchReady({
564
- ...base,
565
- provider: changed.provider,
566
- pkg: {} as unknown as Package,
567
- entities: [entity("alpha", "src", "reworded")],
568
- });
569
-
570
- // pkgA's done memo must now be stale (the sync bumped the
571
- // generation): its next call re-kicks a sync instead of serving
572
- // from the trusted memo.
573
- const next = await trySemanticSearch({
574
- ...base,
575
- pkg: pkgA,
576
- entities: [entity("alpha", "src")],
577
- });
578
- expect(next).toEqual({ unavailable: "indexing" });
579
- });
580
-
581
- it("a call overlapping a purge answers indexing, not empty semantic hits", async () => {
582
- const { provider } = mapProvider({ ...ENTITY_VECTORS, ...QUERY_VECTORS });
583
- const base = {
584
- db,
585
- environmentName: "env",
586
- packageName: "pkg",
587
- entities: [entity("alpha", "src")],
588
- query: "find alpha",
589
- limit: 10,
590
- };
591
- const pkgA = {} as unknown as Package;
592
- await searchReady({ ...base, provider, pkg: pkgA });
593
-
594
- // Call C on pkgA holds at its query embed (gated), memo done and
595
- // generation current at entry.
596
- let release!: () => void;
597
- const gate = new Promise<void>((resolve) => {
598
- release = resolve;
599
- });
600
- const gated = mapProvider(
601
- { ...ENTITY_VECTORS, ...QUERY_VECTORS },
602
- { gate: { forText: "find alpha", until: gate } },
603
- );
604
- const cPromise = trySemanticSearch({
605
- ...base,
606
- provider: gated.provider,
607
- pkg: pkgA,
608
- });
609
-
610
- // While C is gated, another instance's heal purges the table (a
611
- // 4-dim provider makes every row stale) and stops before any
612
- // re-sync repopulates it.
613
- const wide = mapProvider({
614
- alpha: [1, 0, 0, 0],
615
- "find alpha": [1, 0, 0, 0],
616
- });
617
- const pkgB = {} as unknown as Package;
618
- for (let i = 0; i < 200; i++) {
619
- await trySemanticSearch({
620
- ...base,
621
- provider: wide.provider,
622
- pkg: pkgB,
623
- });
624
- const rows = await db.all<{ n: number }>(
625
- "SELECT CAST(COUNT(*) AS INTEGER) AS n FROM entity_embeddings WHERE environment_name = 'env'",
626
- );
627
- if (rows[0].n === 0) break;
628
- await new Promise((resolve) => setTimeout(resolve, 5));
629
- }
630
-
631
- // Released, C searches an empty table with no stale rows left to
632
- // heal. Without the entry-generation re-check it would return
633
- // {hits: []} (served as semantic "nothing relevant here"); with
634
- // it, it reports indexing and the tool answers marked lexical.
635
- release();
636
- expect(await cPromise).toEqual({ unavailable: "indexing" });
637
- });
638
-
639
- it("a torn write (sync failing mid-loop) still invalidates other memos", async () => {
640
- const { provider } = mapProvider({ ...ENTITY_VECTORS, ...QUERY_VECTORS });
641
- const base = {
642
- db,
643
- provider,
644
- environmentName: "env",
645
- packageName: "pkg",
646
- query: "find alpha",
647
- limit: 10,
648
- };
649
- const pkgA = {} as unknown as Package;
650
- await searchReady({
651
- ...base,
652
- pkg: pkgA,
653
- entities: [entity("alpha", "src")],
654
- });
655
-
656
- // A reload instance syncs two changed entities through a DB whose
657
- // SECOND insert fails: one row was already rewritten, so even
658
- // though the sync rejects, snapshots must be invalidated.
659
- let inserts = 0;
660
- const failingDb = new Proxy(db, {
661
- get(target, prop, receiver) {
662
- if (prop === "run") {
663
- return async (query: string, params?: unknown[]) => {
664
- if (query.includes("INSERT INTO entity_embeddings")) {
665
- inserts++;
666
- if (inserts === 2) throw new Error("disk full (test)");
667
- }
668
- return target.run(query, params);
669
- };
670
- }
671
- return Reflect.get(target, prop, receiver);
672
- },
673
- });
674
- const changed = mapProvider({
675
- ...QUERY_VECTORS,
676
- "alpha: reworded": [0, 1, 0],
677
- "beta: new": [0, 0, 1],
678
- });
679
- const first = await trySemanticSearch({
680
- ...base,
681
- db: failingDb,
682
- provider: changed.provider,
683
- pkg: {} as unknown as Package,
684
- entities: [
685
- entity("alpha", "src", "reworded"),
686
- entity("beta", "src", "new"),
687
- ],
688
- });
689
- expect(first).toEqual({ unavailable: "indexing" });
690
-
691
- // Settle phase 1: wait until the second insert has been attempted
692
- // (it increments the counter before throwing).
693
- for (let i = 0; i < 200 && inserts < 2; i++) {
694
- await new Promise((resolve) => setTimeout(resolve, 5));
695
- }
696
- expect(inserts).toBe(2);
697
- // Settle phase 2: the rejection handler (which sets the cool-down)
698
- // runs asynchronously after the throw; wait until a call observes
699
- // the cool-down so the failed sync has fully settled. This
700
- // converges with or without the finally bump, so it does not mask
701
- // the pin below.
702
- let settled: SemanticSearchResult = { hits: [] };
703
- for (let i = 0; i < 200; i++) {
704
- settled = await trySemanticSearch({
705
- ...base,
706
- pkg: pkgA,
707
- entities: [entity("alpha", "src")],
708
- });
709
- if ("unavailable" in settled && settled.unavailable === "cooldown") {
710
- break;
711
- }
712
- await new Promise((resolve) => setTimeout(resolve, 5));
713
- }
714
- expect(settled).toEqual({ unavailable: "cooldown" });
715
-
716
- // The pin: with the cool-down cleared, pkgA's done memo must be
717
- // stale, because the failed sync changed a row before it died. A
718
- // success-only bump would leave pkgA serving the half-rewritten
719
- // table as semantic hits here.
720
- _clearProviderCooldownForTests();
721
- const next = await trySemanticSearch({
722
- ...base,
723
- pkg: pkgA,
724
- entities: [entity("alpha", "src")],
725
- });
726
- expect(next).toEqual({ unavailable: "indexing" });
727
- });
728
-
729
- it("querying through an old instance after a package delete recovers, never empty-semantic", async () => {
730
- const { provider } = mapProvider({ ...ENTITY_VECTORS, ...QUERY_VECTORS });
731
- const base = {
732
- db,
733
- provider,
734
- environmentName: "env",
735
- packageName: "pkg",
736
- entities: [entity("alpha", "src")],
737
- query: "find alpha",
738
- limit: 10,
739
- };
740
- const pkgA = {} as unknown as Package;
741
- await searchReady({ ...base, pkg: pkgA });
742
-
743
- // Delete removes the rows AND the syncMeta entry (the churn-leak
744
- // fix); the map must shrink back.
745
- const before = _syncMetaSizeForTests();
746
- await deletePackageEmbeddings(db, "env", "pkg");
747
- expect(_syncMetaSizeForTests()).toBe(before - 1);
748
-
749
- // pkgA still holds a done memo minted under the deleted meta. Its
750
- // generation can never match the re-minted meta's (globally unique
751
- // values), so the next call re-syncs and answers with real hits.
752
- // Trusting the stale memo would serve {hits: []} marked semantic
753
- // over the emptied table.
754
- const back = await searchReady({ ...base, pkg: pkgA });
755
- if (!("hits" in back))
756
- throw new Error("expected hits, got " + JSON.stringify(back));
757
- expect(back.hits.map((h) => h.name)).toEqual(["alpha"]);
758
- });
759
-
760
- it("a sync queued behind a package delete aborts instead of writing under an orphaned meta", async () => {
761
- // Hold the package mutex via a gated first sync, queue the delete
762
- // behind it, and queue a second instance's sync behind the delete.
763
- // When the gate opens: sync 1 completes, the delete purges and
764
- // orphans the meta, and sync 2 must abort as a no-op rather than
765
- // re-embedding rows for the deleted package.
766
- let release!: () => void;
767
- const gate = new Promise<void>((resolve) => {
768
- release = resolve;
769
- });
770
- const gated = mapProvider(
771
- { ...ENTITY_VECTORS, ...QUERY_VECTORS },
772
- { gate: { forText: "alpha", until: gate } },
773
- );
774
- const base = {
775
- db,
776
- environmentName: "env",
777
- packageName: "pkg",
778
- entities: [entity("alpha", "src")],
779
- query: "find alpha",
780
- limit: 10,
781
- };
782
- // Sync 1 (holds the mutex at its embed once it starts).
783
- const s1 = trySemanticSearch({
784
- ...base,
785
- provider: gated.provider,
786
- pkg: {} as unknown as Package,
787
- });
788
- // Delete queues behind sync 1.
789
- const del = deletePackageEmbeddings(db, "env", "pkg");
790
- // Sync 2 queues behind the delete, under the SAME (old) meta.
791
- const { provider } = mapProvider({ ...ENTITY_VECTORS, ...QUERY_VECTORS });
792
- const s2kick = trySemanticSearch({
793
- ...base,
794
- provider,
795
- pkg: {} as unknown as Package,
796
- });
797
-
798
- release();
799
- await Promise.all([s1, del, s2kick]);
800
-
801
- // The orphaned sync runs as soon as the delete releases the mutex.
802
- // Poll a failure-detection window: if the orphan guard were gone,
803
- // sync 2 would re-insert alpha within milliseconds; with it, the
804
- // table stays empty for the whole window.
805
- let resurrected = 0;
806
- for (let i = 0; i < 60; i++) {
807
- const rows = await db.all<{ n: number }>(
808
- "SELECT CAST(COUNT(*) AS INTEGER) AS n FROM entity_embeddings WHERE environment_name = 'env'",
809
- );
810
- resurrected = rows[0].n;
811
- if (resurrected > 0) break;
812
- await new Promise((resolve) => setTimeout(resolve, 5));
813
- }
814
- expect(resurrected).toBe(0);
815
- });
816
-
817
- it("answers indexing when the heal finds the mutex held mid-flight (busy path)", async () => {
818
- // pkgA synced at 3 dims; a 4-dim query makes its rows stale, so
819
- // the call reaches the heal.
820
- const narrow = mapProvider({ ...ENTITY_VECTORS, ...QUERY_VECTORS });
821
- const base = {
822
- db,
823
- environmentName: "env",
824
- packageName: "pkg",
825
- entities: [entity("alpha", "src")],
826
- query: "find alpha",
827
- limit: 10,
828
- };
829
- const pkgA = {} as unknown as Package;
830
- await searchReady({ ...base, provider: narrow.provider, pkg: pkgA });
831
- const initialRows = await db.all<{ content_hash: string }>(
832
- "SELECT content_hash FROM entity_embeddings WHERE environment_name = 'env' AND entity_name = 'alpha'",
833
- );
834
- const staleHash = initialRows[0].content_hash;
835
-
836
- // Call C: gate its out-of-mutex stale-count SELECT so we can slip
837
- // a mutex-holding sync in between the isLocked() check and the
838
- // heal's acquire, the exact race the tryAcquire busy path covers.
839
- let releaseSelect!: () => void;
840
- const selectGate = new Promise<void>((resolve) => {
841
- releaseSelect = resolve;
842
- });
843
- let gatedOnce = false;
844
- const gatedDb = new Proxy(db, {
845
- get(target, prop, receiver) {
846
- if (prop === "get") {
847
- return async (query: string, params?: unknown[]) => {
848
- if (!gatedOnce && query.includes("NOT (embedding_model")) {
849
- gatedOnce = true;
850
- await selectGate;
851
- }
852
- return target.get(query, params);
853
- };
854
- }
855
- return Reflect.get(target, prop, receiver);
856
- },
857
- });
858
- const wide = mapProvider({
859
- alpha: [1, 0, 0, 0],
860
- "find alpha": [1, 0, 0, 0],
861
- });
862
- const cPromise = trySemanticSearch({
863
- ...base,
864
- db: gatedDb,
865
- provider: wide.provider,
866
- pkg: pkgA,
867
- });
868
-
869
- // ORDER IS LOAD-BEARING: wait until C is provably parked at the
870
- // gated SELECT (it has already passed the isLocked() check with
871
- // the mutex free) BEFORE the holder takes the mutex. Kicking the
872
- // holder first would park the mutex before C's isLocked() check,
873
- // and C would exit through that pre-existing guard without ever
874
- // reaching the tryAcquire busy path this test exists to pin.
875
- for (let i = 0; i < 200 && !gatedOnce; i++) {
876
- await new Promise((resolve) => setTimeout(resolve, 5));
877
- }
878
- expect(gatedOnce).toBe(true);
879
-
880
- // Now a sync with a changed entity takes and holds the package
881
- // mutex (its provider fetch is gated).
882
- let releaseSync!: () => void;
883
- const syncGate = new Promise<void>((resolve) => {
884
- releaseSync = resolve;
885
- });
886
- const holder = mapProvider(
887
- { ...QUERY_VECTORS, "alpha: changed": [0, 1, 0] },
888
- { gate: { forText: "alpha: changed", until: syncGate } },
889
- );
890
- const holdKick = trySemanticSearch({
891
- ...base,
892
- provider: holder.provider,
893
- pkg: {} as unknown as Package,
894
- entities: [entity("alpha", "src", "changed")],
895
- });
896
- await holdKick;
897
-
898
- // Release C: it finds stale rows, tries the heal, and must answer
899
- // indexing immediately (busy) instead of queueing behind the held
900
- // mutex for the sync's duration. With a blocking acquire instead
901
- // of tryAcquire, C queues behind the still-gated holder and the
902
- // race below times out.
903
- releaseSelect();
904
- const timeoutMarker = Symbol("timeout");
905
- const raced = await Promise.race([
906
- cPromise,
907
- new Promise((resolve) =>
908
- setTimeout(() => resolve(timeoutMarker), 1_000),
909
- ),
910
- ]);
911
- expect(raced).toEqual({ unavailable: "indexing" });
912
-
913
- // Let the holder sync settle deterministically (its upsert changes
914
- // alpha's content hash) so no write leaks past the next test's
915
- // cleanup.
916
- releaseSync();
917
- for (let i = 0; i < 200; i++) {
918
- const rows = await db.all<{ content_hash: string }>(
919
- "SELECT content_hash FROM entity_embeddings WHERE environment_name = 'env' AND entity_name = 'alpha'",
920
- );
921
- if (rows.length === 1 && rows[0].content_hash !== staleHash) break;
922
- await new Promise((resolve) => setTimeout(resolve, 5));
923
- }
924
- });
925
-
926
- it("deletion helpers drop a package's and an environment's rows", async () => {
927
- const { provider } = mapProvider({ ...ENTITY_VECTORS, ...QUERY_VECTORS });
928
- const base = { db, provider, query: "find alpha", limit: 10 };
929
- await searchReady({
930
- ...base,
931
- environmentName: "env",
932
- packageName: "pkg-a",
933
- pkg: {} as unknown as Package,
934
- entities: [entity("alpha", "src")],
935
- });
936
- await searchReady({
937
- ...base,
938
- environmentName: "env",
939
- packageName: "pkg-b",
940
- pkg: {} as unknown as Package,
941
- entities: [entity("beta", "src")],
942
- });
943
-
944
- await deletePackageEmbeddings(db, "env", "pkg-a");
945
- const afterPkg = await db.all<{ package_name: string }>(
946
- "SELECT DISTINCT package_name FROM entity_embeddings WHERE environment_name = 'env' ORDER BY package_name",
947
- );
948
- expect(afterPkg.map((r) => r.package_name)).toEqual(["pkg-b"]);
949
-
950
- // Clear the sync metas so pkg-b has none: the environment delete
951
- // must then reach its rows via the final env-wide sweep, whose
952
- // whole purpose is packages never queried in this process. Without
953
- // this reset the per-meta loop would empty the table first and the
954
- // sweep would pass vacuously.
955
- _resetEmbeddingIndexStateForTests();
956
- await deleteEnvironmentEmbeddings(db, "env");
957
- const afterEnv = await db.all<{ n: number }>(
958
- "SELECT CAST(COUNT(*) AS INTEGER) AS n FROM entity_embeddings WHERE environment_name = 'env'",
959
- );
960
- expect(afterEnv[0].n).toBe(0);
961
- });
962
-
963
- it("backs off instead of purging twice within the cool-down window", async () => {
964
- const narrow = mapProvider({ ...ENTITY_VECTORS, ...QUERY_VECTORS });
965
- const base = {
966
- db,
967
- environmentName: "env",
968
- packageName: "pkg",
969
- entities: [entity("alpha", "src")],
970
- query: "find alpha",
971
- limit: 10,
972
- };
973
- await searchReady({
974
- ...base,
975
- provider: narrow.provider,
976
- pkg: {} as unknown as Package,
977
- });
978
-
979
- // First dims flip: heals via one purge + re-sync at 4 dims.
980
- const wide = mapProvider({
981
- alpha: [1, 0, 0, 0],
982
- "find alpha": [1, 0, 0, 0],
983
- });
984
- const healed = await searchReady({
985
- ...base,
986
- provider: wide.provider,
987
- pkg: {} as unknown as Package,
988
- });
989
- if (!("hits" in healed)) throw new Error("expected hits");
990
-
991
- // Second flip inside the window (back to 3 dims): the endpoint is
992
- // inconsistent; the heal must NOT purge again but cool down, and
993
- // the 4-dim rows must survive. One instance throughout, so the
994
- // poll passes its cold start and reaches the heal check.
995
- const pkgC = {} as unknown as Package;
996
- let result = await trySemanticSearch({
997
- ...base,
998
- provider: narrow.provider,
999
- pkg: pkgC,
1000
- });
1001
- for (
1002
- let i = 0;
1003
- i < 200 &&
1004
- "unavailable" in result &&
1005
- result.unavailable === "indexing";
1006
- i++
1007
- ) {
1008
- await new Promise((resolve) => setTimeout(resolve, 5));
1009
- result = await trySemanticSearch({
1010
- ...base,
1011
- provider: narrow.provider,
1012
- pkg: pkgC,
1013
- });
1014
- }
1015
- expect(result).toEqual({ unavailable: "cooldown" });
1016
- const rows = await db.all<{ dims: number }>(
1017
- "SELECT CAST(dims AS INTEGER) AS dims FROM entity_embeddings WHERE environment_name = 'env'",
1018
- );
1019
- expect(rows.map((r) => r.dims)).toEqual([4]);
1020
- });
1021
-
1022
- it("does not re-purge once per cooldown window: suppression outlasts the cooldown", async () => {
1023
- // The core of blocker 4. cooldown 15ms, suppression 400ms: after
1024
- // the cooldown clears (but well inside the suppression window) a
1025
- // fresh mismatch must BACK OFF, not re-purge. With the co-anchored
1026
- // bug (equal windows) the first post-cooldown call re-purges and
1027
- // full-re-embeds; this test fails against that.
1028
- _setTimingForTests({ cooldownMs: 15, purgeSuppressionMs: 400 });
1029
- const base = {
1030
- db,
1031
- environmentName: "env",
1032
- packageName: "pkg",
1033
- entities: [entity("alpha", "src")],
1034
- query: "find alpha",
1035
- limit: 10,
1036
- };
1037
- // Sync at 3 dims, then flip to 4: one purge + resync leaves 4-dim rows.
1038
- const narrow = mapProvider({ ...ENTITY_VECTORS, ...QUERY_VECTORS });
1039
- await searchReady({
1040
- ...base,
1041
- provider: narrow.provider,
1042
- pkg: {} as unknown as Package,
1043
- });
1044
- const wide = mapProvider({
1045
- alpha: [1, 0, 0, 0],
1046
- "find alpha": [1, 0, 0, 0],
1047
- });
1048
- const healed = await searchReady({
1049
- ...base,
1050
- provider: wide.provider,
1051
- pkg: {} as unknown as Package,
1052
- });
1053
- if (!("hits" in healed)) throw new Error("expected hits");
1054
- const purgeAt = _lastPurgeAtMsForTests("env", "pkg") ?? 0;
1055
- expect(purgeAt).toBeGreaterThan(0);
1056
-
1057
- // Query back at 3 dims (rows are 4-dim, so stale) across several
1058
- // cooldown-expiry cycles, all inside the 400ms suppression window.
1059
- const pkgQ = {} as unknown as Package;
1060
- for (let cycle = 0; cycle < 4; cycle++) {
1061
- let r = await trySemanticSearch({
1062
- ...base,
1063
- provider: narrow.provider,
1064
- pkg: pkgQ,
1065
- });
1066
- for (
1067
- let j = 0;
1068
- j < 200 && "unavailable" in r && r.unavailable === "indexing";
1069
- j++
1070
- ) {
1071
- await new Promise((resolve) => setTimeout(resolve, 3));
1072
- r = await trySemanticSearch({
1073
- ...base,
1074
- provider: narrow.provider,
1075
- pkg: pkgQ,
1076
- });
1077
- }
1078
- expect(r).toEqual({ unavailable: "cooldown" });
1079
- await new Promise((resolve) => setTimeout(resolve, 20)); // clear the 15ms cooldown
1080
- }
1081
-
1082
- // Never re-purged: lastPurgeAtMs unchanged and the 4-dim rows survive
1083
- // (the 3-dim vector was only ever the query, never re-embedded).
1084
- expect(_lastPurgeAtMsForTests("env", "pkg")).toBe(purgeAt);
1085
- const dims = await db.all<{ dims: number }>(
1086
- "SELECT DISTINCT CAST(dims AS INTEGER) AS dims FROM entity_embeddings WHERE environment_name = 'env'",
1087
- );
1088
- expect(dims.map((d) => d.dims)).toEqual([4]);
1089
- });
1090
-
1091
- it("re-heals once the suppression window elapses (self-recovery)", async () => {
1092
- // Same setup but a short suppression window: after it elapses a
1093
- // mismatch re-purges and re-adopts the current dims, so a provider
1094
- // that stabilizes on a new dimensionality is not stranded lexical.
1095
- _setTimingForTests({ cooldownMs: 10, purgeSuppressionMs: 60 });
1096
- const base = {
1097
- db,
1098
- environmentName: "env",
1099
- packageName: "pkg",
1100
- entities: [entity("alpha", "src")],
1101
- query: "find alpha",
1102
- limit: 10,
1103
- };
1104
- const narrow = mapProvider({ ...ENTITY_VECTORS, ...QUERY_VECTORS });
1105
- await searchReady({
1106
- ...base,
1107
- provider: narrow.provider,
1108
- pkg: {} as unknown as Package,
1109
- });
1110
- const wide = mapProvider({
1111
- alpha: [1, 0, 0, 0],
1112
- "find alpha": [1, 0, 0, 0],
1113
- });
1114
- const healed = await searchReady({
1115
- ...base,
1116
- provider: wide.provider,
1117
- pkg: {} as unknown as Package,
1118
- });
1119
- if (!("hits" in healed)) throw new Error("expected hits");
1120
- const purgeAt = _lastPurgeAtMsForTests("env", "pkg") ?? 0;
1121
-
1122
- // Wait past the 60ms suppression window, then the provider is now
1123
- // stably 3-dim: the next mismatch re-purges and re-adopts 3 dims.
1124
- await new Promise((resolve) => setTimeout(resolve, 90));
1125
- const recovered = await searchReady({
1126
- ...base,
1127
- provider: narrow.provider,
1128
- pkg: {} as unknown as Package,
1129
- });
1130
- if (!("hits" in recovered))
1131
- throw new Error("expected hits after re-heal");
1132
- expect(recovered.hits.map((h) => h.name)).toEqual(["alpha"]);
1133
- expect(_lastPurgeAtMsForTests("env", "pkg") ?? 0).toBeGreaterThan(
1134
- purgeAt,
1135
- );
1136
- const dims = await db.all<{ dims: number }>(
1137
- "SELECT DISTINCT CAST(dims AS INTEGER) AS dims FROM entity_embeddings WHERE environment_name = 'env'",
1138
- );
1139
- expect(dims.map((d) => d.dims)).toEqual([3]);
1140
- });
1141
-
1142
- it("a provider failure cools down only its own package, not healthy siblings", async () => {
1143
- // pkg-a's provider fails on every call; pkg-b (same env) is healthy.
1144
- const failing = mapProvider(
1145
- { ...ENTITY_VECTORS, ...QUERY_VECTORS },
1146
- { fail: () => true },
1147
- );
1148
- const argsA = {
1149
- db,
1150
- provider: failing.provider,
1151
- pkg: {} as unknown as Package,
1152
- environmentName: "env",
1153
- packageName: "pkg-a",
1154
- entities: [entity("alpha", "src")],
1155
- query: "find alpha",
1156
- limit: 10,
1157
- };
1158
- let a = await trySemanticSearch(argsA);
1159
- expect(a).toEqual({ unavailable: "indexing" });
1160
- for (let i = 0; i < 200 && !isCooldown(a); i++) {
1161
- await new Promise((resolve) => setTimeout(resolve, 5));
1162
- a = await trySemanticSearch(argsA);
1163
- }
1164
- expect(a).toEqual({ unavailable: "cooldown" });
1165
-
1166
- // pkg-b, healthy, same env: a per-package cool-down leaves it able
1167
- // to reach semantic. A global cool-down (the old bug) would make
1168
- // this return cooldown and never produce hits.
1169
- const healthy = mapProvider({ ...ENTITY_VECTORS, ...QUERY_VECTORS });
1170
- const b = await searchReady({
1171
- db,
1172
- provider: healthy.provider,
1173
- pkg: {} as unknown as Package,
1174
- environmentName: "env",
1175
- packageName: "pkg-b",
1176
- entities: [entity("alpha", "src")],
1177
- query: "find alpha",
1178
- limit: 10,
1179
- });
1180
- expect("hits" in b).toBe(true);
1181
- });
1182
-
1183
- it("cools down after a provider failure instead of erroring every call", async () => {
1184
- let failing = true;
1185
- const { provider } = mapProvider(
1186
- { ...ENTITY_VECTORS, ...QUERY_VECTORS },
1187
- { fail: () => failing },
1188
- );
1189
- const args = {
1190
- db,
1191
- provider,
1192
- pkg: {} as unknown as Package,
1193
- environmentName: "env",
1194
- packageName: "pkg",
1195
- entities: [entity("alpha", "src")],
1196
- query: "find alpha",
1197
- limit: 10,
1198
- };
1199
-
1200
- const first = await trySemanticSearch(args);
1201
- expect(first).toEqual({ unavailable: "indexing" });
1202
- // Poll until the background sync failure lands and starts the
1203
- // cool-down; a fixed sleep would race the rejection handler.
1204
- let second = await trySemanticSearch(args);
1205
- for (let i = 0; i < 200 && !isCooldown(second); i++) {
1206
- await new Promise((resolve) => setTimeout(resolve, 5));
1207
- second = await trySemanticSearch(args);
1208
- }
1209
- expect(second).toEqual({ unavailable: "cooldown" });
1210
-
1211
- // After the cool-down clears (test reset) the path recovers.
1212
- failing = false;
1213
- _resetEmbeddingIndexStateForTests();
1214
- const recovered = await searchReady(args);
1215
- expect("hits" in recovered).toBe(true);
1216
- });
1217
-
1218
- it("stays lexical for oversized packages", async () => {
1219
- const { provider, counts } = mapProvider(QUERY_VECTORS);
1220
- const entities = Array.from({ length: 5_001 }, (_, i) =>
1221
- entity(`e${i}`, "src"),
1222
- );
1223
- const result = await trySemanticSearch({
1224
- db,
1225
- provider,
1226
- pkg: {} as unknown as Package,
1227
- environmentName: "env",
1228
- packageName: "huge",
1229
- entities,
1230
- query: "find alpha",
1231
- limit: 10,
1232
- });
1233
- expect(result).toEqual({ unavailable: "too-many-entities" });
1234
- expect(counts.size).toBe(0);
1235
- });
1236
- });