mrplex 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (319) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +284 -0
  3. package/dist/cli/config.d.ts +28 -0
  4. package/dist/cli/config.js +48 -0
  5. package/dist/cli/config.js.map +1 -0
  6. package/dist/cli/exit-codes.d.ts +13 -0
  7. package/dist/cli/exit-codes.js +39 -0
  8. package/dist/cli/exit-codes.js.map +1 -0
  9. package/dist/cli/format.d.ts +22 -0
  10. package/dist/cli/format.js +148 -0
  11. package/dist/cli/format.js.map +1 -0
  12. package/dist/cli/main.d.ts +16 -0
  13. package/dist/cli/main.js +1518 -0
  14. package/dist/cli/main.js.map +1 -0
  15. package/dist/client/kernel-client.d.ts +97 -0
  16. package/dist/client/kernel-client.js +14 -0
  17. package/dist/client/kernel-client.js.map +1 -0
  18. package/dist/client/local.d.ts +26 -0
  19. package/dist/client/local.js +103 -0
  20. package/dist/client/local.js.map +1 -0
  21. package/dist/client/remote-mcp.d.ts +35 -0
  22. package/dist/client/remote-mcp.js +174 -0
  23. package/dist/client/remote-mcp.js.map +1 -0
  24. package/dist/embed/backfill.d.ts +24 -0
  25. package/dist/embed/backfill.js +33 -0
  26. package/dist/embed/backfill.js.map +1 -0
  27. package/dist/embed/chunker.d.ts +31 -0
  28. package/dist/embed/chunker.js +121 -0
  29. package/dist/embed/chunker.js.map +1 -0
  30. package/dist/embed/cmd-hook.d.ts +21 -0
  31. package/dist/embed/cmd-hook.js +100 -0
  32. package/dist/embed/cmd-hook.js.map +1 -0
  33. package/dist/embed/config.d.ts +61 -0
  34. package/dist/embed/config.js +109 -0
  35. package/dist/embed/config.js.map +1 -0
  36. package/dist/embed/hook.d.ts +38 -0
  37. package/dist/embed/hook.js +56 -0
  38. package/dist/embed/hook.js.map +1 -0
  39. package/dist/embed/http-hook.d.ts +13 -0
  40. package/dist/embed/http-hook.js +39 -0
  41. package/dist/embed/http-hook.js.map +1 -0
  42. package/dist/embed/worker.d.ts +62 -0
  43. package/dist/embed/worker.js +228 -0
  44. package/dist/embed/worker.js.map +1 -0
  45. package/dist/fixtures/hwe/characters/albion-ever.md +12 -0
  46. package/dist/fixtures/hwe/characters/cent.md +11 -0
  47. package/dist/fixtures/hwe/concepts/cent-demiurge-resonance.md +10 -0
  48. package/dist/fixtures/hwe/concepts/phase-looper.md +10 -0
  49. package/dist/fixtures/hwe/entities/demiurge.md +11 -0
  50. package/dist/fixtures/hwe/entities/monad.md +11 -0
  51. package/dist/fixtures/hwe/moc/globocorp-network.md +19 -0
  52. package/dist/fixtures/hwe/organizations/globocorp.md +11 -0
  53. package/dist/fixtures/hwe/readme.md +24 -0
  54. package/dist/fixtures/starship/crew/aria-okonkwo.md +14 -0
  55. package/dist/fixtures/starship/crew/bexley-orr.md +14 -0
  56. package/dist/fixtures/starship/crew/dax-thorne.md +14 -0
  57. package/dist/fixtures/starship/crew/isolde-marsh.md +16 -0
  58. package/dist/fixtures/starship/crew/kestrel-vance.md +15 -0
  59. package/dist/fixtures/starship/crew/quill-vasquez.md +14 -0
  60. package/dist/fixtures/starship/crew/soren-halloway.md +16 -0
  61. package/dist/fixtures/starship/encounters/the-drift-choir.md +14 -0
  62. package/dist/fixtures/starship/encounters/the-hollow-signal.md +16 -0
  63. package/dist/fixtures/starship/encounters/the-wandering-buoy.md +14 -0
  64. package/dist/fixtures/starship/equipment/coolant-loop-b.md +13 -0
  65. package/dist/fixtures/starship/equipment/emergency-ration-bars.md +12 -0
  66. package/dist/fixtures/starship/equipment/long-range-sensors.md +13 -0
  67. package/dist/fixtures/starship/equipment/plasma-manifold-3.md +15 -0
  68. package/dist/fixtures/starship/equipment/shuttle-corvid.md +13 -0
  69. package/dist/fixtures/starship/logs/marsh-4419-1.md +13 -0
  70. package/dist/fixtures/starship/logs/okonkwo-4415-1.md +13 -0
  71. package/dist/fixtures/starship/logs/orr-4413-1.md +13 -0
  72. package/dist/fixtures/starship/logs/orr-4420-1.md +12 -0
  73. package/dist/fixtures/starship/logs/thorne-4413-2.md +13 -0
  74. package/dist/fixtures/starship/logs/vance-4419-2.md +13 -0
  75. package/dist/fixtures/starship/misc/the-galley-ficus.md +11 -0
  76. package/dist/fixtures/starship/missions/the-cinder-run.md +17 -0
  77. package/dist/fixtures/starship/missions/the-drift-choir.md +17 -0
  78. package/dist/fixtures/starship/missions/the-hollow-signal.md +17 -0
  79. package/dist/fixtures/starship/missions/the-silent-beacon.md +17 -0
  80. package/dist/fixtures/starship/moc/crew.md +19 -0
  81. package/dist/fixtures/starship/moc/encounters.md +14 -0
  82. package/dist/fixtures/starship/moc/missions.md +15 -0
  83. package/dist/fixtures/starship/readme.md +60 -0
  84. package/dist/index.d.ts +1 -0
  85. package/dist/index.js +3 -0
  86. package/dist/index.js.map +1 -0
  87. package/dist/kernel/auth/glob.d.ts +46 -0
  88. package/dist/kernel/auth/glob.js +112 -0
  89. package/dist/kernel/auth/glob.js.map +1 -0
  90. package/dist/kernel/auth/scope.d.ts +53 -0
  91. package/dist/kernel/auth/scope.js +83 -0
  92. package/dist/kernel/auth/scope.js.map +1 -0
  93. package/dist/kernel/casefold.d.ts +37 -0
  94. package/dist/kernel/casefold.js +44 -0
  95. package/dist/kernel/casefold.js.map +1 -0
  96. package/dist/kernel/constants.d.ts +21 -0
  97. package/dist/kernel/constants.js +22 -0
  98. package/dist/kernel/constants.js.map +1 -0
  99. package/dist/kernel/context.d.ts +70 -0
  100. package/dist/kernel/context.js +114 -0
  101. package/dist/kernel/context.js.map +1 -0
  102. package/dist/kernel/deletion.d.ts +50 -0
  103. package/dist/kernel/deletion.js +83 -0
  104. package/dist/kernel/deletion.js.map +1 -0
  105. package/dist/kernel/diff.d.ts +36 -0
  106. package/dist/kernel/diff.js +61 -0
  107. package/dist/kernel/diff.js.map +1 -0
  108. package/dist/kernel/errors.d.ts +37 -0
  109. package/dist/kernel/errors.js +55 -0
  110. package/dist/kernel/errors.js.map +1 -0
  111. package/dist/kernel/frontmatter-input.d.ts +32 -0
  112. package/dist/kernel/frontmatter-input.js +85 -0
  113. package/dist/kernel/frontmatter-input.js.map +1 -0
  114. package/dist/kernel/graph.d.ts +34 -0
  115. package/dist/kernel/graph.js +559 -0
  116. package/dist/kernel/graph.js.map +1 -0
  117. package/dist/kernel/history.d.ts +74 -0
  118. package/dist/kernel/history.js +143 -0
  119. package/dist/kernel/history.js.map +1 -0
  120. package/dist/kernel/kernel.d.ts +162 -0
  121. package/dist/kernel/kernel.js +675 -0
  122. package/dist/kernel/kernel.js.map +1 -0
  123. package/dist/kernel/path-config.d.ts +101 -0
  124. package/dist/kernel/path-config.js +171 -0
  125. package/dist/kernel/path-config.js.map +1 -0
  126. package/dist/kernel/query/ast.d.ts +24 -0
  127. package/dist/kernel/query/ast.js +52 -0
  128. package/dist/kernel/query/ast.js.map +1 -0
  129. package/dist/kernel/query/cel-parse.d.ts +37 -0
  130. package/dist/kernel/query/cel-parse.js +88 -0
  131. package/dist/kernel/query/cel-parse.js.map +1 -0
  132. package/dist/kernel/query/degrees.d.ts +31 -0
  133. package/dist/kernel/query/degrees.js +135 -0
  134. package/dist/kernel/query/degrees.js.map +1 -0
  135. package/dist/kernel/query/graph-ast.d.ts +51 -0
  136. package/dist/kernel/query/graph-ast.js +139 -0
  137. package/dist/kernel/query/graph-ast.js.map +1 -0
  138. package/dist/kernel/query/query.d.ts +56 -0
  139. package/dist/kernel/query/query.js +276 -0
  140. package/dist/kernel/query/query.js.map +1 -0
  141. package/dist/kernel/validation.d.ts +64 -0
  142. package/dist/kernel/validation.js +183 -0
  143. package/dist/kernel/validation.js.map +1 -0
  144. package/dist/kernel/version-id.d.ts +10 -0
  145. package/dist/kernel/version-id.js +21 -0
  146. package/dist/kernel/version-id.js.map +1 -0
  147. package/dist/kernel/wire.d.ts +148 -0
  148. package/dist/kernel/wire.js +6 -0
  149. package/dist/kernel/wire.js.map +1 -0
  150. package/dist/links/backfill.d.ts +28 -0
  151. package/dist/links/backfill.js +33 -0
  152. package/dist/links/backfill.js.map +1 -0
  153. package/dist/links/extract.d.ts +29 -0
  154. package/dist/links/extract.js +233 -0
  155. package/dist/links/extract.js.map +1 -0
  156. package/dist/links/link-config.d.ts +70 -0
  157. package/dist/links/link-config.js +107 -0
  158. package/dist/links/link-config.js.map +1 -0
  159. package/dist/links/maintain.d.ts +37 -0
  160. package/dist/links/maintain.js +91 -0
  161. package/dist/links/maintain.js.map +1 -0
  162. package/dist/links/repair.d.ts +46 -0
  163. package/dist/links/repair.js +96 -0
  164. package/dist/links/repair.js.map +1 -0
  165. package/dist/links/resolve.d.ts +41 -0
  166. package/dist/links/resolve.js +121 -0
  167. package/dist/links/resolve.js.map +1 -0
  168. package/dist/links/stale.d.ts +32 -0
  169. package/dist/links/stale.js +73 -0
  170. package/dist/links/stale.js.map +1 -0
  171. package/dist/markdown/content-hash.d.ts +34 -0
  172. package/dist/markdown/content-hash.js +46 -0
  173. package/dist/markdown/content-hash.js.map +1 -0
  174. package/dist/markdown/frontmatter.d.ts +69 -0
  175. package/dist/markdown/frontmatter.js +147 -0
  176. package/dist/markdown/frontmatter.js.map +1 -0
  177. package/dist/markdown/hash-backfill.d.ts +22 -0
  178. package/dist/markdown/hash-backfill.js +43 -0
  179. package/dist/markdown/hash-backfill.js.map +1 -0
  180. package/dist/mcp/query-syntax.d.ts +20 -0
  181. package/dist/mcp/query-syntax.js +194 -0
  182. package/dist/mcp/query-syntax.js.map +1 -0
  183. package/dist/mcp/render.d.ts +31 -0
  184. package/dist/mcp/render.js +110 -0
  185. package/dist/mcp/render.js.map +1 -0
  186. package/dist/mcp/server.d.ts +60 -0
  187. package/dist/mcp/server.js +170 -0
  188. package/dist/mcp/server.js.map +1 -0
  189. package/dist/mcp/tools.d.ts +93 -0
  190. package/dist/mcp/tools.js +1218 -0
  191. package/dist/mcp/tools.js.map +1 -0
  192. package/dist/rest/conditional.d.ts +37 -0
  193. package/dist/rest/conditional.js +58 -0
  194. package/dist/rest/conditional.js.map +1 -0
  195. package/dist/rest/negotiate.d.ts +33 -0
  196. package/dist/rest/negotiate.js +54 -0
  197. package/dist/rest/negotiate.js.map +1 -0
  198. package/dist/rest/routes.d.ts +37 -0
  199. package/dist/rest/routes.js +744 -0
  200. package/dist/rest/routes.js.map +1 -0
  201. package/dist/seed/fixture-seed.d.ts +35 -0
  202. package/dist/seed/fixture-seed.js +75 -0
  203. package/dist/seed/fixture-seed.js.map +1 -0
  204. package/dist/seed/starship-config.d.ts +3 -0
  205. package/dist/seed/starship-config.js +5 -0
  206. package/dist/seed/starship-config.js.map +1 -0
  207. package/dist/server/headers.d.ts +36 -0
  208. package/dist/server/headers.js +30 -0
  209. package/dist/server/headers.js.map +1 -0
  210. package/dist/server/http-error.d.ts +53 -0
  211. package/dist/server/http-error.js +104 -0
  212. package/dist/server/http-error.js.map +1 -0
  213. package/dist/server/serve.d.ts +56 -0
  214. package/dist/server/serve.js +135 -0
  215. package/dist/server/serve.js.map +1 -0
  216. package/dist/shell/audit.d.ts +32 -0
  217. package/dist/shell/audit.js +28 -0
  218. package/dist/shell/audit.js.map +1 -0
  219. package/dist/shell/guard.d.ts +50 -0
  220. package/dist/shell/guard.js +176 -0
  221. package/dist/shell/guard.js.map +1 -0
  222. package/dist/shell/keys.d.ts +36 -0
  223. package/dist/shell/keys.js +64 -0
  224. package/dist/shell/keys.js.map +1 -0
  225. package/dist/shell/login.d.ts +56 -0
  226. package/dist/shell/login.js +130 -0
  227. package/dist/shell/login.js.map +1 -0
  228. package/dist/shell/oidc.d.ts +62 -0
  229. package/dist/shell/oidc.js +102 -0
  230. package/dist/shell/oidc.js.map +1 -0
  231. package/dist/shell/policy.d.ts +100 -0
  232. package/dist/shell/policy.js +232 -0
  233. package/dist/shell/policy.js.map +1 -0
  234. package/dist/shell/proxy-policy.d.ts +34 -0
  235. package/dist/shell/proxy-policy.js +120 -0
  236. package/dist/shell/proxy-policy.js.map +1 -0
  237. package/dist/shell/proxy.d.ts +47 -0
  238. package/dist/shell/proxy.js +253 -0
  239. package/dist/shell/proxy.js.map +1 -0
  240. package/dist/shell/serve.d.ts +86 -0
  241. package/dist/shell/serve.js +0 -0
  242. package/dist/shell/serve.js.map +1 -0
  243. package/dist/shell/stdio.d.ts +55 -0
  244. package/dist/shell/stdio.js +63 -0
  245. package/dist/shell/stdio.js.map +1 -0
  246. package/dist/storage/registry.d.ts +20 -0
  247. package/dist/storage/registry.js +38 -0
  248. package/dist/storage/registry.js.map +1 -0
  249. package/dist/storage/search-plan.d.ts +93 -0
  250. package/dist/storage/search-plan.js +15 -0
  251. package/dist/storage/search-plan.js.map +1 -0
  252. package/dist/storage/types.d.ts +448 -0
  253. package/dist/storage/types.js +14 -0
  254. package/dist/storage/types.js.map +1 -0
  255. package/dist/storage/versions-since.d.ts +72 -0
  256. package/dist/storage/versions-since.js +92 -0
  257. package/dist/storage/versions-since.js.map +1 -0
  258. package/dist/storage-postgres/adapter.d.ts +17 -0
  259. package/dist/storage-postgres/adapter.js +782 -0
  260. package/dist/storage-postgres/adapter.js.map +1 -0
  261. package/dist/storage-postgres/compile-postgres.d.ts +22 -0
  262. package/dist/storage-postgres/compile-postgres.js +830 -0
  263. package/dist/storage-postgres/compile-postgres.js.map +1 -0
  264. package/dist/storage-postgres/errors.d.ts +34 -0
  265. package/dist/storage-postgres/errors.js +50 -0
  266. package/dist/storage-postgres/errors.js.map +1 -0
  267. package/dist/storage-postgres/migrations/0001_init.sql +113 -0
  268. package/dist/storage-postgres/migrations/0002_content_hash.sql +7 -0
  269. package/dist/storage-postgres/migrations/index.d.ts +22 -0
  270. package/dist/storage-postgres/migrations/index.js +70 -0
  271. package/dist/storage-postgres/migrations/index.js.map +1 -0
  272. package/dist/storage-sqlite/adapter.d.ts +2 -0
  273. package/dist/storage-sqlite/adapter.js +688 -0
  274. package/dist/storage-sqlite/adapter.js.map +1 -0
  275. package/dist/storage-sqlite/compile-filter.d.ts +41 -0
  276. package/dist/storage-sqlite/compile-filter.js +881 -0
  277. package/dist/storage-sqlite/compile-filter.js.map +1 -0
  278. package/dist/storage-sqlite/compile-sqlite.d.ts +19 -0
  279. package/dist/storage-sqlite/compile-sqlite.js +176 -0
  280. package/dist/storage-sqlite/compile-sqlite.js.map +1 -0
  281. package/dist/storage-sqlite/migrations/0001_init.sql +123 -0
  282. package/dist/storage-sqlite/migrations/0002_content_hash.sql +7 -0
  283. package/dist/storage-sqlite/migrations/index.d.ts +6 -0
  284. package/dist/storage-sqlite/migrations/index.js +41 -0
  285. package/dist/storage-sqlite/migrations/index.js.map +1 -0
  286. package/dist/storage-sqlite/vec.d.ts +40 -0
  287. package/dist/storage-sqlite/vec.js +62 -0
  288. package/dist/storage-sqlite/vec.js.map +1 -0
  289. package/dist/sync/cursor.d.ts +36 -0
  290. package/dist/sync/cursor.js +66 -0
  291. package/dist/sync/cursor.js.map +1 -0
  292. package/dist/sync/daemon.d.ts +40 -0
  293. package/dist/sync/daemon.js +202 -0
  294. package/dist/sync/daemon.js.map +1 -0
  295. package/dist/sync/feed.d.ts +38 -0
  296. package/dist/sync/feed.js +125 -0
  297. package/dist/sync/feed.js.map +1 -0
  298. package/dist/sync/fs-store.d.ts +8 -0
  299. package/dist/sync/fs-store.js +76 -0
  300. package/dist/sync/fs-store.js.map +1 -0
  301. package/dist/sync/intrinsics.d.ts +52 -0
  302. package/dist/sync/intrinsics.js +76 -0
  303. package/dist/sync/intrinsics.js.map +1 -0
  304. package/dist/sync/paths.d.ts +38 -0
  305. package/dist/sync/paths.js +56 -0
  306. package/dist/sync/paths.js.map +1 -0
  307. package/dist/sync/push.d.ts +48 -0
  308. package/dist/sync/push.js +328 -0
  309. package/dist/sync/push.js.map +1 -0
  310. package/dist/sync/reconcile.d.ts +54 -0
  311. package/dist/sync/reconcile.js +252 -0
  312. package/dist/sync/reconcile.js.map +1 -0
  313. package/dist/sync/run.d.ts +27 -0
  314. package/dist/sync/run.js +46 -0
  315. package/dist/sync/run.js.map +1 -0
  316. package/dist/version.d.ts +2 -0
  317. package/dist/version.js +5 -0
  318. package/dist/version.js.map +1 -0
  319. package/package.json +80 -0
@@ -0,0 +1,881 @@
1
+ /**
2
+ * CEL AST → SQLite SQL compiler — design §5.
3
+ *
4
+ * Every user value ends up as a `?` placeholder in the emitted SQL; no
5
+ * string interpolation of literals. The kernel wraps the returned
6
+ * fragment in the WHERE clause of a versions query.
7
+ *
8
+ * Design-mandated ergonomics:
9
+ * • Bare identifiers refer to frontmatter keys, compiled to
10
+ * `json_extract(versions.frontmatter, '$."name"')`.
11
+ * • `$path`, `$updated_at` — kernel-owned intrinsics, compiled to
12
+ * `versions.path` / `versions.created_at` (§5.1). `$updated_at` names
13
+ * the intrinsic from the caller's mental model — queries always see
14
+ * current versions, so the version's created_at IS the doc's last
15
+ * update time.
16
+ * • `list(x)` — a compile-time hint that the enclosing expression should
17
+ * compile against scalar OR list frontmatter shapes (§5.2).
18
+ * • Missing key → false predicate (m2-plan §5 decision).
19
+ *
20
+ * Compiled SQL fragments are pure boolean predicates (no SELECT, no FROM);
21
+ * the kernel wraps them in the main query.
22
+ */
23
+ import { globToRegexSource } from "../kernel/auth/glob.js";
24
+ import { KernelError } from "../kernel/errors.js";
25
+ import { unwrapConstant, unwrapListHint } from "../kernel/query/ast.js";
26
+ import { INTRINSIC_PREFIX } from "../kernel/query/cel-parse.js";
27
+ import { asGraphCollection, asGraphMembership, assertNotReservedGraphName, } from "../kernel/query/graph-ast.js";
28
+ import { BODY_FIELD } from "../links/extract.js";
29
+ // Per-compile state for the current pass: the context plus a graph-subquery
30
+ // alias counter (reset each compileFilter so aliases are stable per query,
31
+ // matching the Postgres compiler's per-Builder counter). Compilation is
32
+ // fully synchronous, so a module-level cell is safe and avoids threading
33
+ // state through every compileExpr signature.
34
+ let CTX = {};
35
+ let GRAPH_ALIAS_SEQ = 0;
36
+ export function compileFilter(expr, ctx = {}) {
37
+ CTX = ctx;
38
+ GRAPH_ALIAS_SEQ = 0;
39
+ try {
40
+ return compileExpr(expr);
41
+ }
42
+ finally {
43
+ CTX = {};
44
+ GRAPH_ALIAS_SEQ = 0;
45
+ }
46
+ }
47
+ /** Alias for a graph subquery's joined `versions` row; unique per compile. */
48
+ function nextGraphAlias() {
49
+ return `lv${GRAPH_ALIAS_SEQ++}`;
50
+ }
51
+ // -----------------------------------------------------------------------------
52
+ // Intrinsic + frontmatter symbol resolution
53
+ // -----------------------------------------------------------------------------
54
+ const INTRINSIC_COLUMNS = {
55
+ path: "versions.path",
56
+ // `$updated_at` reads as "the timestamp of the current version" — which is
57
+ // what filters see (`next_id IS NULL` is implicit). The underlying column
58
+ // is `versions.created_at` (when this version row was written), but from
59
+ // a query-writer's mental model that's the doc's last update time.
60
+ updated_at: "versions.created_at",
61
+ // The document body — a text column, addressable via $body for the
62
+ // consistency of the $-prefixed convention. Design §5.1's example
63
+ // `contains(body, "pricing")` is updated to `$body`.
64
+ body: "versions.body",
65
+ // Canonical content fingerprint (sync/history plan §2.5). Reads the
66
+ // persisted column; pre-backfill rows carry null and simply don't match
67
+ // a hash filter until `mrplex hash backfill` populates them (§2.6).
68
+ content_hash: "versions.content_hash",
69
+ };
70
+ function compileIntrinsic(mangledName) {
71
+ const name = mangledName.slice(INTRINSIC_PREFIX.length);
72
+ const column = INTRINSIC_COLUMNS[name];
73
+ if (!column) {
74
+ // Derive the "expected" list from INTRINSIC_COLUMNS so the message
75
+ // can't drift from the code.
76
+ const expected = Object.keys(INTRINSIC_COLUMNS)
77
+ .map((k) => `$${k}`)
78
+ .join(", ");
79
+ throw new KernelError("filter_invalid", {
80
+ reason: `unknown intrinsic $${name} (expected ${expected})`,
81
+ });
82
+ }
83
+ return column;
84
+ }
85
+ /**
86
+ * Escape a JSON-path key for the `'$."name"'` form. SQLite's json1 doesn't
87
+ * allow `"` inside a double-quoted key in the path — but our frontmatter
88
+ * keys come through YAML validation and don't include double quotes in
89
+ * practice. We defensively escape by doubling the `"` (SQLite's convention
90
+ * for identifier quoting) and refusing keys with newlines / `$` prefixes.
91
+ */
92
+ function frontmatterExtract(name) {
93
+ if (name.includes("\n") || name.includes("\r")) {
94
+ throw new KernelError("filter_invalid", {
95
+ reason: `frontmatter key contains newline: ${JSON.stringify(name)}`,
96
+ });
97
+ }
98
+ const escaped = name.replace(/"/g, '""');
99
+ return `json_extract(versions.frontmatter, '$."${escaped}"')`;
100
+ }
101
+ // -----------------------------------------------------------------------------
102
+ // Expression compiler — dispatches on exprKind.case
103
+ // -----------------------------------------------------------------------------
104
+ function compileExpr(expr) {
105
+ const kind = expr.exprKind.case;
106
+ switch (kind) {
107
+ case "constExpr":
108
+ return compileConst(expr);
109
+ case "identExpr":
110
+ return compileIdent(expr);
111
+ case "selectExpr":
112
+ return compileSelect(expr);
113
+ case "callExpr":
114
+ return compileCall(expr);
115
+ case "comprehensionExpr":
116
+ return compileComprehension(expr);
117
+ case "listExpr":
118
+ case "structExpr":
119
+ throw new KernelError("filter_invalid", {
120
+ reason: `${kind ?? "unknown"} expressions are not supported in filters`,
121
+ });
122
+ default:
123
+ throw new KernelError("filter_invalid", {
124
+ reason: `unsupported expression kind: ${String(kind)}`,
125
+ });
126
+ }
127
+ }
128
+ function compileConst(expr) {
129
+ const value = unwrapConstant(expr);
130
+ if (value === undefined) {
131
+ throw new KernelError("filter_invalid", { reason: "unsupported constant kind" });
132
+ }
133
+ if (value === null)
134
+ return { sql: "NULL", params: [] };
135
+ if (typeof value === "boolean")
136
+ return { sql: value ? "1" : "0", params: [] };
137
+ return { sql: "?", params: [value] };
138
+ }
139
+ function compileIdent(expr) {
140
+ if (expr.exprKind.case !== "identExpr")
141
+ throw new Error("compileIdent: wrong kind");
142
+ const name = expr.exprKind.value.name;
143
+ if (name.startsWith(INTRINSIC_PREFIX)) {
144
+ return { sql: compileIntrinsic(name), params: [] };
145
+ }
146
+ // Bare identifiers are frontmatter keys. Missing key → predicate false
147
+ // (m2-plan §5 decision). The compileCall layer wraps comparisons to make
148
+ // that shape work; the raw ident extract is what feeds them.
149
+ return { sql: frontmatterExtract(name), params: [] };
150
+ }
151
+ /**
152
+ * Selects like `x.y` — we support nested frontmatter access when the LHS is
153
+ * a bare identifier or another select.
154
+ */
155
+ function compileSelect(expr) {
156
+ if (expr.exprKind.case !== "selectExpr")
157
+ throw new Error("compileSelect: wrong kind");
158
+ const sel = expr.exprKind.value;
159
+ const path = collectSelectPath(expr);
160
+ if (!path) {
161
+ throw new KernelError("filter_invalid", {
162
+ reason: `unsupported select expression: field '${sel.field}'`,
163
+ });
164
+ }
165
+ // The first segment is the root — either an intrinsic or frontmatter.
166
+ const [root, ...rest] = path;
167
+ const rootExpr = root;
168
+ if (rootExpr.startsWith(INTRINSIC_PREFIX)) {
169
+ if (rest.length > 0) {
170
+ throw new KernelError("filter_invalid", {
171
+ reason: `intrinsics do not have subfields ($${rootExpr.slice(INTRINSIC_PREFIX.length)})`,
172
+ });
173
+ }
174
+ return { sql: compileIntrinsic(rootExpr), params: [] };
175
+ }
176
+ const parts = [rootExpr, ...rest].map((p) => `"${p.replace(/"/g, '""')}"`);
177
+ return {
178
+ sql: `json_extract(versions.frontmatter, '$.${parts.join(".")}')`,
179
+ params: [],
180
+ };
181
+ }
182
+ function collectSelectPath(expr) {
183
+ if (expr.exprKind.case === "identExpr")
184
+ return [expr.exprKind.value.name];
185
+ if (expr.exprKind.case !== "selectExpr")
186
+ return undefined;
187
+ const inner = expr.exprKind.value.operand;
188
+ if (!inner)
189
+ return undefined;
190
+ const innerPath = collectSelectPath(inner);
191
+ if (!innerPath)
192
+ return undefined;
193
+ return [...innerPath, expr.exprKind.value.field];
194
+ }
195
+ // -----------------------------------------------------------------------------
196
+ // Call dispatch
197
+ // -----------------------------------------------------------------------------
198
+ function compileCall(expr) {
199
+ if (expr.exprKind.case !== "callExpr")
200
+ throw new Error("compileCall: wrong kind");
201
+ const call = expr.exprKind.value;
202
+ const fn = call.function;
203
+ const args = call.args;
204
+ const target = call.target;
205
+ // Boolean/comparison operators
206
+ const binOp = BIN_OPS[fn];
207
+ if (binOp && args.length === 2) {
208
+ return compileBinary(binOp, args[0], args[1]);
209
+ }
210
+ if (fn === "!_" && args.length === 1)
211
+ return compileNot(args[0]);
212
+ if (fn === "-_" && args.length === 1) {
213
+ const inner = compileExpr(args[0]);
214
+ return { sql: `-(${inner.sql})`, params: inner.params };
215
+ }
216
+ if (fn === "@in" && args.length === 2) {
217
+ return compileIn(args[0], args[1]);
218
+ }
219
+ // Free-standing calls
220
+ if (target === undefined) {
221
+ if (fn === "size" && args.length === 1)
222
+ return compileSize(args[0]);
223
+ if (fn === "contains" && args.length === 2) {
224
+ return compileContains(args[0], args[1]);
225
+ }
226
+ if (fn === "startsWith" && args.length === 2) {
227
+ return compileStartsWith(args[0], args[1]);
228
+ }
229
+ if (fn === "endsWith" && args.length === 2) {
230
+ return compileEndsWith(args[0], args[1]);
231
+ }
232
+ if (fn === "matches" && args.length === 2) {
233
+ return compileMatches(args[0], args[1]);
234
+ }
235
+ if (fn === "list" && args.length === 1) {
236
+ // Bare `list(x)` outside a `@in` / comprehension context is a silent
237
+ // semantic loss — `list(tags) == "foo"` would compile as
238
+ // `tags == "foo"`, which fails on list-valued frontmatter. Since
239
+ // list() is a compile-time hint (§5.2) that only makes sense in
240
+ // hint-consuming contexts, reject it here so users get a clear
241
+ // signal that they probably meant `"foo" in list(tags)`.
242
+ throw new KernelError("filter_invalid", {
243
+ reason: "list() is a hint that must sit inside `in` or a comprehension (.all/.exists), not stand alone",
244
+ });
245
+ }
246
+ }
247
+ // Method-style calls: `x.startsWith(y)`, etc.
248
+ if (target) {
249
+ // `$backlinks_static().size()` parses as method-form size (target =
250
+ // the collection call, zero args) — dispatch to the collection COUNT.
251
+ if (fn === "size" && args.length === 0) {
252
+ const collection = asGraphCollection(target);
253
+ if (collection)
254
+ return compileGraphCollectionSize(collection);
255
+ }
256
+ if (fn === "startsWith" && args.length === 1) {
257
+ return compileStartsWith(target, args[0]);
258
+ }
259
+ if (fn === "endsWith" && args.length === 1) {
260
+ return compileEndsWith(target, args[0]);
261
+ }
262
+ if (fn === "contains" && args.length === 1) {
263
+ return compileContains(target, args[0]);
264
+ }
265
+ if (fn === "matches" && args.length === 1) {
266
+ return compileMatches(target, args[0]);
267
+ }
268
+ }
269
+ // Graph membership predicates: $in_static / $has_static (§11.2).
270
+ const membership = asGraphMembership(expr);
271
+ if (membership)
272
+ return compileGraphMembership(membership);
273
+ // A bare collection call ($backlinks_static()) reaching a boolean context
274
+ // is meaningless — collections only compose via .size()/.exists()/.all().
275
+ const collection = asGraphCollection(expr);
276
+ if (collection) {
277
+ throw new KernelError("filter_invalid", {
278
+ reason: `$${collection.direction}() is a collection; use .size(), .exists(), or .all()`,
279
+ });
280
+ }
281
+ // Reserved `_dyn` graph names (need Phase 2 embedded queries).
282
+ assertNotReservedGraphName(fn);
283
+ throw new KernelError("filter_invalid", {
284
+ reason: `unsupported function: ${target ? "<target>." : ""}${fn}(...) with ${args.length} arg(s)`,
285
+ });
286
+ }
287
+ const BIN_OPS = {
288
+ "_==_": "=",
289
+ "_!=_": "<>",
290
+ "_<_": "<",
291
+ "_<=_": "<=",
292
+ "_>_": ">",
293
+ "_>=_": ">=",
294
+ "_&&_": "AND",
295
+ "_||_": "OR",
296
+ };
297
+ // -----------------------------------------------------------------------------
298
+ // Graph predicates — $in_static / $has_static / $backlinks_static() /
299
+ // $links_static() over the `links` index (design §11.2, WS4).
300
+ //
301
+ // Direction fixes which end of the edge the OUTER `versions` row sits on:
302
+ // in / backlinks → outer doc is the TARGET; the OTHER end is the source.
303
+ // has / links → outer doc is the SOURCE; the OTHER end is the target.
304
+ //
305
+ // Scope (§5 decision 5): the other endpoint's version row carries the
306
+ // caller's read scope, so an edge to/from an unreadable doc is invisible.
307
+ // For `has`/`links` a resolved edge joins the target's version; a *dangling*
308
+ // `has` edge has no target version to scope, but the source (the outer,
309
+ // already-scoped row) is what the caller reads, and the target is just a
310
+ // written path — so dangling edges count for `has_static` (matches
311
+ // "$has_static(\"horses.md\") — dangling target ok").
312
+ // -----------------------------------------------------------------------------
313
+ /**
314
+ * The full visibility predicate for a joined endpoint `alias`: read scope
315
+ * AND sigil-exclusion, so the visible graph equals the readable graph
316
+ * (§11.2). Errors if the compile context didn't supply the builders (a
317
+ * graph predicate must never leak an unfiltered endpoint). Sigil-exclusion
318
+ * has no fragment when the plan excludes nothing (`include_hidden/system`),
319
+ * in which case only scope contributes.
320
+ */
321
+ function graphVisibility(alias) {
322
+ if (!CTX.graphScope) {
323
+ throw new KernelError("filter_invalid", {
324
+ reason: "graph predicates are not available in this query context",
325
+ });
326
+ }
327
+ const scope = CTX.graphScope(alias);
328
+ const sigils = CTX.graphSigils ? CTX.graphSigils(alias) : { sql: "", params: [] };
329
+ if (!sigils.sql)
330
+ return scope;
331
+ return { sql: `(${scope.sql} AND ${sigils.sql})`, params: [...scope.params, ...sigils.params] };
332
+ }
333
+ /** Anchored regexp over a path glob (gitignore-style, §8.2 machinery). */
334
+ function globRegex(glob) {
335
+ return `^${globToRegexSource(glob)}$`;
336
+ }
337
+ /** `AND l.field = ?` fragment (or '$body' sentinel) when a field is given. */
338
+ function fieldClause(field) {
339
+ if (field === undefined)
340
+ return { sql: "", params: [] };
341
+ const f = field === "$body" ? BODY_FIELD : field;
342
+ return { sql: " AND l.field = ?", params: [f] };
343
+ }
344
+ /**
345
+ * The correlated EXISTS body shared by membership ($in/$has) and the
346
+ * collection `.size()`/comprehension forms. Returns the FROM+WHERE of a
347
+ * subquery correlated to the outer `versions` row, minus the leading
348
+ * SELECT (callers wrap it).
349
+ */
350
+ function graphSubqueryCore(direction, opts) {
351
+ const params = [];
352
+ if (direction === "in" || direction === "backlinks") {
353
+ // Outer = target; join the source version. Resolved edges only
354
+ // (target_id must equal the outer doc), source must be live + readable.
355
+ const alias = opts.predicateAlias ?? nextGraphAlias();
356
+ const parts = [
357
+ "FROM links l",
358
+ `JOIN versions ${alias} ON l.source_id = ${alias}.document_id AND ${alias}.next_id IS NULL`,
359
+ "WHERE l.target_id = versions.document_id",
360
+ ];
361
+ if (opts.glob !== undefined) {
362
+ parts.push(`AND regexp(?, ${alias}.path)`);
363
+ params.push(globRegex(opts.glob));
364
+ }
365
+ const fc = fieldClause(opts.field);
366
+ if (fc.sql) {
367
+ parts.push(fc.sql.replace(/^ AND /, "AND "));
368
+ params.push(...fc.params);
369
+ }
370
+ const vis = graphVisibility(alias);
371
+ parts.push(`AND ${vis.sql}`);
372
+ params.push(...vis.params);
373
+ return { sql: parts.join(" "), params };
374
+ }
375
+ // direction === "has" || "links": outer = source; the other end is target.
376
+ // Match the written folded target (target_norm) so dangling edges count.
377
+ // When the target resolves, also require it to be readable; a dangling
378
+ // target has no version row to scope (the target path isn't a live doc).
379
+ const alias = opts.predicateAlias ?? nextGraphAlias();
380
+ const parts = [
381
+ `FROM links l LEFT JOIN versions ${alias} ON l.target_id = ${alias}.document_id AND ${alias}.next_id IS NULL`,
382
+ "WHERE l.source_id = versions.document_id",
383
+ ];
384
+ if (opts.glob !== undefined) {
385
+ // has/links match against the resolved/​written target path. Prefer the
386
+ // resolved live path when bound; fall back to target_norm for danglers.
387
+ parts.push(`AND regexp(?, COALESCE(${alias}.path, l.target_norm))`);
388
+ params.push(globRegex(opts.glob));
389
+ }
390
+ const fc = fieldClause(opts.field);
391
+ if (fc.sql) {
392
+ parts.push(fc.sql.replace(/^ AND /, "AND "));
393
+ params.push(...fc.params);
394
+ }
395
+ // Visibility: a bound target must be readable AND not sigil-hidden (so an
396
+ // inbound edge to a deleted/hidden doc doesn't surface the source); a
397
+ // dangling edge (no target row) is visible because the source is the
398
+ // caller's own already-filtered row.
399
+ const vis = graphVisibility(alias);
400
+ parts.push(`AND (l.target_id IS NULL OR ${vis.sql})`);
401
+ params.push(...vis.params);
402
+ return { sql: parts.join(" "), params };
403
+ }
404
+ function compileGraphMembership(m) {
405
+ const core = graphSubqueryCore(m.direction, { glob: m.glob, field: m.field });
406
+ return { sql: `EXISTS (SELECT 1 ${core.sql})`, params: core.params };
407
+ }
408
+ /** COUNT(*) scalar for size($backlinks_static()) / size($links_static()). */
409
+ function compileGraphCollectionSize(c) {
410
+ const core = graphSubqueryCore(c.direction, {});
411
+ return { sql: `(SELECT COUNT(*) ${core.sql})`, params: core.params };
412
+ }
413
+ /**
414
+ * $backlinks_static().exists(d, pred) / .all(d, pred). The iter var ranges
415
+ * over the OTHER document's live version (aliased); the predicate compiles
416
+ * against that alias. `.exists` → EXISTS(core AND pred); `.all` →
417
+ * NOT EXISTS(core AND NOT pred) — vacuously true on an empty collection,
418
+ * matching CEL's `.all` semantics.
419
+ */
420
+ function compileGraphComprehension(comp, collection) {
421
+ const shape = classifyComprehension(comp);
422
+ if (shape === "unsupported") {
423
+ throw new KernelError("filter_invalid", {
424
+ reason: "unsupported comprehension over a link collection (use .exists or .all)",
425
+ });
426
+ }
427
+ const alias = nextGraphAlias();
428
+ const core = graphSubqueryCore(collection.direction, { predicateAlias: alias });
429
+ const userPred = extractUserPredicate(comp.loopStep);
430
+ const pred = compileAgainstAlias(userPred, comp.iterVar, alias);
431
+ if (shape === "exists") {
432
+ return {
433
+ sql: `EXISTS (SELECT 1 ${core.sql} AND (${pred.sql}))`,
434
+ params: [...core.params, ...pred.params],
435
+ };
436
+ }
437
+ // "all": no member violates the predicate (vacuously true when empty).
438
+ return {
439
+ sql: `NOT EXISTS (SELECT 1 ${core.sql} AND NOT (${pred.sql}))`,
440
+ params: [...core.params, ...pred.params],
441
+ };
442
+ }
443
+ /**
444
+ * Compile a comprehension predicate where the iter var denotes the OTHER
445
+ * document's version row (aliased). `d` bare is meaningless; `d.field`
446
+ * reads that doc's frontmatter, and `d.$path`/`d.$updated_at`/`d.$body`
447
+ * read its intrinsics. Everything else compiles normally (referring to the
448
+ * OUTER `versions` row) — so a predicate can mix both documents.
449
+ */
450
+ function compileAgainstAlias(expr, iterVar, alias) {
451
+ const kind = expr.exprKind.case;
452
+ if (kind === "identExpr") {
453
+ if (expr.exprKind.value.name === iterVar) {
454
+ // Bare iter var in a boolean/scalar position isn't supported — you
455
+ // must access a field (d.status) or intrinsic (d.$path).
456
+ throw new KernelError("filter_invalid", {
457
+ reason: `bare '${iterVar}' is not usable; access a field like ${iterVar}.status or ${iterVar}.$path`,
458
+ });
459
+ }
460
+ return compileExpr(expr);
461
+ }
462
+ if (kind === "selectExpr") {
463
+ const path = collectSelectPath(expr);
464
+ if (path && path[0] === iterVar) {
465
+ return aliasFieldAccess(alias, path.slice(1));
466
+ }
467
+ return compileExpr(expr);
468
+ }
469
+ if (kind === "callExpr") {
470
+ const call = expr.exprKind.value;
471
+ const target = call.target
472
+ ? compileAgainstAlias(call.target, iterVar, alias)
473
+ : undefined;
474
+ const args = call.args.map((a) => compileAgainstAlias(a, iterVar, alias));
475
+ return recompileCallWithSubstitutedArgs(call.function, target, args);
476
+ }
477
+ // Constants and anything else: compile normally.
478
+ return compileExpr(expr);
479
+ }
480
+ /**
481
+ * Field access on the aliased other-document row. An empty tail is the bare
482
+ * iter var (rejected above). A `$`-prefixed head is an intrinsic
483
+ * ($path/$updated_at/$body); otherwise it's a frontmatter path.
484
+ */
485
+ function aliasFieldAccess(alias, tail) {
486
+ const head = tail[0];
487
+ if (head === undefined) {
488
+ throw new KernelError("filter_invalid", { reason: "empty field access on link iter var" });
489
+ }
490
+ if (head.startsWith(INTRINSIC_PREFIX)) {
491
+ if (tail.length > 1) {
492
+ throw new KernelError("filter_invalid", { reason: "intrinsics have no subfields" });
493
+ }
494
+ const name = head.slice(INTRINSIC_PREFIX.length);
495
+ const col = INTRINSIC_COLUMNS[name];
496
+ if (!col) {
497
+ throw new KernelError("filter_invalid", { reason: `unknown intrinsic $${name}` });
498
+ }
499
+ // INTRINSIC_COLUMNS maps to `versions.<col>`; retarget to the alias.
500
+ return { sql: col.replace(/^versions\./, `${alias}.`), params: [] };
501
+ }
502
+ const parts = tail.map((p) => `"${p.replace(/"/g, '""')}"`);
503
+ return { sql: `json_extract(${alias}.frontmatter, '$.${parts.join(".")}')`, params: [] };
504
+ }
505
+ function compileBinary(op, a, b) {
506
+ const lhs = compileExpr(a);
507
+ const rhs = compileExpr(b);
508
+ return {
509
+ sql: `(${lhs.sql} ${op} ${rhs.sql})`,
510
+ params: [...lhs.params, ...rhs.params],
511
+ };
512
+ }
513
+ function compileNot(inner) {
514
+ const c = compileExpr(inner);
515
+ return { sql: `NOT (${c.sql})`, params: c.params };
516
+ }
517
+ // -----------------------------------------------------------------------------
518
+ // String methods
519
+ // -----------------------------------------------------------------------------
520
+ function compileContains(haystack, needle) {
521
+ const h = compileExpr(haystack);
522
+ const n = compileExpr(needle);
523
+ return {
524
+ sql: `(instr(${h.sql}, ${n.sql}) > 0)`,
525
+ params: [...h.params, ...n.params],
526
+ };
527
+ }
528
+ function compileStartsWith(target, prefix) {
529
+ // Literal prefix → use LIKE with the literal escaped. Dynamic prefix →
530
+ // use substr-based check (LIKE with runtime pattern is possible but
531
+ // needs LIKE-escaping which we'd rather avoid).
532
+ const constPrefix = unwrapConstant(prefix);
533
+ const t = compileExpr(target);
534
+ if (typeof constPrefix === "string") {
535
+ const escaped = escapeLikePattern(constPrefix);
536
+ return {
537
+ sql: `(${t.sql} LIKE ? ESCAPE '\\')`,
538
+ params: [...t.params, `${escaped}%`],
539
+ };
540
+ }
541
+ const p = compileExpr(prefix);
542
+ // SQL contains p.sql twice: length(p) and = p. Duplicate p.params 2x.
543
+ return {
544
+ sql: `(substr(${t.sql}, 1, length(${p.sql})) = ${p.sql})`,
545
+ params: [...t.params, ...p.params, ...p.params],
546
+ };
547
+ }
548
+ function compileEndsWith(target, suffix) {
549
+ const constSuffix = unwrapConstant(suffix);
550
+ const t = compileExpr(target);
551
+ if (typeof constSuffix === "string") {
552
+ const escaped = escapeLikePattern(constSuffix);
553
+ return {
554
+ sql: `(${t.sql} LIKE ? ESCAPE '\\')`,
555
+ params: [...t.params, `%${escaped}`],
556
+ };
557
+ }
558
+ const s = compileExpr(suffix);
559
+ return {
560
+ sql: `(substr(${t.sql}, length(${t.sql}) - length(${s.sql}) + 1) = ${s.sql})`,
561
+ params: [...t.params, ...t.params, ...s.params, ...s.params],
562
+ };
563
+ }
564
+ function compileMatches(target, pattern) {
565
+ // `regexp` is registered as a user function by the adapter (m2-plan §5).
566
+ const t = compileExpr(target);
567
+ const p = compileExpr(pattern);
568
+ return { sql: `(regexp(${p.sql}, ${t.sql}))`, params: [...p.params, ...t.params] };
569
+ }
570
+ function escapeLikePattern(literal) {
571
+ return literal.replace(/[\\%_]/g, (ch) => `\\${ch}`);
572
+ }
573
+ // -----------------------------------------------------------------------------
574
+ // Membership + size (list() polymorphism, §5.2)
575
+ // -----------------------------------------------------------------------------
576
+ function compileIn(needle, container) {
577
+ const listHint = unwrapListHint(container);
578
+ const n = compileExpr(needle);
579
+ if (listHint) {
580
+ // Polymorphic scalar-or-list frontmatter membership.
581
+ const field = compileExpr(listHint);
582
+ // needle params appear THREE times: once for scalar branch, twice
583
+ // for the list-branch inner comparison? No — just twice: scalar
584
+ // comparison + json_each subquery.
585
+ return {
586
+ sql: `(
587
+ ${field.sql} = ${n.sql}
588
+ OR EXISTS (
589
+ SELECT 1 FROM json_each(${field.sql}) WHERE value = ${n.sql}
590
+ )
591
+ )`,
592
+ // field.sql appears twice; n.sql appears twice → duplicate both.
593
+ params: [...field.params, ...n.params, ...field.params, ...n.params],
594
+ };
595
+ }
596
+ // No list() hint → plain @in against a materialized list. Not supported
597
+ // in M2 (there's no scalar-list variable to iterate against yet).
598
+ throw new KernelError("filter_invalid", {
599
+ reason: '@in without list(field): use `"x" in list(field)` for polymorphic membership',
600
+ });
601
+ }
602
+ function compileSize(inner) {
603
+ // size($backlinks_static()) / size($links_static()) → COUNT subquery.
604
+ const collection = asGraphCollection(inner);
605
+ if (collection)
606
+ return compileGraphCollectionSize(collection);
607
+ // size(list(field)) — polymorphic: 1 if scalar, N if list, 0 if null.
608
+ // We use the two-arg form of json_type — json_type(root, path) — so it
609
+ // looks at the JSON representation BEFORE unwrapping (a bare scalar
610
+ // like "one" would otherwise fail json_type as invalid JSON).
611
+ const listHint = unwrapListHint(inner);
612
+ if (listHint) {
613
+ const jsonType = jsonTypeSql(listHint);
614
+ if (jsonType) {
615
+ const field = compileExpr(listHint);
616
+ return {
617
+ sql: `(CASE
618
+ WHEN ${jsonType.sql} IS NULL THEN 0
619
+ WHEN ${jsonType.sql} = 'array' THEN json_array_length(${field.sql})
620
+ ELSE 1
621
+ END)`,
622
+ params: [...jsonType.params, ...jsonType.params, ...field.params],
623
+ };
624
+ }
625
+ }
626
+ // size(string) → length(str). size(list_literal) not supported.
627
+ const c = compileExpr(inner);
628
+ return { sql: `length(${c.sql})`, params: c.params };
629
+ }
630
+ /**
631
+ * Build a `json_type(root, path)` SQL fragment for a frontmatter access
632
+ * expression, so we can classify scalar vs list without json_extract
633
+ * unwrapping the value. Returns undefined for anything other than a
634
+ * top-level frontmatter identifier or a select over frontmatter.
635
+ */
636
+ function jsonTypeSql(expr) {
637
+ if (expr.exprKind.case === "identExpr") {
638
+ const name = expr.exprKind.value.name;
639
+ if (name.startsWith(INTRINSIC_PREFIX))
640
+ return undefined;
641
+ const escaped = name.replace(/"/g, '""');
642
+ return {
643
+ sql: `json_type(versions.frontmatter, '$."${escaped}"')`,
644
+ params: [],
645
+ };
646
+ }
647
+ if (expr.exprKind.case === "selectExpr") {
648
+ const path = collectSelectPath(expr);
649
+ if (!path)
650
+ return undefined;
651
+ const [root, ...rest] = path;
652
+ if (!root || root.startsWith(INTRINSIC_PREFIX))
653
+ return undefined;
654
+ const parts = [root, ...rest].map((p) => `"${p.replace(/"/g, '""')}"`);
655
+ return {
656
+ sql: `json_type(versions.frontmatter, '$.${parts.join(".")}')`,
657
+ params: [],
658
+ };
659
+ }
660
+ return undefined;
661
+ }
662
+ // -----------------------------------------------------------------------------
663
+ // Comprehensions — `list(field).all(v, pred)` and `.exists(v, pred)`
664
+ // -----------------------------------------------------------------------------
665
+ function compileComprehension(expr) {
666
+ if (expr.exprKind.case !== "comprehensionExpr") {
667
+ throw new Error("compileComprehension: wrong kind");
668
+ }
669
+ const comp = expr.exprKind.value;
670
+ // CEL comprehensions are macro-expanded by the parser. The parser emits
671
+ // one of a few canonical shapes:
672
+ //
673
+ // .all(v, pred) → accuInit=true, loopStep = accu && pred,
674
+ // loopCondition = accu (short-circuit)
675
+ // .exists(v, pred) → accuInit=false, loopStep = accu || pred
676
+ // .filter(v, pred) → accumulator is a list (not supported here)
677
+ // .map(...) → likewise not supported
678
+ // Graph collection comprehension: $backlinks_static().exists(d, pred) /
679
+ // $links_static().all(d, pred). The iter var ranges over the OTHER
680
+ // document's version row (§11.2), so the predicate compiles against a
681
+ // joined `versions` alias rather than json_each rows.
682
+ const collection = asGraphCollection(comp.iterRange);
683
+ if (collection)
684
+ return compileGraphComprehension(comp, collection);
685
+ // We detect the two supported shapes structurally. iterRange must be a
686
+ // list() hint (§5.2).
687
+ const listHint = unwrapListHint(comp.iterRange);
688
+ if (!listHint) {
689
+ throw new KernelError("filter_invalid", {
690
+ reason: "comprehensions must iterate over list(field)",
691
+ });
692
+ }
693
+ const shape = classifyComprehension(comp);
694
+ if (shape === "unsupported") {
695
+ throw new KernelError("filter_invalid", {
696
+ reason: "unsupported comprehension shape (use .all or .exists)",
697
+ });
698
+ }
699
+ const field = compileExpr(listHint);
700
+ // The predicate uses `comp.iterVar` (the loop var) as an ident referring
701
+ // to the current element. We compile predicates against a "row.value"
702
+ // sourced from json_each; substitute the iter var with a placeholder
703
+ // that maps to `_row.value`.
704
+ const predicate = compilePredicateAgainstIter(comp.loopStep, comp.iterVar, shape);
705
+ // json_type(root, path) is used to classify scalar vs array BEFORE
706
+ // json_extract unwraps the value (which would otherwise leave us with
707
+ // a bare scalar that json_type(...) alone can't process).
708
+ const jt = jsonTypeSql(listHint);
709
+ const jtSql = jt?.sql ?? "NULL";
710
+ const jtParams = jt?.params ?? [];
711
+ if (shape === "exists") {
712
+ // exists over list(field): try each list element; on scalar frontmatter
713
+ // treat the scalar as a one-element list.
714
+ const scalarPred = compilePredicateAgainstIter(comp.loopStep, comp.iterVar, shape, {
715
+ source: "scalar",
716
+ fieldSql: field.sql,
717
+ fieldParams: field.params,
718
+ });
719
+ return {
720
+ sql: `(
721
+ (${jtSql} IS NOT NULL AND ${jtSql} != 'array' AND (${scalarPred.sql}))
722
+ OR (${jtSql} = 'array' AND EXISTS (SELECT 1 FROM json_each(${field.sql}) WHERE ${predicate.sql}))
723
+ )`,
724
+ params: [
725
+ ...jtParams,
726
+ ...jtParams,
727
+ ...scalarPred.params,
728
+ ...jtParams,
729
+ ...field.params,
730
+ ...predicate.params,
731
+ ],
732
+ };
733
+ }
734
+ // "all"
735
+ const scalarPredAll = compilePredicateAgainstIter(comp.loopStep, comp.iterVar, shape, {
736
+ source: "scalar",
737
+ fieldSql: field.sql,
738
+ fieldParams: field.params,
739
+ });
740
+ return {
741
+ sql: `(
742
+ CASE
743
+ WHEN ${jtSql} IS NULL THEN 1
744
+ WHEN ${jtSql} != 'array' THEN (${scalarPredAll.sql})
745
+ ELSE NOT EXISTS (SELECT 1 FROM json_each(${field.sql}) WHERE NOT (${predicate.sql}))
746
+ END
747
+ )`,
748
+ params: [
749
+ ...jtParams,
750
+ ...jtParams,
751
+ ...scalarPredAll.params,
752
+ ...field.params,
753
+ ...predicate.params,
754
+ ],
755
+ };
756
+ }
757
+ function classifyComprehension(comp) {
758
+ const init = unwrapConstant(comp.accuInit);
759
+ if (init === true)
760
+ return "all";
761
+ if (init === false)
762
+ return "exists";
763
+ return "unsupported";
764
+ }
765
+ /**
766
+ * Compile a comprehension predicate, substituting occurrences of the
767
+ * loop-variable identifier with either `_row.value` (list branch) or the
768
+ * field's own SQL (scalar branch). The predicate walker is a recursive
769
+ * compileExpr with a scoped override for `identExpr` matches on `iterVar`.
770
+ */
771
+ function compilePredicateAgainstIter(predicateBase, iterVar, _shape, scalarCtx) {
772
+ // The loopStep for CEL's .all/.exists is a boolean combinator around the
773
+ // user's predicate. For .all: loopStep = _&&_(accu, pred). For .exists:
774
+ // loopStep = _||_(accu, pred). We extract the raw predicate.
775
+ const userPred = extractUserPredicate(predicateBase);
776
+ return compileWithIterSubstitution(userPred, iterVar, scalarCtx);
777
+ }
778
+ /**
779
+ * loopStep in CEL's canonical .all/.exists macros is `__result__ && pred` or
780
+ * `__result__ || pred`. Peel the accumulator off — we compile only the
781
+ * user's predicate; the accumulator semantics are handled by our
782
+ * EXISTS / NOT-EXISTS wrapping.
783
+ */
784
+ function extractUserPredicate(loopStep) {
785
+ if (loopStep.exprKind.case === "callExpr") {
786
+ const call = loopStep.exprKind.value;
787
+ if ((call.function === "_&&_" || call.function === "_||_") && call.args.length === 2) {
788
+ const [first, second] = call.args;
789
+ // Heuristic: the accumulator is an identExpr against @result / __result__.
790
+ if (first?.exprKind.case === "identExpr" &&
791
+ (first.exprKind.value.name === "@result" || first.exprKind.value.name === "__result__")) {
792
+ return second;
793
+ }
794
+ if (second?.exprKind.case === "identExpr" &&
795
+ (second.exprKind.value.name === "@result" || second.exprKind.value.name === "__result__")) {
796
+ return first;
797
+ }
798
+ }
799
+ }
800
+ return loopStep;
801
+ }
802
+ function compileWithIterSubstitution(expr, iterVar, scalarCtx) {
803
+ const kind = expr.exprKind.case;
804
+ if (kind === "identExpr" && expr.exprKind.value.name === iterVar) {
805
+ if (scalarCtx) {
806
+ return { sql: scalarCtx.fieldSql, params: [...scalarCtx.fieldParams] };
807
+ }
808
+ // Inside a FROM json_each(...) subquery, the current element is
809
+ // exposed as the unqualified column `value`.
810
+ return { sql: "value", params: [] };
811
+ }
812
+ if (kind === "callExpr") {
813
+ const call = expr.exprKind.value;
814
+ const target = call.target;
815
+ const args = call.args.map((a) => compileWithIterSubstitution(a, iterVar, scalarCtx));
816
+ const targetCompiled = target
817
+ ? compileWithIterSubstitution(target, iterVar, scalarCtx)
818
+ : undefined;
819
+ return recompileCallWithSubstitutedArgs(call.function, targetCompiled, args);
820
+ }
821
+ if (kind === "selectExpr") {
822
+ // Guard against silent-wrong-results: if the select's root IS the iter
823
+ // var (e.g. `list(authors).all(a, a.name == "alice")`), the fall-
824
+ // through to compileExpr would compile `a.name` as if it were a
825
+ // frontmatter path — semantically wrong and impossible to notice.
826
+ // Reject explicitly until we grow proper member-access support.
827
+ const root = collectSelectPath(expr)?.[0];
828
+ if (root === iterVar) {
829
+ throw new KernelError("filter_invalid", {
830
+ reason: `member access on comprehension iter-var '${iterVar}' is not yet supported`,
831
+ });
832
+ }
833
+ return compileExpr(expr);
834
+ }
835
+ return compileExpr(expr);
836
+ }
837
+ function recompileCallWithSubstitutedArgs(fn, target, args) {
838
+ // Small dispatch for the operators/functions we actually see inside a
839
+ // comprehension predicate: comparison ops + string methods.
840
+ const binOp = BIN_OPS[fn];
841
+ if (binOp && args.length === 2 && !target) {
842
+ const [a, b] = args;
843
+ return { sql: `(${a.sql} ${binOp} ${b.sql})`, params: [...a.params, ...b.params] };
844
+ }
845
+ if (fn === "!_" && args.length === 1 && !target) {
846
+ const [a] = args;
847
+ return { sql: `NOT (${a.sql})`, params: a.params };
848
+ }
849
+ if (fn === "startsWith") {
850
+ const t = target ?? args[0];
851
+ const p = target ? args[0] : args[1];
852
+ if (!t || !p)
853
+ throw new KernelError("filter_invalid", { reason: "startsWith needs 2 args" });
854
+ // SQL contains p.sql twice (length(p), = p). Duplicate p.params 2x.
855
+ return {
856
+ sql: `(substr(${t.sql}, 1, length(${p.sql})) = ${p.sql})`,
857
+ params: [...t.params, ...p.params, ...p.params],
858
+ };
859
+ }
860
+ if (fn === "endsWith") {
861
+ const t = target ?? args[0];
862
+ const p = target ? args[0] : args[1];
863
+ if (!t || !p)
864
+ throw new KernelError("filter_invalid", { reason: "endsWith needs 2 args" });
865
+ return {
866
+ sql: `(substr(${t.sql}, length(${t.sql}) - length(${p.sql}) + 1) = ${p.sql})`,
867
+ params: [...t.params, ...t.params, ...p.params, ...p.params],
868
+ };
869
+ }
870
+ if (fn === "contains") {
871
+ const t = target ?? args[0];
872
+ const n = target ? args[0] : args[1];
873
+ if (!t || !n)
874
+ throw new KernelError("filter_invalid", { reason: "contains needs 2 args" });
875
+ return { sql: `(instr(${t.sql}, ${n.sql}) > 0)`, params: [...t.params, ...n.params] };
876
+ }
877
+ throw new KernelError("filter_invalid", {
878
+ reason: `unsupported call inside comprehension predicate: ${fn}`,
879
+ });
880
+ }
881
+ //# sourceMappingURL=compile-filter.js.map