@rocicorp/zero 1.11.0-canary.12 → 1.11.0-canary.14

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 (260) hide show
  1. package/out/ast-to-zql/src/ast-to-zql.d.ts +8 -1
  2. package/out/ast-to-zql/src/ast-to-zql.d.ts.map +1 -1
  3. package/out/ast-to-zql/src/ast-to-zql.js +25 -25
  4. package/out/ast-to-zql/src/ast-to-zql.js.map +1 -1
  5. package/out/otel/src/log-options.d.ts +16 -0
  6. package/out/otel/src/log-options.d.ts.map +1 -1
  7. package/out/otel/src/log-options.js +54 -1
  8. package/out/otel/src/log-options.js.map +1 -1
  9. package/out/replicache/src/get-kv-store-provider.d.ts +1 -1
  10. package/out/replicache/src/get-kv-store-provider.d.ts.map +1 -1
  11. package/out/replicache/src/get-kv-store-provider.js +4 -4
  12. package/out/replicache/src/get-kv-store-provider.js.map +1 -1
  13. package/out/replicache/src/kv/idb-store.d.ts +10 -1
  14. package/out/replicache/src/kv/idb-store.d.ts.map +1 -1
  15. package/out/replicache/src/kv/idb-store.js +18 -2
  16. package/out/replicache/src/kv/idb-store.js.map +1 -1
  17. package/out/replicache/src/kv/mem-fallback-store.d.ts +54 -0
  18. package/out/replicache/src/kv/mem-fallback-store.d.ts.map +1 -0
  19. package/out/replicache/src/kv/mem-fallback-store.js +177 -0
  20. package/out/replicache/src/kv/mem-fallback-store.js.map +1 -0
  21. package/out/replicache/src/kv/op-sqlite/store.js +3 -3
  22. package/out/replicache/src/kv/op-sqlite/store.js.map +1 -1
  23. package/out/replicache/src/kv/sqlite-store.d.ts +9 -0
  24. package/out/replicache/src/kv/sqlite-store.d.ts.map +1 -1
  25. package/out/replicache/src/kv/sqlite-store.js +86 -6
  26. package/out/replicache/src/kv/sqlite-store.js.map +1 -1
  27. package/out/replicache/src/new-client-channel.js.map +1 -1
  28. package/out/replicache/src/persist/collect-idb-databases.d.ts +17 -3
  29. package/out/replicache/src/persist/collect-idb-databases.d.ts.map +1 -1
  30. package/out/replicache/src/persist/collect-idb-databases.js +81 -11
  31. package/out/replicache/src/persist/collect-idb-databases.js.map +1 -1
  32. package/out/replicache/src/persist/idb-databases-store-db-name.js +1 -1
  33. package/out/replicache/src/persist/idb-databases-store-db-name.js.map +1 -1
  34. package/out/replicache/src/persist/persist.d.ts.map +1 -1
  35. package/out/replicache/src/persist/persist.js +6 -3
  36. package/out/replicache/src/persist/persist.js.map +1 -1
  37. package/out/replicache/src/replicache-impl.d.ts +43 -3
  38. package/out/replicache/src/replicache-impl.d.ts.map +1 -1
  39. package/out/replicache/src/replicache-impl.js +135 -17
  40. package/out/replicache/src/replicache-impl.js.map +1 -1
  41. package/out/replicache/src/storage-failure.d.ts +37 -0
  42. package/out/replicache/src/storage-failure.d.ts.map +1 -0
  43. package/out/replicache/src/storage-failure.js +31 -0
  44. package/out/replicache/src/storage-failure.js.map +1 -0
  45. package/out/shared/src/random-values.js +12 -1
  46. package/out/shared/src/random-values.js.map +1 -1
  47. package/out/zero/package.js +1 -1
  48. package/out/zero/package.js.map +1 -1
  49. package/out/zero/src/zero.js +2 -1
  50. package/out/zero-cache/src/config/zero-config.d.ts +26 -0
  51. package/out/zero-cache/src/config/zero-config.d.ts.map +1 -1
  52. package/out/zero-cache/src/config/zero-config.js +36 -0
  53. package/out/zero-cache/src/config/zero-config.js.map +1 -1
  54. package/out/zero-cache/src/observability/log-throttle.d.ts +44 -0
  55. package/out/zero-cache/src/observability/log-throttle.d.ts.map +1 -0
  56. package/out/zero-cache/src/observability/log-throttle.js +76 -0
  57. package/out/zero-cache/src/observability/log-throttle.js.map +1 -0
  58. package/out/zero-cache/src/scripts/permissions.d.ts +16 -0
  59. package/out/zero-cache/src/scripts/permissions.d.ts.map +1 -1
  60. package/out/zero-cache/src/server/runner/zero-dispatcher.d.ts.map +1 -1
  61. package/out/zero-cache/src/server/runner/zero-dispatcher.js +2 -0
  62. package/out/zero-cache/src/server/runner/zero-dispatcher.js.map +1 -1
  63. package/out/zero-cache/src/server/syncer.d.ts.map +1 -1
  64. package/out/zero-cache/src/server/syncer.js +20 -2
  65. package/out/zero-cache/src/server/syncer.js.map +1 -1
  66. package/out/zero-cache/src/services/change-source/common/backfill-manager.d.ts.map +1 -1
  67. package/out/zero-cache/src/services/change-source/common/backfill-manager.js +189 -27
  68. package/out/zero-cache/src/services/change-source/common/backfill-manager.js.map +1 -1
  69. package/out/zero-cache/src/services/change-source/common/replica-schema.d.ts +1 -0
  70. package/out/zero-cache/src/services/change-source/common/replica-schema.d.ts.map +1 -1
  71. package/out/zero-cache/src/services/change-source/common/replica-schema.js +15 -3
  72. package/out/zero-cache/src/services/change-source/common/replica-schema.js.map +1 -1
  73. package/out/zero-cache/src/services/change-source/pg/backfill-stream.d.ts +9 -0
  74. package/out/zero-cache/src/services/change-source/pg/backfill-stream.d.ts.map +1 -1
  75. package/out/zero-cache/src/services/change-source/pg/backfill-stream.js +204 -18
  76. package/out/zero-cache/src/services/change-source/pg/backfill-stream.js.map +1 -1
  77. package/out/zero-cache/src/services/change-source/pg/initial-sync.d.ts +11 -2
  78. package/out/zero-cache/src/services/change-source/pg/initial-sync.d.ts.map +1 -1
  79. package/out/zero-cache/src/services/change-source/pg/initial-sync.js +11 -2
  80. package/out/zero-cache/src/services/change-source/pg/initial-sync.js.map +1 -1
  81. package/out/zero-cache/src/services/change-source/protocol/backfill-progress.d.ts +68 -0
  82. package/out/zero-cache/src/services/change-source/protocol/backfill-progress.d.ts.map +1 -0
  83. package/out/zero-cache/src/services/change-source/protocol/backfill-progress.js +141 -0
  84. package/out/zero-cache/src/services/change-source/protocol/backfill-progress.js.map +1 -0
  85. package/out/zero-cache/src/services/change-source/protocol/current/data.d.ts +43 -0
  86. package/out/zero-cache/src/services/change-source/protocol/current/data.d.ts.map +1 -1
  87. package/out/zero-cache/src/services/change-source/protocol/current/data.js +10 -1
  88. package/out/zero-cache/src/services/change-source/protocol/current/data.js.map +1 -1
  89. package/out/zero-cache/src/services/change-source/protocol/current/downstream.d.ts +57 -0
  90. package/out/zero-cache/src/services/change-source/protocol/current/downstream.d.ts.map +1 -1
  91. package/out/zero-cache/src/services/change-source/protocol/current/upstream.d.ts +20 -6
  92. package/out/zero-cache/src/services/change-source/protocol/current/upstream.d.ts.map +1 -1
  93. package/out/zero-cache/src/services/change-source/protocol/current/upstream.js +17 -7
  94. package/out/zero-cache/src/services/change-source/protocol/current/upstream.js.map +1 -1
  95. package/out/zero-cache/src/services/change-source/protocol/current.js +2 -1
  96. package/out/zero-cache/src/services/change-streamer/backfill-state-tracker.d.ts +66 -0
  97. package/out/zero-cache/src/services/change-streamer/backfill-state-tracker.d.ts.map +1 -0
  98. package/out/zero-cache/src/services/change-streamer/backfill-state-tracker.js +154 -0
  99. package/out/zero-cache/src/services/change-streamer/backfill-state-tracker.js.map +1 -0
  100. package/out/zero-cache/src/services/change-streamer/backfill-state.d.ts +50 -0
  101. package/out/zero-cache/src/services/change-streamer/backfill-state.d.ts.map +1 -0
  102. package/out/zero-cache/src/services/change-streamer/backfill-state.js +268 -0
  103. package/out/zero-cache/src/services/change-streamer/backfill-state.js.map +1 -0
  104. package/out/zero-cache/src/services/change-streamer/change-streamer-http.js +5 -4
  105. package/out/zero-cache/src/services/change-streamer/change-streamer-http.js.map +1 -1
  106. package/out/zero-cache/src/services/change-streamer/change-streamer-service.d.ts.map +1 -1
  107. package/out/zero-cache/src/services/change-streamer/change-streamer-service.js +20 -4
  108. package/out/zero-cache/src/services/change-streamer/change-streamer-service.js.map +1 -1
  109. package/out/zero-cache/src/services/change-streamer/change-streamer.d.ts +26 -1
  110. package/out/zero-cache/src/services/change-streamer/change-streamer.d.ts.map +1 -1
  111. package/out/zero-cache/src/services/change-streamer/change-streamer.js.map +1 -1
  112. package/out/zero-cache/src/services/change-streamer/storer.d.ts.map +1 -1
  113. package/out/zero-cache/src/services/change-streamer/storer.js +1 -1
  114. package/out/zero-cache/src/services/change-streamer/storer.js.map +1 -1
  115. package/out/zero-cache/src/services/change-streamer/subscribe.d.ts +88 -0
  116. package/out/zero-cache/src/services/change-streamer/subscribe.d.ts.map +1 -1
  117. package/out/zero-cache/src/services/change-streamer/subscribe.js +9 -1
  118. package/out/zero-cache/src/services/change-streamer/subscribe.js.map +1 -1
  119. package/out/zero-cache/src/services/change-streamer/subscriber.d.ts +29 -0
  120. package/out/zero-cache/src/services/change-streamer/subscriber.d.ts.map +1 -1
  121. package/out/zero-cache/src/services/change-streamer/subscriber.js +34 -0
  122. package/out/zero-cache/src/services/change-streamer/subscriber.js.map +1 -1
  123. package/out/zero-cache/src/services/plannerz.d.ts +159 -0
  124. package/out/zero-cache/src/services/plannerz.d.ts.map +1 -0
  125. package/out/zero-cache/src/services/plannerz.js +263 -0
  126. package/out/zero-cache/src/services/plannerz.js.map +1 -0
  127. package/out/zero-cache/src/services/replicator/change-log-cookies.js +1 -1
  128. package/out/zero-cache/src/services/replicator/change-log-cookies.js.map +1 -1
  129. package/out/zero-cache/src/services/replicator/change-processor.d.ts.map +1 -1
  130. package/out/zero-cache/src/services/replicator/change-processor.js +48 -5
  131. package/out/zero-cache/src/services/replicator/change-processor.js.map +1 -1
  132. package/out/zero-cache/src/services/replicator/incremental-sync.d.ts.map +1 -1
  133. package/out/zero-cache/src/services/replicator/incremental-sync.js +4 -3
  134. package/out/zero-cache/src/services/replicator/incremental-sync.js.map +1 -1
  135. package/out/zero-cache/src/services/replicator/schema/backfilling.d.ts +23 -2
  136. package/out/zero-cache/src/services/replicator/schema/backfilling.d.ts.map +1 -1
  137. package/out/zero-cache/src/services/replicator/schema/backfilling.js +54 -2
  138. package/out/zero-cache/src/services/replicator/schema/backfilling.js.map +1 -1
  139. package/out/zero-cache/src/services/replicator/write-worker-client.d.ts +11 -3
  140. package/out/zero-cache/src/services/replicator/write-worker-client.d.ts.map +1 -1
  141. package/out/zero-cache/src/services/replicator/write-worker-client.js.map +1 -1
  142. package/out/zero-cache/src/services/replicator/write-worker.js +5 -1
  143. package/out/zero-cache/src/services/replicator/write-worker.js.map +1 -1
  144. package/out/zero-cache/src/services/view-syncer/cvr-store.d.ts +11 -0
  145. package/out/zero-cache/src/services/view-syncer/cvr-store.d.ts.map +1 -1
  146. package/out/zero-cache/src/services/view-syncer/cvr-store.js +20 -7
  147. package/out/zero-cache/src/services/view-syncer/cvr-store.js.map +1 -1
  148. package/out/zero-cache/src/services/view-syncer/cvr.d.ts.map +1 -1
  149. package/out/zero-cache/src/services/view-syncer/cvr.js +1 -1
  150. package/out/zero-cache/src/services/view-syncer/cvr.js.map +1 -1
  151. package/out/zero-cache/src/services/view-syncer/deferred-writes-budget.d.ts +81 -0
  152. package/out/zero-cache/src/services/view-syncer/deferred-writes-budget.d.ts.map +1 -0
  153. package/out/zero-cache/src/services/view-syncer/deferred-writes-budget.js +128 -0
  154. package/out/zero-cache/src/services/view-syncer/deferred-writes-budget.js.map +1 -0
  155. package/out/zero-cache/src/services/view-syncer/inspect-handler.js +1 -1
  156. package/out/zero-cache/src/services/view-syncer/pipeline-driver.d.ts +36 -1
  157. package/out/zero-cache/src/services/view-syncer/pipeline-driver.d.ts.map +1 -1
  158. package/out/zero-cache/src/services/view-syncer/pipeline-driver.js +393 -30
  159. package/out/zero-cache/src/services/view-syncer/pipeline-driver.js.map +1 -1
  160. package/out/zero-cache/src/services/view-syncer/plan-warnings.d.ts +7 -0
  161. package/out/zero-cache/src/services/view-syncer/plan-warnings.d.ts.map +1 -0
  162. package/out/zero-cache/src/services/view-syncer/plan-warnings.js +35 -0
  163. package/out/zero-cache/src/services/view-syncer/plan-warnings.js.map +1 -0
  164. package/out/zero-cache/src/services/view-syncer/query-shape.d.ts +25 -0
  165. package/out/zero-cache/src/services/view-syncer/query-shape.d.ts.map +1 -0
  166. package/out/zero-cache/src/services/view-syncer/query-shape.js +28 -0
  167. package/out/zero-cache/src/services/view-syncer/query-shape.js.map +1 -0
  168. package/out/zero-cache/src/services/view-syncer/query-stats.d.ts +77 -0
  169. package/out/zero-cache/src/services/view-syncer/query-stats.d.ts.map +1 -0
  170. package/out/zero-cache/src/services/view-syncer/query-stats.js +206 -0
  171. package/out/zero-cache/src/services/view-syncer/query-stats.js.map +1 -0
  172. package/out/zero-cache/src/services/view-syncer/snapshotter.d.ts +11 -1
  173. package/out/zero-cache/src/services/view-syncer/snapshotter.d.ts.map +1 -1
  174. package/out/zero-cache/src/services/view-syncer/snapshotter.js +7 -1
  175. package/out/zero-cache/src/services/view-syncer/snapshotter.js.map +1 -1
  176. package/out/zero-cache/src/services/view-syncer/view-syncer.d.ts.map +1 -1
  177. package/out/zero-cache/src/services/view-syncer/view-syncer.js +50 -3
  178. package/out/zero-cache/src/services/view-syncer/view-syncer.js.map +1 -1
  179. package/out/zero-client/src/client/options.d.ts +29 -0
  180. package/out/zero-client/src/client/options.d.ts.map +1 -1
  181. package/out/zero-client/src/client/options.js.map +1 -1
  182. package/out/zero-client/src/client/version.js +1 -1
  183. package/out/zero-client/src/client/zero.d.ts.map +1 -1
  184. package/out/zero-client/src/client/zero.js +8 -23
  185. package/out/zero-client/src/client/zero.js.map +1 -1
  186. package/out/zero-client/src/mod.d.ts +1 -0
  187. package/out/zero-client/src/mod.d.ts.map +1 -1
  188. package/out/zero-client/src/mod.js +1 -0
  189. package/out/zero-protocol/src/down.d.ts +2 -2
  190. package/out/zero-react/src/zero.js +1 -0
  191. package/out/zero-solid/src/zero.js +1 -0
  192. package/out/zql/src/builder/builder.d.ts +6 -0
  193. package/out/zql/src/builder/builder.d.ts.map +1 -1
  194. package/out/zql/src/builder/builder.js +5 -4
  195. package/out/zql/src/builder/builder.js.map +1 -1
  196. package/out/zql/src/ivm/flipped-join.d.ts +1 -1
  197. package/out/zql/src/ivm/flipped-join.d.ts.map +1 -1
  198. package/out/zql/src/ivm/flipped-join.js +19 -22
  199. package/out/zql/src/ivm/flipped-join.js.map +1 -1
  200. package/out/zql/src/ivm/join.d.ts +1 -1
  201. package/out/zql/src/ivm/join.d.ts.map +1 -1
  202. package/out/zql/src/ivm/join.js +34 -18
  203. package/out/zql/src/ivm/join.js.map +1 -1
  204. package/out/zql/src/ivm/memory-source.d.ts +13 -0
  205. package/out/zql/src/ivm/memory-source.d.ts.map +1 -1
  206. package/out/zql/src/ivm/memory-source.js +1 -1
  207. package/out/zql/src/ivm/memory-source.js.map +1 -1
  208. package/out/zql/src/ivm/take-gate.d.ts +23 -2
  209. package/out/zql/src/ivm/take-gate.d.ts.map +1 -1
  210. package/out/zql/src/ivm/take-gate.js +23 -1
  211. package/out/zql/src/ivm/take-gate.js.map +1 -1
  212. package/out/zql/src/planner/planner-builder.d.ts +12 -1
  213. package/out/zql/src/planner/planner-builder.d.ts.map +1 -1
  214. package/out/zql/src/planner/planner-builder.js +8 -1
  215. package/out/zql/src/planner/planner-builder.js.map +1 -1
  216. package/out/zql/src/planner/planner-connection.d.ts +43 -0
  217. package/out/zql/src/planner/planner-connection.d.ts.map +1 -1
  218. package/out/zql/src/planner/planner-connection.js +31 -4
  219. package/out/zql/src/planner/planner-connection.js.map +1 -1
  220. package/out/zql/src/planner/planner-graph.d.ts +10 -0
  221. package/out/zql/src/planner/planner-graph.d.ts.map +1 -1
  222. package/out/zql/src/planner/planner-graph.js +14 -0
  223. package/out/zql/src/planner/planner-graph.js.map +1 -1
  224. package/out/zql/src/planner/planner-warnings.d.ts +83 -0
  225. package/out/zql/src/planner/planner-warnings.d.ts.map +1 -0
  226. package/out/zql/src/planner/planner-warnings.js +78 -0
  227. package/out/zql/src/planner/planner-warnings.js.map +1 -0
  228. package/out/zql/src/query/measure-push-operator.d.ts.map +1 -1
  229. package/out/zql/src/query/measure-push-operator.js +30 -8
  230. package/out/zql/src/query/measure-push-operator.js.map +1 -1
  231. package/out/zqlite/src/pending-delta.d.ts +110 -0
  232. package/out/zqlite/src/pending-delta.d.ts.map +1 -0
  233. package/out/zqlite/src/pending-delta.js +278 -0
  234. package/out/zqlite/src/pending-delta.js.map +1 -0
  235. package/out/zqlite/src/query-delegate.d.ts.map +1 -1
  236. package/out/zqlite/src/query-delegate.js +5 -1
  237. package/out/zqlite/src/query-delegate.js.map +1 -1
  238. package/out/zqlite/src/sqlite-cost-model.d.ts.map +1 -1
  239. package/out/zqlite/src/sqlite-cost-model.js +52 -1
  240. package/out/zqlite/src/sqlite-cost-model.js.map +1 -1
  241. package/out/zqlite/src/sqlite-stat-fanout.d.ts.map +1 -1
  242. package/out/zqlite/src/sqlite-stat-fanout.js +2 -26
  243. package/out/zqlite/src/sqlite-stat-fanout.js.map +1 -1
  244. package/out/zqlite/src/sqlite-stat4-sample.d.ts +37 -0
  245. package/out/zqlite/src/sqlite-stat4-sample.d.ts.map +1 -0
  246. package/out/zqlite/src/sqlite-stat4-sample.js +61 -0
  247. package/out/zqlite/src/sqlite-stat4-sample.js.map +1 -0
  248. package/out/zqlite/src/table-source.d.ts +68 -0
  249. package/out/zqlite/src/table-source.d.ts.map +1 -1
  250. package/out/zqlite/src/table-source.js +206 -7
  251. package/out/zqlite/src/table-source.js.map +1 -1
  252. package/package.json +1 -1
  253. package/out/replicache/src/kv/idb-store-with-mem-fallback.d.ts +0 -35
  254. package/out/replicache/src/kv/idb-store-with-mem-fallback.d.ts.map +0 -1
  255. package/out/replicache/src/kv/idb-store-with-mem-fallback.js +0 -83
  256. package/out/replicache/src/kv/idb-store-with-mem-fallback.js.map +0 -1
  257. package/out/zero-cache/src/services/change-source/pg/backfill-resume.d.ts +0 -156
  258. package/out/zero-cache/src/services/change-source/pg/backfill-resume.d.ts.map +0 -1
  259. package/out/zero-cache/src/services/change-source/pg/backfill-resume.js +0 -15
  260. package/out/zero-cache/src/services/change-source/pg/backfill-resume.js.map +0 -1
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pending-delta.js","names":["#primaryKey","#singleColumnKey","#firstKeyValues","#byKey","#indexes","#rowBytes","#indexBytes","#liveCount","#key","#track","#removeFromIndexes","#addToIndexes","#getOrCreateIndex"],"sources":["../../../../zqlite/src/pending-delta.ts"],"sourcesContent":["import {BTreeSet} from '../../shared/src/btree-set.ts';\nimport {hasOwn} from '../../shared/src/has-own.ts';\nimport type {Ordering, OrderPart} from '../../zero-protocol/src/ast.ts';\nimport type {Row, Value} from '../../zero-protocol/src/data.ts';\nimport type {PrimaryKey} from '../../zero-protocol/src/primary-key.ts';\nimport {\n constraintMatchesPrimaryKey,\n constraintMatchesRow,\n type Constraint,\n} from '../../zql/src/ivm/constraint.ts';\nimport type {Comparator} from '../../zql/src/ivm/data.ts';\nimport {\n generateRows,\n makeBoundComparator,\n maxValue,\n minValue,\n type RowBound,\n} from '../../zql/src/ivm/memory-source.ts';\nimport type {MultiConstraint} from '../../zql/src/ivm/operator.ts';\n\n/**\n * The batch overlay that lets a `TableSource` derive a transaction without\n * writing to the database it is reading from.\n *\n * A `TableSource` normally makes each change durable in its backing SQLite\n * connection as soon as it has been vended to every output, because change\n * *k+1* has to read a base that already includes changes *1..k* -- IVM's\n * joins fetch from the source mid-push. On the server that backing connection\n * is a `BEGIN CONCURRENT` snapshot that is never committed and always rolled\n * back, so those writes exist only to serve reads for the rest of the pass.\n *\n * A `PendingDelta` holds them in memory instead. The base stays pinned at the\n * state it had when the pass began and this object supplies *1..k*, merged\n * into every leaf scan. The durable copy comes from the replicator's own\n * commit, which the source picks up when it leapfrogs to the next snapshot.\n *\n * State is coalesced by primary key: whatever the batch did to a row, all that\n * matters downstream is the row's final value, or that it is gone. A key that\n * appears here overrides the base unconditionally -- an added row has no base\n * to override, an edited row's base copy is suppressed in favor of the new\n * value, and a removed row's base copy is suppressed with nothing to put in\n * its place.\n *\n * Live rows are additionally kept in lazily built sorted indexes, one per\n * `[...constraintKeys, ...sort]` shape a fetch has asked for, so that merging\n * the delta into a constrained scan costs `O(log D + matches)` rather than a\n * walk of the whole delta. This mirrors `MemorySource`'s index handling; see\n * its `#fetch` for the bound construction the scans here reproduce.\n */\nexport class PendingDelta {\n readonly #primaryKey: PrimaryKey;\n readonly #singleColumnKey: string | undefined;\n\n /**\n * For a composite primary key, the first key column's value of every key in\n * `#byKey`. {@link overrides} runs for every base row of a merged fetch, and\n * building a composite key allocates, so this rules most rows out first.\n */\n readonly #firstKeyValues = new Set<Value>();\n\n /**\n * The coalesced net state, keyed by primary key. A `undefined` value is a\n * tombstone: the batch removed the row, so the base copy is suppressed and\n * nothing replaces it. Membership alone is what suppresses a base row, so\n * this map is also the \"is this key overridden\" test.\n */\n readonly #byKey = new Map<unknown, Row | undefined>();\n\n /** Sorted views over the live (non-tombstone) rows, keyed by `JSON(sort)`. */\n readonly #indexes = new Map<string, BTreeSet<Row>>();\n\n #liveCount = 0;\n #rowBytes = 0;\n #indexBytes = 0;\n\n constructor(primaryKey: PrimaryKey) {\n this.#primaryKey = primaryKey;\n this.#singleColumnKey = primaryKey.length === 1 ? primaryKey[0] : undefined;\n }\n\n /**\n * True when the source can take its untouched fast path: no suppression\n * test per base row, no merge, no index maintenance.\n */\n get isEmpty(): boolean {\n return this.#byKey.size === 0;\n }\n\n /** The number of rows the batch has touched, tombstones included. */\n get size(): number {\n return this.#byKey.size;\n }\n\n /**\n * Estimated retained bytes, including keys, tombstones, and sorted indexes.\n * This is a budgeting heuristic, not a measurement of the JavaScript heap.\n */\n get estimatedBytes(): number {\n // Budget 32 bytes per live row per index for references and B-tree nodes.\n return (\n this.#rowBytes +\n this.#indexBytes +\n this.#liveCount * this.#indexes.size * 32\n );\n }\n\n #key(row: Row): unknown {\n const single = this.#singleColumnKey;\n if (single !== undefined) {\n return row[single];\n }\n const key = this.#primaryKey.map(k => row[k]);\n return JSON.stringify(key);\n }\n\n #track(row: Row, key: unknown): void {\n if (this.#byKey.has(key)) {\n return;\n }\n this.#rowBytes += 64 + estimateBytes(key as Value);\n if (this.#singleColumnKey === undefined) {\n this.#firstKeyValues.add(row[this.#primaryKey[0]]);\n }\n }\n\n /** Records that `row` is now present with this value. */\n set(row: Row): void {\n const key = this.#key(row);\n const existing = this.#byKey.get(key);\n this.#track(row, key);\n this.#rowBytes +=\n estimateBytes(row) -\n (existing === undefined ? 0 : estimateBytes(existing));\n if (existing !== undefined) {\n // A live row for this key is already indexed under its old value, which\n // may sort differently. Retract it before inserting the new one.\n this.#removeFromIndexes(existing);\n this.#liveCount--;\n }\n this.#byKey.set(key, row);\n this.#addToIndexes(row);\n this.#liveCount++;\n }\n\n /** Records that `row` is now absent. */\n delete(row: Row): void {\n const key = this.#key(row);\n const existing = this.#byKey.get(key);\n this.#track(row, key);\n if (existing !== undefined) {\n this.#rowBytes -= estimateBytes(existing);\n this.#removeFromIndexes(existing);\n this.#liveCount--;\n }\n this.#byKey.set(key, undefined);\n }\n\n /**\n * Whether the batch overrides this base row. Callers use it to suppress base\n * rows during a merge, so it must be answered by primary key -- the value\n * carried by the base row is exactly what the delta is replacing.\n */\n overrides(row: Row): boolean {\n if (\n this.#singleColumnKey === undefined &&\n !this.#firstKeyValues.has(row[this.#primaryKey[0]])\n ) {\n return false;\n }\n return this.#byKey.has(this.#key(row));\n }\n\n /**\n * The batch's value for a row, by primary key. Returns `NOT_OVERRIDDEN` when\n * the batch has not touched the key at all (so the caller should consult the\n * base) and `undefined` when the batch removed it.\n */\n get(row: Row): Row | undefined | typeof NOT_OVERRIDDEN {\n const key = this.#key(row);\n if (!this.#byKey.has(key)) {\n return NOT_OVERRIDDEN;\n }\n return this.#byKey.get(key);\n }\n\n /**\n * Looks a row up by an arbitrary (not necessarily primary) unique key. Only\n * reachable when the delta is non-empty, which is never the case for the one\n * caller that uses a non-primary key, so a walk is fine here.\n */\n getByColumns(keyCols: readonly string[], keyRow: Row): Row | undefined {\n for (const row of this.#byKey.values()) {\n if (row !== undefined && keyCols.every(c => row[c] === keyRow[c])) {\n return row;\n }\n }\n return undefined;\n }\n\n #addToIndexes(row: Row): void {\n for (const data of this.#indexes.values()) {\n data.add(row);\n }\n }\n\n #removeFromIndexes(row: Row): void {\n for (const data of this.#indexes.values()) {\n data.delete(row);\n }\n }\n\n #getOrCreateIndex(sort: Ordering): BTreeSet<Row> {\n const indexKey = JSON.stringify(sort);\n let data = this.#indexes.get(indexKey);\n if (data === undefined) {\n data = new BTreeSet<Row>(makeBoundComparator(sort) as Comparator);\n for (const row of this.#byKey.values()) {\n if (row !== undefined) {\n data.add(row);\n }\n }\n this.#indexes.set(indexKey, data);\n this.#indexBytes += 128 + estimateBytes(indexKey) + estimateBytes(sort);\n }\n return data;\n }\n\n /**\n * The live rows this batch contributes to a fetch, in the same order the\n * backing SQL query returns its rows, so the two streams can be merged.\n *\n * `constraint`, `multiConstraints` and `filterPredicate` are the ones SQL\n * already applied to the base rows; they are applied here so the delta rows\n * are subject to the same query. `start` is the fetch's start row: the scan\n * begins there rather than at the start of the constraint span. It is\n * inclusive, so the caller still applies an `after` basis.\n */\n *rowsFor(\n sort: Ordering,\n constraint: Constraint | undefined,\n reverse: boolean | undefined,\n filterPredicate: ((row: Row) => boolean | undefined) | undefined,\n multiConstraints: readonly MultiConstraint[] | undefined,\n start?: Row | undefined,\n ): Iterable<Row> {\n if (this.#liveCount === 0) {\n return;\n }\n\n // Lead the index with the constraint keys so a constrained fetch scans\n // only the matching span; within that span rows come out in `sort` order,\n // which is the order SQL returned the base rows in.\n const indexSort: OrderPart[] = [];\n if (constraint) {\n for (const key of Object.keys(constraint)) {\n indexSort.push([key, 'asc']);\n }\n }\n // Constraining by the whole primary key admits at most one row, so the\n // requested sort adds nothing.\n const sorted =\n !constraint || !constraintMatchesPrimaryKey(constraint, this.#primaryKey);\n if (sorted) {\n indexSort.push(...sort);\n }\n\n const data = this.#getOrCreateIndex(indexSort);\n\n let scanStart: RowBound | undefined;\n if (\n start !== undefined &&\n sorted &&\n (!constraint || constraintMatchesRow(constraint, start))\n ) {\n // `start` carries every index column, and within the constraint span it\n // agrees with the constraint, so it is the scan's bound as it stands.\n // A `start` outside the span bounds nothing the scan can use.\n scanStart = start;\n } else if (constraint) {\n // The first row matching the constraint is not simply the constraint\n // values with everything else absent: a `desc` part puts absent values\n // last. The min/max sentinels say \"the first row with these constraint\n // values\" regardless of direction. Same construction as `MemorySource`.\n scanStart = {};\n for (const [key, dir] of indexSort) {\n if (hasOwn(constraint, key)) {\n scanStart[key] = constraint[key] as Value;\n } else if (reverse) {\n scanStart[key] = dir === 'asc' ? maxValue : minValue;\n } else {\n scanStart[key] = dir === 'asc' ? minValue : maxValue;\n }\n }\n }\n\n for (const row of generateRows(data, scanStart, reverse)) {\n if (constraint && !constraintMatchesRow(constraint, row)) {\n // Rows are sorted by the constraint keys first, so matches are\n // contiguous and the first miss ends the span.\n break;\n }\n if (multiConstraints && !matchesMultiConstraints(row, multiConstraints)) {\n continue;\n }\n if (filterPredicate && !filterPredicate(row)) {\n continue;\n }\n yield row;\n }\n }\n\n /**\n * A row with the primary key of each row the batch has touched, removed\n * rows included. A live row is returned as is.\n */\n *touchedKeys(): Iterable<Row> {\n const single = this.#singleColumnKey;\n for (const [key, row] of this.#byKey) {\n if (row !== undefined) {\n yield row;\n } else if (single !== undefined) {\n yield {[single]: key as Value};\n } else {\n const values = JSON.parse(key as string) as Value[];\n yield Object.fromEntries(\n this.#primaryKey.map((k, i) => [k, values[i]]),\n );\n }\n }\n }\n\n /** The rows the batch has added or edited, and not since removed. */\n *liveRows(): Iterable<Row> {\n for (const row of this.#byKey.values()) {\n if (row !== undefined) {\n yield row;\n }\n }\n }\n\n /** Drops the batch. Called when the source moves to a snapshot that has it. */\n clear(): void {\n this.#byKey.clear();\n this.#firstKeyValues.clear();\n this.#indexes.clear();\n this.#liveCount = 0;\n this.#rowBytes = 0;\n this.#indexBytes = 0;\n }\n}\n\n// Allow for object/property storage and UTF-16 strings without allocating a\n// serialized copy. Shared values may be counted more than once intentionally.\nfunction estimateBytes(value: Value): number {\n if (typeof value === 'string') {\n return 24 + value.length * 2;\n }\n if (value === null || typeof value !== 'object') {\n return 8;\n }\n let bytes = 32;\n if (Array.isArray(value)) {\n for (const item of value) {\n bytes += 8 + estimateBytes(item);\n }\n } else {\n for (const key in value) {\n bytes += 16 + estimateBytes(key) + estimateBytes((value as Row)[key]);\n }\n }\n return bytes;\n}\n\nexport const NOT_OVERRIDDEN = Symbol('not-overridden');\n\nfunction matchesMultiConstraints(\n row: Row,\n multiConstraints: readonly MultiConstraint[],\n): boolean {\n for (const mc of multiConstraints) {\n if (mc.length === 0) {\n continue;\n }\n let any = false;\n for (const c of mc) {\n if (constraintMatchesRow(c, row)) {\n any = true;\n break;\n }\n }\n if (!any) {\n return false;\n }\n }\n return true;\n}\n\n/**\n * Merges a batch's rows into an ordered base stream and drops the base rows\n * the batch overrides.\n *\n * Both streams must already be in `compare` order. A delta row that ties with\n * a base row is the *same* row by primary key -- every fetch ordering includes\n * the primary key -- so the base copy is suppressed and the delta's value\n * takes its place at that position.\n */\nexport function* generateWithPendingDelta(\n baseRows: Iterable<Row>,\n deltaRows: Iterable<Row>,\n delta: PendingDelta,\n compare: Comparator,\n): IterableIterator<Row> {\n const deltaIter = deltaRows[Symbol.iterator]();\n let pending = deltaIter.next();\n try {\n for (const row of baseRows) {\n while (!pending.done && compare(pending.value, row) < 0) {\n yield pending.value;\n pending = deltaIter.next();\n }\n if (!delta.overrides(row)) {\n yield row;\n }\n }\n while (!pending.done) {\n yield pending.value;\n pending = deltaIter.next();\n }\n } finally {\n // Propagate early termination so the delta iterator can release the BTree\n // cursor it is walking, the same contract `mergeSortedStreams` documents.\n if (!pending.done) {\n deltaIter.return?.(undefined);\n }\n }\n}\n\n/**\n * The unordered counterpart. Nothing downstream depends on position, so the\n * surviving base rows are emitted first and the batch's rows follow.\n */\nexport function* generateWithPendingDeltaUnordered(\n baseRows: Iterable<Row>,\n deltaRows: Iterable<Row>,\n delta: PendingDelta,\n): IterableIterator<Row> {\n for (const row of baseRows) {\n if (!delta.overrides(row)) {\n yield row;\n }\n }\n yield* deltaRows;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAiDA,IAAa,eAAb,MAA0B;CACxB;CACA;;;;;;CAOA,kCAA2B,IAAI,IAAW;;;;;;;CAQ1C,yBAAkB,IAAI,IAA8B;;CAGpD,2BAAoB,IAAI,IAA2B;CAEnD,aAAa;CACb,YAAY;CACZ,cAAc;CAEd,YAAY,YAAwB;EAClC,KAAKA,cAAc;EACnB,KAAKC,mBAAmB,WAAW,WAAW,IAAI,WAAW,KAAK,KAAA;CACpE;;;;;CAMA,IAAI,UAAmB;EACrB,OAAO,KAAKE,OAAO,SAAS;CAC9B;;CAGA,IAAI,OAAe;EACjB,OAAO,KAAKA,OAAO;CACrB;;;;;CAMA,IAAI,iBAAyB;EAE3B,OACE,KAAKE,YACL,KAAKC,cACL,KAAKC,aAAa,KAAKH,SAAS,OAAO;CAE3C;CAEA,KAAK,KAAmB;EACtB,MAAM,SAAS,KAAKH;EACpB,IAAI,WAAW,KAAA,GACb,OAAO,IAAI;EAEb,MAAM,MAAM,KAAKD,YAAY,KAAI,MAAK,IAAI,EAAE;EAC5C,OAAO,KAAK,UAAU,GAAG;CAC3B;CAEA,OAAO,KAAU,KAAoB;EACnC,IAAI,KAAKG,OAAO,IAAI,GAAG,GACrB;EAEF,KAAKE,aAAa,KAAK,cAAc,GAAY;EACjD,IAAI,KAAKJ,qBAAqB,KAAA,GAC5B,KAAKC,gBAAgB,IAAI,IAAI,KAAKF,YAAY,GAAG;CAErD;;CAGA,IAAI,KAAgB;EAClB,MAAM,MAAM,KAAKQ,KAAK,GAAG;EACzB,MAAM,WAAW,KAAKL,OAAO,IAAI,GAAG;EACpC,KAAKM,OAAO,KAAK,GAAG;EACpB,KAAKJ,aACH,cAAc,GAAG,KAChB,aAAa,KAAA,IAAY,IAAI,cAAc,QAAQ;EACtD,IAAI,aAAa,KAAA,GAAW;GAG1B,KAAKK,mBAAmB,QAAQ;GAChC,KAAKH;EACP;EACA,KAAKJ,OAAO,IAAI,KAAK,GAAG;EACxB,KAAKQ,cAAc,GAAG;EACtB,KAAKJ;CACP;;CAGA,OAAO,KAAgB;EACrB,MAAM,MAAM,KAAKC,KAAK,GAAG;EACzB,MAAM,WAAW,KAAKL,OAAO,IAAI,GAAG;EACpC,KAAKM,OAAO,KAAK,GAAG;EACpB,IAAI,aAAa,KAAA,GAAW;GAC1B,KAAKJ,aAAa,cAAc,QAAQ;GACxC,KAAKK,mBAAmB,QAAQ;GAChC,KAAKH;EACP;EACA,KAAKJ,OAAO,IAAI,KAAK,KAAA,CAAS;CAChC;;;;;;CAOA,UAAU,KAAmB;EAC3B,IACE,KAAKF,qBAAqB,KAAA,KAC1B,CAAC,KAAKC,gBAAgB,IAAI,IAAI,KAAKF,YAAY,GAAG,GAElD,OAAO;EAET,OAAO,KAAKG,OAAO,IAAI,KAAKK,KAAK,GAAG,CAAC;CACvC;;;;;;CAOA,IAAI,KAAmD;EACrD,MAAM,MAAM,KAAKA,KAAK,GAAG;EACzB,IAAI,CAAC,KAAKL,OAAO,IAAI,GAAG,GACtB,OAAO;EAET,OAAO,KAAKA,OAAO,IAAI,GAAG;CAC5B;;;;;;CAOA,aAAa,SAA4B,QAA8B;EACrE,KAAK,MAAM,OAAO,KAAKA,OAAO,OAAO,GACnC,IAAI,QAAQ,KAAA,KAAa,QAAQ,OAAM,MAAK,IAAI,OAAO,OAAO,EAAE,GAC9D,OAAO;CAIb;CAEA,cAAc,KAAgB;EAC5B,KAAK,MAAM,QAAQ,KAAKC,SAAS,OAAO,GACtC,KAAK,IAAI,GAAG;CAEhB;CAEA,mBAAmB,KAAgB;EACjC,KAAK,MAAM,QAAQ,KAAKA,SAAS,OAAO,GACtC,KAAK,OAAO,GAAG;CAEnB;CAEA,kBAAkB,MAA+B;EAC/C,MAAM,WAAW,KAAK,UAAU,IAAI;EACpC,IAAI,OAAO,KAAKA,SAAS,IAAI,QAAQ;EACrC,IAAI,SAAS,KAAA,GAAW;GACtB,OAAO,IAAI,SAAc,oBAAoB,IAAI,CAAe;GAChE,KAAK,MAAM,OAAO,KAAKD,OAAO,OAAO,GACnC,IAAI,QAAQ,KAAA,GACV,KAAK,IAAI,GAAG;GAGhB,KAAKC,SAAS,IAAI,UAAU,IAAI;GAChC,KAAKE,eAAe,MAAM,cAAc,QAAQ,IAAI,cAAc,IAAI;EACxE;EACA,OAAO;CACT;;;;;;;;;;;CAYA,CAAC,QACC,MACA,YACA,SACA,iBACA,kBACA,OACe;EACf,IAAI,KAAKC,eAAe,GACtB;EAMF,MAAM,YAAyB,CAAC;EAChC,IAAI,YACF,KAAK,MAAM,OAAO,OAAO,KAAK,UAAU,GACtC,UAAU,KAAK,CAAC,KAAK,KAAK,CAAC;EAK/B,MAAM,SACJ,CAAC,cAAc,CAAC,4BAA4B,YAAY,KAAKP,WAAW;EAC1E,IAAI,QACF,UAAU,KAAK,GAAG,IAAI;EAGxB,MAAM,OAAO,KAAKY,kBAAkB,SAAS;EAE7C,IAAI;EACJ,IACE,UAAU,KAAA,KACV,WACC,CAAC,cAAc,qBAAqB,YAAY,KAAK,IAKtD,YAAY;OACP,IAAI,YAAY;GAKrB,YAAY,CAAC;GACb,KAAK,MAAM,CAAC,KAAK,QAAQ,WACvB,IAAI,OAAO,YAAY,GAAG,GACxB,UAAU,OAAO,WAAW;QACvB,IAAI,SACT,UAAU,OAAO,QAAQ,QAAQ,WAAW;QAE5C,UAAU,OAAO,QAAQ,QAAQ,WAAW;EAGlD;EAEA,KAAK,MAAM,OAAO,aAAa,MAAM,WAAW,OAAO,GAAG;GACxD,IAAI,cAAc,CAAC,qBAAqB,YAAY,GAAG,GAGrD;GAEF,IAAI,oBAAoB,CAAC,wBAAwB,KAAK,gBAAgB,GACpE;GAEF,IAAI,mBAAmB,CAAC,gBAAgB,GAAG,GACzC;GAEF,MAAM;EACR;CACF;;;;;CAMA,CAAC,cAA6B;EAC5B,MAAM,SAAS,KAAKX;EACpB,KAAK,MAAM,CAAC,KAAK,QAAQ,KAAKE,QAC5B,IAAI,QAAQ,KAAA,GACV,MAAM;OACD,IAAI,WAAW,KAAA,GACpB,MAAM,GAAE,SAAS,IAAY;OACxB;GACL,MAAM,SAAS,KAAK,MAAM,GAAa;GACvC,MAAM,OAAO,YACX,KAAKH,YAAY,KAAK,GAAG,MAAM,CAAC,GAAG,OAAO,EAAE,CAAC,CAC/C;EACF;CAEJ;;CAGA,CAAC,WAA0B;EACzB,KAAK,MAAM,OAAO,KAAKG,OAAO,OAAO,GACnC,IAAI,QAAQ,KAAA,GACV,MAAM;CAGZ;;CAGA,QAAc;EACZ,KAAKA,OAAO,MAAM;EAClB,KAAKD,gBAAgB,MAAM;EAC3B,KAAKE,SAAS,MAAM;EACpB,KAAKG,aAAa;EAClB,KAAKF,YAAY;EACjB,KAAKC,cAAc;CACrB;AACF;AAIA,SAAS,cAAc,OAAsB;CAC3C,IAAI,OAAO,UAAU,UACnB,OAAO,KAAK,MAAM,SAAS;CAE7B,IAAI,UAAU,QAAQ,OAAO,UAAU,UACrC,OAAO;CAET,IAAI,QAAQ;CACZ,IAAI,MAAM,QAAQ,KAAK,GACrB,KAAK,MAAM,QAAQ,OACjB,SAAS,IAAI,cAAc,IAAI;MAGjC,KAAK,MAAM,OAAO,OAChB,SAAS,KAAK,cAAc,GAAG,IAAI,cAAe,MAAc,IAAI;CAGxE,OAAO;AACT;AAEA,IAAa,iBAAiB,OAAO,gBAAgB;AAErD,SAAS,wBACP,KACA,kBACS;CACT,KAAK,MAAM,MAAM,kBAAkB;EACjC,IAAI,GAAG,WAAW,GAChB;EAEF,IAAI,MAAM;EACV,KAAK,MAAM,KAAK,IACd,IAAI,qBAAqB,GAAG,GAAG,GAAG;GAChC,MAAM;GACN;EACF;EAEF,IAAI,CAAC,KACH,OAAO;CAEX;CACA,OAAO;AACT;;;;;;;;;;AAWA,UAAiB,yBACf,UACA,WACA,OACA,SACuB;CACvB,MAAM,YAAY,UAAU,OAAO,UAAU;CAC7C,IAAI,UAAU,UAAU,KAAK;CAC7B,IAAI;EACF,KAAK,MAAM,OAAO,UAAU;GAC1B,OAAO,CAAC,QAAQ,QAAQ,QAAQ,QAAQ,OAAO,GAAG,IAAI,GAAG;IACvD,MAAM,QAAQ;IACd,UAAU,UAAU,KAAK;GAC3B;GACA,IAAI,CAAC,MAAM,UAAU,GAAG,GACtB,MAAM;EAEV;EACA,OAAO,CAAC,QAAQ,MAAM;GACpB,MAAM,QAAQ;GACd,UAAU,UAAU,KAAK;EAC3B;CACF,UAAU;EAGR,IAAI,CAAC,QAAQ,MACX,UAAU,SAAS,KAAA,CAAS;CAEhC;AACF;;;;;AAMA,UAAiB,kCACf,UACA,WACA,OACuB;CACvB,KAAK,MAAM,OAAO,UAChB,IAAI,CAAC,MAAM,UAAU,GAAG,GACtB,MAAM;CAGV,OAAO;AACT"}
@@ -1 +1 @@
1
- {"version":3,"file":"query-delegate.d.ts","sourceRoot":"","sources":["../../../../zqlite/src/query-delegate.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAC,UAAU,EAAC,MAAM,kBAAkB,CAAC;AACjD,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,+BAA+B,CAAC;AAE7D,OAAO,KAAK,EAAC,MAAM,EAAC,MAAM,gCAAgC,CAAC;AAC3D,OAAO,KAAK,EAAC,MAAM,EAAC,MAAM,6BAA6B,CAAC;AACxD,OAAO,EAAC,iBAAiB,EAAC,MAAM,4CAA4C,CAAC;AAC7E,OAAO,KAAK,EAAC,cAAc,EAAC,MAAM,uCAAuC,CAAC;AAC1E,OAAO,KAAK,EAAC,QAAQ,EAAC,MAAM,SAAS,CAAC;AAGtC,qBAAa,iBAAkB,SAAQ,iBAAiB;;IAMtD,QAAQ,CAAC,oBAAoB,QAAQ;IAGrC,YACE,EAAE,EAAE,UAAU,EACd,EAAE,EAAE,QAAQ,EACZ,MAAM,EAAE,MAAM,EACd,SAAS,CAAC,EAAE,SAAS,EAatB;IAED,SAAS,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAYnC;IAED,mBAAmB,CAAC,EAAE,EAAE,cAAc,cAKrC;IACQ,gBAAgB,CAAC,CAAC,EAAE,gBAAgB,EAAE,MAAM,CAAC,GAAG,CAAC,CAMzD;CACF"}
1
+ {"version":3,"file":"query-delegate.d.ts","sourceRoot":"","sources":["../../../../zqlite/src/query-delegate.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAC,UAAU,EAAC,MAAM,kBAAkB,CAAC;AACjD,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,+BAA+B,CAAC;AAE7D,OAAO,KAAK,EAAC,MAAM,EAAC,MAAM,gCAAgC,CAAC;AAC3D,OAAO,KAAK,EAAC,MAAM,EAAC,MAAM,6BAA6B,CAAC;AACxD,OAAO,EAAC,iBAAiB,EAAC,MAAM,4CAA4C,CAAC;AAC7E,OAAO,KAAK,EAAC,cAAc,EAAC,MAAM,uCAAuC,CAAC;AAC1E,OAAO,KAAK,EAAC,QAAQ,EAAC,MAAM,SAAS,CAAC;AAGtC,qBAAa,iBAAkB,SAAQ,iBAAiB;;IAMtD,QAAQ,CAAC,oBAAoB,QAAQ;IAGrC,YACE,EAAE,EAAE,UAAU,EACd,EAAE,EAAE,QAAQ,EACZ,MAAM,EAAE,MAAM,EACd,SAAS,CAAC,EAAE,SAAS,EAiBtB;IAED,SAAS,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAYnC;IAED,mBAAmB,CAAC,EAAE,EAAE,cAAc,cAKrC;IACQ,gBAAgB,CAAC,CAAC,EAAE,gBAAgB,EAAE,MAAM,CAAC,GAAG,CAAC,CAMzD;CACF"}
@@ -20,7 +20,11 @@ var QueryDelegateImpl = class extends QueryDelegateBase {
20
20
  ivmSampling: 0,
21
21
  level: "info",
22
22
  slowHydrateThreshold: 0,
23
- slowRowThreshold: 0
23
+ slowAdvanceThreshold: 0,
24
+ queryStatsIntervalSeconds: 0,
25
+ slowRowThreshold: 0,
26
+ planWarningRowThreshold: 0,
27
+ planWarningCostThreshold: 0
24
28
  };
25
29
  }
26
30
  getSource(tableName) {
@@ -1 +1 @@
1
- {"version":3,"file":"query-delegate.js","names":["#lc","#db","#schema","#sources","#logConfig","#commitObservers"],"sources":["../../../../zqlite/src/query-delegate.ts"],"sourcesContent":["import type {LogContext} from '@rocicorp/logger';\nimport type {LogConfig} from '../../otel/src/log-options.ts';\nimport {getOrInsertComputed} from '../../shared/src/map.ts';\nimport type {Schema} from '../../zero-types/src/schema.ts';\nimport type {Source} from '../../zql/src/ivm/source.ts';\nimport {QueryDelegateBase} from '../../zql/src/query/query-delegate-base.ts';\nimport type {CommitListener} from '../../zql/src/query/query-delegate.ts';\nimport type {Database} from './db.ts';\nimport {TableSource} from './table-source.ts';\n\nexport class QueryDelegateImpl extends QueryDelegateBase {\n readonly #lc: LogContext;\n readonly #db: Database;\n readonly #schema: Schema;\n readonly #sources: Map<string, Source> = new Map();\n readonly #logConfig: LogConfig;\n readonly defaultQueryComplete = true;\n readonly #commitObservers = new Set<() => void>();\n\n constructor(\n lc: LogContext,\n db: Database,\n schema: Schema,\n logConfig?: LogConfig,\n ) {\n super();\n this.#lc = lc.withContext('class', 'QueryDelegateImpl');\n this.#db = db;\n this.#schema = schema;\n this.#logConfig = logConfig ?? {\n format: 'text',\n ivmSampling: 0,\n level: 'info',\n slowHydrateThreshold: 0,\n slowRowThreshold: 0,\n };\n }\n\n getSource(tableName: string): Source {\n return getOrInsertComputed(this.#sources, tableName, tableName => {\n const tableSchema = this.#schema.tables[tableName];\n return new TableSource(\n this.#lc,\n this.#logConfig,\n this.#db,\n tableName,\n tableSchema.columns,\n tableSchema.primaryKey,\n );\n });\n }\n\n onTransactionCommit(cb: CommitListener) {\n this.#commitObservers.add(cb);\n return () => {\n this.#commitObservers.delete(cb);\n };\n }\n override batchViewUpdates<T>(applyViewUpdates: () => T): T {\n const ret = applyViewUpdates();\n for (const observer of this.#commitObservers) {\n observer();\n }\n return ret;\n }\n}\n"],"mappings":";;;;AAUA,IAAa,oBAAb,cAAuC,kBAAkB;CACvD;CACA;CACA;CACA,2BAAyC,IAAI,IAAI;CACjD;CACA,uBAAgC;CAChC,mCAA4B,IAAI,IAAgB;CAEhD,YACE,IACA,IACA,QACA,WACA;EACA,MAAM;EACN,KAAKA,MAAM,GAAG,YAAY,SAAS,mBAAmB;EACtD,KAAKC,MAAM;EACX,KAAKC,UAAU;EACf,KAAKE,aAAa,aAAa;GAC7B,QAAQ;GACR,aAAa;GACb,OAAO;GACP,sBAAsB;GACtB,kBAAkB;EACpB;CACF;CAEA,UAAU,WAA2B;EACnC,OAAO,oBAAoB,KAAKD,UAAU,YAAW,cAAa;GAChE,MAAM,cAAc,KAAKD,QAAQ,OAAO;GACxC,OAAO,IAAI,YACT,KAAKF,KACL,KAAKI,YACL,KAAKH,KACL,WACA,YAAY,SACZ,YAAY,UACd;EACF,CAAC;CACH;CAEA,oBAAoB,IAAoB;EACtC,KAAKI,iBAAiB,IAAI,EAAE;EAC5B,aAAa;GACX,KAAKA,iBAAiB,OAAO,EAAE;EACjC;CACF;CACA,iBAA6B,kBAA8B;EACzD,MAAM,MAAM,iBAAiB;EAC7B,KAAK,MAAM,YAAY,KAAKA,kBAC1B,SAAS;EAEX,OAAO;CACT;AACF"}
1
+ {"version":3,"file":"query-delegate.js","names":["#lc","#db","#schema","#sources","#logConfig","#commitObservers"],"sources":["../../../../zqlite/src/query-delegate.ts"],"sourcesContent":["import type {LogContext} from '@rocicorp/logger';\nimport type {LogConfig} from '../../otel/src/log-options.ts';\nimport {getOrInsertComputed} from '../../shared/src/map.ts';\nimport type {Schema} from '../../zero-types/src/schema.ts';\nimport type {Source} from '../../zql/src/ivm/source.ts';\nimport {QueryDelegateBase} from '../../zql/src/query/query-delegate-base.ts';\nimport type {CommitListener} from '../../zql/src/query/query-delegate.ts';\nimport type {Database} from './db.ts';\nimport {TableSource} from './table-source.ts';\n\nexport class QueryDelegateImpl extends QueryDelegateBase {\n readonly #lc: LogContext;\n readonly #db: Database;\n readonly #schema: Schema;\n readonly #sources: Map<string, Source> = new Map();\n readonly #logConfig: LogConfig;\n readonly defaultQueryComplete = true;\n readonly #commitObservers = new Set<() => void>();\n\n constructor(\n lc: LogContext,\n db: Database,\n schema: Schema,\n logConfig?: LogConfig,\n ) {\n super();\n this.#lc = lc.withContext('class', 'QueryDelegateImpl');\n this.#db = db;\n this.#schema = schema;\n this.#logConfig = logConfig ?? {\n format: 'text',\n ivmSampling: 0,\n level: 'info',\n slowHydrateThreshold: 0,\n slowAdvanceThreshold: 0,\n queryStatsIntervalSeconds: 0,\n slowRowThreshold: 0,\n planWarningRowThreshold: 0,\n planWarningCostThreshold: 0,\n };\n }\n\n getSource(tableName: string): Source {\n return getOrInsertComputed(this.#sources, tableName, tableName => {\n const tableSchema = this.#schema.tables[tableName];\n return new TableSource(\n this.#lc,\n this.#logConfig,\n this.#db,\n tableName,\n tableSchema.columns,\n tableSchema.primaryKey,\n );\n });\n }\n\n onTransactionCommit(cb: CommitListener) {\n this.#commitObservers.add(cb);\n return () => {\n this.#commitObservers.delete(cb);\n };\n }\n override batchViewUpdates<T>(applyViewUpdates: () => T): T {\n const ret = applyViewUpdates();\n for (const observer of this.#commitObservers) {\n observer();\n }\n return ret;\n }\n}\n"],"mappings":";;;;AAUA,IAAa,oBAAb,cAAuC,kBAAkB;CACvD;CACA;CACA;CACA,2BAAyC,IAAI,IAAI;CACjD;CACA,uBAAgC;CAChC,mCAA4B,IAAI,IAAgB;CAEhD,YACE,IACA,IACA,QACA,WACA;EACA,MAAM;EACN,KAAKA,MAAM,GAAG,YAAY,SAAS,mBAAmB;EACtD,KAAKC,MAAM;EACX,KAAKC,UAAU;EACf,KAAKE,aAAa,aAAa;GAC7B,QAAQ;GACR,aAAa;GACb,OAAO;GACP,sBAAsB;GACtB,sBAAsB;GACtB,2BAA2B;GAC3B,kBAAkB;GAClB,yBAAyB;GACzB,0BAA0B;EAC5B;CACF;CAEA,UAAU,WAA2B;EACnC,OAAO,oBAAoB,KAAKD,UAAU,YAAW,cAAa;GAChE,MAAM,cAAc,KAAKD,QAAQ,OAAO;GACxC,OAAO,IAAI,YACT,KAAKF,KACL,KAAKI,YACL,KAAKH,KACL,WACA,YAAY,SACZ,YAAY,UACd;EACF,CAAC;CACH;CAEA,oBAAoB,IAAoB;EACtC,KAAKI,iBAAiB,IAAI,EAAE;EAC5B,aAAa;GACX,KAAKA,iBAAiB,OAAO,EAAE;EACjC;CACF;CACA,iBAA6B,kBAA8B;EACzD,MAAM,MAAM,iBAAiB;EAC7B,KAAK,MAAM,YAAY,KAAKA,kBAC1B,SAAS;EAEX,OAAO;CACT;AACF"}
@@ -1 +1 @@
1
- {"version":3,"file":"sqlite-cost-model.d.ts","sourceRoot":"","sources":["../../../../zqlite/src/sqlite-cost-model.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAC,WAAW,EAAC,MAAM,sCAAsC,CAAC;AAEtE,OAAO,KAAK,EACV,mBAAmB,EAEpB,MAAM,6CAA6C,CAAC;AAErD,OAAO,KAAK,EAAC,QAAQ,EAAY,MAAM,SAAS,CAAC;AAmBjD;;;;;;;;GAQG;AACH,wBAAgB,qBAAqB,CACnC,EAAE,EAAE,QAAQ,EACZ,UAAU,EAAE,GAAG,CAAC,MAAM,EAAE;IAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,WAAW,CAAC,CAAA;CAAC,CAAC,GAC9D,mBAAmB,CA+DrB;AAmFD,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAK9C"}
1
+ {"version":3,"file":"sqlite-cost-model.d.ts","sourceRoot":"","sources":["../../../../zqlite/src/sqlite-cost-model.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAC,WAAW,EAAC,MAAM,sCAAsC,CAAC;AAEtE,OAAO,KAAK,EAEV,mBAAmB,EAEpB,MAAM,6CAA6C,CAAC;AAErD,OAAO,KAAK,EAAC,QAAQ,EAAY,MAAM,SAAS,CAAC;AAmBjD;;;;;;;;GAQG;AACH,wBAAgB,qBAAqB,CACnC,EAAE,EAAE,QAAQ,EACZ,UAAU,EAAE,GAAG,CAAC,MAAM,EAAE;IAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,WAAW,CAAC,CAAA;CAAC,CAAC,GAC9D,mBAAmB,CAgErB;AAqJD,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAK9C"}
@@ -17,13 +17,64 @@ import SQLite3Database from "@rocicorp/zero-sqlite3";
17
17
  */
18
18
  function createSQLiteCostModel(db, tableSpecs) {
19
19
  const fanoutEstimator = new SQLiteStatFanout(db);
20
+ const tableRows = tableRowsReader(db);
20
21
  return (tableName, sort, filters, constraint) => {
21
22
  const noSubqueryFilters = transformFilters(filters).filters;
22
23
  const { zqlSpec } = must(tableSpecs.get(tableName));
23
24
  const sql = compileInline(buildSelectQuery(tableName, zqlSpec, constraint, noSubqueryFilters, sort, void 0, void 0));
24
25
  const loops = getScanstatusLoops(db.prepare(sql));
25
26
  assert(loops.length > 0, `Expected scanstatus to return at least one loop for query: ${sql}`);
26
- return estimateCost(loops, (columns) => fanoutEstimator.getFanout(tableName, columns));
27
+ return {
28
+ ...estimateCost(loops, (columns) => fanoutEstimator.getFanout(tableName, columns)),
29
+ plan: accessPlan(loops, tableRows(tableName))
30
+ };
31
+ };
32
+ }
33
+ /**
34
+ * Returns a function that reads the number of rows in a table from
35
+ * `sqlite_stat1`, or `undefined` if the table has not been analyzed.
36
+ *
37
+ * The counts are cached: they only feed plan warnings, for which the count
38
+ * as of the first plan is close enough.
39
+ */
40
+ function tableRowsReader(db) {
41
+ let stmt;
42
+ const read = (table) => {
43
+ try {
44
+ stmt ??= db.prepare("SELECT stat FROM sqlite_stat1 WHERE tbl = ? LIMIT 1");
45
+ const row = stmt.get(table);
46
+ const rows = row ? parseInt(row.stat, 10) : NaN;
47
+ return Number.isNaN(rows) ? void 0 : rows;
48
+ } catch {
49
+ return;
50
+ }
51
+ };
52
+ const cache = /* @__PURE__ */ new Map();
53
+ return (table) => {
54
+ if (!cache.has(table)) cache.set(table, read(table));
55
+ return cache.get(table);
56
+ };
57
+ }
58
+ /**
59
+ * Describes how SQLite reads the rows, from the EXPLAIN text of the
60
+ * statement's top-level loops, e.g.:
61
+ *
62
+ * - `SEARCH issue USING INDEX issue_project (projectID=?)`
63
+ * - `SCAN comment USING INDEX sqlite_autoindex_comment_1` (every row, in the
64
+ * order of the index)
65
+ * - `SCAN issue` (every row, in table order)
66
+ * - `USE TEMP B-TREE FOR ORDER BY` (a full sort)
67
+ * - `USE TEMP B-TREE FOR LAST TERM OF ORDER BY` or `... RIGHT PART OF ORDER
68
+ * BY` (sorting runs of rows that tie on the index)
69
+ */
70
+ function accessPlan(loops, tableRows) {
71
+ const [first, ...rest] = loops.filter((loop) => loop.parentId === 0);
72
+ let sort = "none";
73
+ for (const { explain } of rest) if (explain.startsWith("USE TEMP B-TREE FOR") && explain.includes("ORDER BY")) sort = explain === "USE TEMP B-TREE FOR ORDER BY" ? "full" : "partial";
74
+ return {
75
+ access: first?.explain.startsWith("SCAN ") ? "scan" : "search",
76
+ sort,
77
+ tableRows
27
78
  };
28
79
  }
29
80
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"sqlite-cost-model.js","names":[],"sources":["../../../../zqlite/src/sqlite-cost-model.ts"],"sourcesContent":["import SQLite3Database from '@rocicorp/zero-sqlite3';\nimport {assert} from '../../shared/src/asserts.ts';\nimport {must} from '../../shared/src/must.ts';\nimport type {Condition, Ordering} from '../../zero-protocol/src/ast.ts';\nimport type {SchemaValue} from '../../zero-types/src/schema-value.ts';\nimport {transformFilters} from '../../zql/src/builder/filter.ts';\nimport type {\n ConnectionCostModel,\n CostModelCost,\n} from '../../zql/src/planner/planner-connection.ts';\nimport type {PlannerConstraint} from '../../zql/src/planner/planner-constraint.ts';\nimport type {Database, Statement} from './db.ts';\nimport {compileInline} from './internal/sql-inline.ts';\nimport {buildSelectQuery} from './query-builder.ts';\nimport {SQLiteStatFanout} from './sqlite-stat-fanout.ts';\n\n/**\n * Loop information returned by SQLite's scanstatus API.\n */\ninterface ScanstatusLoop {\n /** Unique identifier for this loop */\n selectId: number;\n /** Parent loop ID, or 0 for root loops */\n parentId: number;\n /** Estimated rows emitted per turn of parent loop */\n est: number;\n /** EXPLAIN text for this loop to determine: b-tree vs list subquery */\n explain: string;\n}\n\n/**\n * Creates a SQLite-based cost model for query planning.\n * Uses SQLite's scanstatus API to estimate query costs based on the actual\n * SQLite query planner's analysis.\n *\n * @param db Database instance for preparing statements\n * @param tableSpecs Map of table names to their table specs with ZQL schemas\n * @returns ConnectionCostModel function for use with the planner\n */\nexport function createSQLiteCostModel(\n db: Database,\n tableSpecs: Map<string, {zqlSpec: Record<string, SchemaValue>}>,\n): ConnectionCostModel {\n const fanoutEstimator = new SQLiteStatFanout(db);\n return (\n tableName: string,\n sort: Ordering,\n filters: Condition | undefined,\n constraint: PlannerConstraint | undefined,\n ): CostModelCost => {\n // Transform filters to remove correlated subqueries.\n //\n // We use `transformFilters` (the same function the runtime uses to\n // decide what to push to the source at connection time). It returns:\n // - For AND: the non-CSQ conjuncts (a *superset* of the original).\n // - For OR with any CSQ branch: `undefined` (the OR-with-CSQ is not\n // pushable, and matches a strict superset of any single branch).\n //\n // This matches what the runtime actually applies at the source. A\n // previous implementation here had wrong OR semantics — it dropped the\n // CSQ branch and returned the simple branch alone, which is a strict\n // *subset* of the original OR's match set, causing the cost model to\n // under-estimate scan rows for OR-with-CSQ shapes and biasing the\n // planner toward semi-join plans. See the comment in\n // `packages/zql/src/builder/filter.ts:transformFilters`.\n const noSubqueryFilters = transformFilters(filters).filters;\n\n // Build the SQL query using the same logic as actual queries\n const {zqlSpec} = must(tableSpecs.get(tableName));\n\n const query = buildSelectQuery(\n tableName,\n zqlSpec,\n constraint,\n noSubqueryFilters,\n sort,\n undefined, // reverse is undefined here\n undefined, // start is undefined here\n );\n\n // Use compileInline to inline actual values into the SQL for cost estimation.\n // This allows SQLite's query planner to see real values and make better decisions\n // about index usage and query plans. This is safe here because it's only used for\n // cost estimation, not for executing user-facing queries (which use parameterized\n // queries via the standard compile() function).\n const sql = compileInline(query);\n\n // Prepare statement to get scanstatus information\n const stmt = db.prepare(sql);\n\n // Get scanstatus loops from the prepared statement\n const loops = getScanstatusLoops(stmt);\n\n // Scanstatus should always be available - if we get no loops, something is wrong\n assert(\n loops.length > 0,\n `Expected scanstatus to return at least one loop for query: ${sql}`,\n );\n\n const ret = estimateCost(loops, (columns: string[]) =>\n fanoutEstimator.getFanout(tableName, columns),\n );\n\n return ret;\n };\n}\n\n/**\n * Gets scanstatus loop information from a prepared statement.\n * Iterates through all query elements and extracts loop statistics.\n *\n * Uses SQLITE_SCANSTAT_COMPLEX flag (1) to get all loops including sorting operations.\n *\n * @param stmt Prepared statement to get scanstatus from\n * @returns Array of loop information, or empty array if scanstatus unavailable\n */\nfunction getScanstatusLoops(stmt: Statement): ScanstatusLoop[] {\n const loops: ScanstatusLoop[] = [];\n\n // Iterate through query elements by incrementing idx until we get undefined\n // which indicates we've reached the end\n for (let idx = 0; ; idx++) {\n const selectId = stmt.scanStatus(\n idx,\n SQLite3Database.SQLITE_SCANSTAT_SELECTID,\n 1,\n );\n\n if (selectId === undefined) {\n break;\n }\n\n loops.push({\n selectId: must(selectId),\n parentId: must(\n stmt.scanStatus(idx, SQLite3Database.SQLITE_SCANSTAT_PARENTID, 1),\n ),\n explain: must(\n stmt.scanStatus(idx, SQLite3Database.SQLITE_SCANSTAT_EXPLAIN, 1),\n ),\n est: must(stmt.scanStatus(idx, SQLite3Database.SQLITE_SCANSTAT_EST, 1)),\n });\n }\n\n return loops.sort((a, b) => a.selectId - b.selectId);\n}\n\n/**\n * Estimates the cost of a query based on scanstats from sqlite3_stmt_scanstatus_v2\n */\nfunction estimateCost(\n scanstats: ScanstatusLoop[],\n fanout: CostModelCost['fanout'],\n): CostModelCost {\n // Sort by selectId to process in execution order\n const sorted = scanstats.toSorted((a, b) => a.selectId - b.selectId);\n\n let totalRows = 0;\n let totalCost = 0;\n\n // Identify if there are multiple top-level (parentId=0) operations\n // If so, the first is typically the scan, and subsequent ones are sorts\n const topLevelOps = sorted.filter(s => s.parentId === 0);\n\n // We only consider top level ops since ZQL queries are single-table when hitting SQLite.\n // We do have a nested op in the case of `WHERE x IN (:arg)` but it is negligible\n // assuming :arg is small.\n let firstLoop = true;\n for (const op of topLevelOps) {\n if (firstLoop) {\n // First top-level op is the main scan\n // and determines the total number of rows output.\n totalRows = op.est;\n firstLoop = false;\n } else {\n if (op.explain.includes('ORDER BY')) {\n totalCost += btreeCost(totalRows);\n }\n }\n }\n\n return {\n rows: totalRows,\n startupCost: totalCost,\n fanout,\n };\n}\n\nexport function btreeCost(rows: number): number {\n // B-Tree construction is ~O(n log n) so we estimate the cost as such.\n // We divide the cost by 10 because sorting in SQLite is ~10x faster\n // than bringing the data into JS and sorting there.\n return (rows * Math.log2(rows)) / 10;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;AAuCA,SAAgB,sBACd,IACA,YACqB;CACrB,MAAM,kBAAkB,IAAI,iBAAiB,EAAE;CAC/C,QACE,WACA,MACA,SACA,eACkB;EAgBlB,MAAM,oBAAoB,iBAAiB,OAAO,EAAE;EAGpD,MAAM,EAAC,YAAW,KAAK,WAAW,IAAI,SAAS,CAAC;EAiBhD,MAAM,MAAM,cAfE,iBACZ,WACA,SACA,YACA,mBACA,MACA,KAAA,GACA,KAAA,CAQwB,CAAK;EAM/B,MAAM,QAAQ,mBAHD,GAAG,QAAQ,GAGS,CAAI;EAGrC,OACE,MAAM,SAAS,GACf,8DAA8D,KAChE;EAMA,OAJY,aAAa,QAAQ,YAC/B,gBAAgB,UAAU,WAAW,OAAO,CAGvC;CACT;AACF;;;;;;;;;;AAWA,SAAS,mBAAmB,MAAmC;CAC7D,MAAM,QAA0B,CAAC;CAIjC,KAAK,IAAI,MAAM,IAAK,OAAO;EACzB,MAAM,WAAW,KAAK,WACpB,KACA,gBAAgB,0BAChB,CACF;EAEA,IAAI,aAAa,KAAA,GACf;EAGF,MAAM,KAAK;GACT,UAAU,KAAK,QAAQ;GACvB,UAAU,KACR,KAAK,WAAW,KAAK,gBAAgB,0BAA0B,CAAC,CAClE;GACA,SAAS,KACP,KAAK,WAAW,KAAK,gBAAgB,yBAAyB,CAAC,CACjE;GACA,KAAK,KAAK,KAAK,WAAW,KAAK,gBAAgB,qBAAqB,CAAC,CAAC;EACxE,CAAC;CACH;CAEA,OAAO,MAAM,MAAM,GAAG,MAAM,EAAE,WAAW,EAAE,QAAQ;AACrD;;;;AAKA,SAAS,aACP,WACA,QACe;CAEf,MAAM,SAAS,UAAU,UAAU,GAAG,MAAM,EAAE,WAAW,EAAE,QAAQ;CAEnE,IAAI,YAAY;CAChB,IAAI,YAAY;CAIhB,MAAM,cAAc,OAAO,QAAO,MAAK,EAAE,aAAa,CAAC;CAKvD,IAAI,YAAY;CAChB,KAAK,MAAM,MAAM,aACf,IAAI,WAAW;EAGb,YAAY,GAAG;EACf,YAAY;CACd,OACE,IAAI,GAAG,QAAQ,SAAS,UAAU,GAChC,aAAa,UAAU,SAAS;CAKtC,OAAO;EACL,MAAM;EACN,aAAa;EACb;CACF;AACF;AAEA,SAAgB,UAAU,MAAsB;CAI9C,OAAQ,OAAO,KAAK,KAAK,IAAI,IAAK;AACpC"}
1
+ {"version":3,"file":"sqlite-cost-model.js","names":[],"sources":["../../../../zqlite/src/sqlite-cost-model.ts"],"sourcesContent":["import SQLite3Database from '@rocicorp/zero-sqlite3';\nimport {assert} from '../../shared/src/asserts.ts';\nimport {must} from '../../shared/src/must.ts';\nimport type {Condition, Ordering} from '../../zero-protocol/src/ast.ts';\nimport type {SchemaValue} from '../../zero-types/src/schema-value.ts';\nimport {transformFilters} from '../../zql/src/builder/filter.ts';\nimport type {\n AccessPlan,\n ConnectionCostModel,\n CostModelCost,\n} from '../../zql/src/planner/planner-connection.ts';\nimport type {PlannerConstraint} from '../../zql/src/planner/planner-constraint.ts';\nimport type {Database, Statement} from './db.ts';\nimport {compileInline} from './internal/sql-inline.ts';\nimport {buildSelectQuery} from './query-builder.ts';\nimport {SQLiteStatFanout} from './sqlite-stat-fanout.ts';\n\n/**\n * Loop information returned by SQLite's scanstatus API.\n */\ninterface ScanstatusLoop {\n /** Unique identifier for this loop */\n selectId: number;\n /** Parent loop ID, or 0 for root loops */\n parentId: number;\n /** Estimated rows emitted per turn of parent loop */\n est: number;\n /** EXPLAIN text for this loop to determine: b-tree vs list subquery */\n explain: string;\n}\n\n/**\n * Creates a SQLite-based cost model for query planning.\n * Uses SQLite's scanstatus API to estimate query costs based on the actual\n * SQLite query planner's analysis.\n *\n * @param db Database instance for preparing statements\n * @param tableSpecs Map of table names to their table specs with ZQL schemas\n * @returns ConnectionCostModel function for use with the planner\n */\nexport function createSQLiteCostModel(\n db: Database,\n tableSpecs: Map<string, {zqlSpec: Record<string, SchemaValue>}>,\n): ConnectionCostModel {\n const fanoutEstimator = new SQLiteStatFanout(db);\n const tableRows = tableRowsReader(db);\n return (\n tableName: string,\n sort: Ordering,\n filters: Condition | undefined,\n constraint: PlannerConstraint | undefined,\n ): CostModelCost => {\n // Transform filters to remove correlated subqueries.\n //\n // We use `transformFilters` (the same function the runtime uses to\n // decide what to push to the source at connection time). It returns:\n // - For AND: the non-CSQ conjuncts (a *superset* of the original).\n // - For OR with any CSQ branch: `undefined` (the OR-with-CSQ is not\n // pushable, and matches a strict superset of any single branch).\n //\n // This matches what the runtime actually applies at the source. A\n // previous implementation here had wrong OR semantics — it dropped the\n // CSQ branch and returned the simple branch alone, which is a strict\n // *subset* of the original OR's match set, causing the cost model to\n // under-estimate scan rows for OR-with-CSQ shapes and biasing the\n // planner toward semi-join plans. See the comment in\n // `packages/zql/src/builder/filter.ts:transformFilters`.\n const noSubqueryFilters = transformFilters(filters).filters;\n\n // Build the SQL query using the same logic as actual queries\n const {zqlSpec} = must(tableSpecs.get(tableName));\n\n const query = buildSelectQuery(\n tableName,\n zqlSpec,\n constraint,\n noSubqueryFilters,\n sort,\n undefined, // reverse is undefined here\n undefined, // start is undefined here\n );\n\n // Use compileInline to inline actual values into the SQL for cost estimation.\n // This allows SQLite's query planner to see real values and make better decisions\n // about index usage and query plans. This is safe here because it's only used for\n // cost estimation, not for executing user-facing queries (which use parameterized\n // queries via the standard compile() function).\n const sql = compileInline(query);\n\n // Prepare statement to get scanstatus information\n const stmt = db.prepare(sql);\n\n // Get scanstatus loops from the prepared statement\n const loops = getScanstatusLoops(stmt);\n\n // Scanstatus should always be available - if we get no loops, something is wrong\n assert(\n loops.length > 0,\n `Expected scanstatus to return at least one loop for query: ${sql}`,\n );\n\n const ret = estimateCost(loops, (columns: string[]) =>\n fanoutEstimator.getFanout(tableName, columns),\n );\n\n return {...ret, plan: accessPlan(loops, tableRows(tableName))};\n };\n}\n\n/**\n * Returns a function that reads the number of rows in a table from\n * `sqlite_stat1`, or `undefined` if the table has not been analyzed.\n *\n * The counts are cached: they only feed plan warnings, for which the count\n * as of the first plan is close enough.\n */\nfunction tableRowsReader(db: Database): (table: string) => number | undefined {\n let stmt: Statement | undefined;\n const read = (table: string): number | undefined => {\n try {\n stmt ??= db.prepare(\n 'SELECT stat FROM sqlite_stat1 WHERE tbl = ? LIMIT 1',\n );\n // Every row for a table starts with the number of rows in the table.\n const row = stmt.get<{stat: string} | undefined>(table);\n const rows = row ? parseInt(row.stat, 10) : NaN;\n return Number.isNaN(rows) ? undefined : rows;\n } catch {\n // sqlite_stat1 does not exist until the database is analyzed.\n return undefined;\n }\n };\n // Tables without statistics are cached too, as `undefined`.\n const cache = new Map<string, number | undefined>();\n return table => {\n if (!cache.has(table)) {\n cache.set(table, read(table));\n }\n return cache.get(table);\n };\n}\n\n/**\n * Describes how SQLite reads the rows, from the EXPLAIN text of the\n * statement's top-level loops, e.g.:\n *\n * - `SEARCH issue USING INDEX issue_project (projectID=?)`\n * - `SCAN comment USING INDEX sqlite_autoindex_comment_1` (every row, in the\n * order of the index)\n * - `SCAN issue` (every row, in table order)\n * - `USE TEMP B-TREE FOR ORDER BY` (a full sort)\n * - `USE TEMP B-TREE FOR LAST TERM OF ORDER BY` or `... RIGHT PART OF ORDER\n * BY` (sorting runs of rows that tie on the index)\n */\nfunction accessPlan(\n loops: ScanstatusLoop[],\n tableRows: number | undefined,\n): AccessPlan {\n const [first, ...rest] = loops.filter(loop => loop.parentId === 0);\n let sort: AccessPlan['sort'] = 'none';\n for (const {explain} of rest) {\n if (\n explain.startsWith('USE TEMP B-TREE FOR') &&\n explain.includes('ORDER BY')\n ) {\n sort = explain === 'USE TEMP B-TREE FOR ORDER BY' ? 'full' : 'partial';\n }\n }\n return {\n access: first?.explain.startsWith('SCAN ') ? 'scan' : 'search',\n sort,\n tableRows,\n };\n}\n\n/**\n * Gets scanstatus loop information from a prepared statement.\n * Iterates through all query elements and extracts loop statistics.\n *\n * Uses SQLITE_SCANSTAT_COMPLEX flag (1) to get all loops including sorting operations.\n *\n * @param stmt Prepared statement to get scanstatus from\n * @returns Array of loop information, or empty array if scanstatus unavailable\n */\nfunction getScanstatusLoops(stmt: Statement): ScanstatusLoop[] {\n const loops: ScanstatusLoop[] = [];\n\n // Iterate through query elements by incrementing idx until we get undefined\n // which indicates we've reached the end\n for (let idx = 0; ; idx++) {\n const selectId = stmt.scanStatus(\n idx,\n SQLite3Database.SQLITE_SCANSTAT_SELECTID,\n 1,\n );\n\n if (selectId === undefined) {\n break;\n }\n\n loops.push({\n selectId: must(selectId),\n parentId: must(\n stmt.scanStatus(idx, SQLite3Database.SQLITE_SCANSTAT_PARENTID, 1),\n ),\n explain: must(\n stmt.scanStatus(idx, SQLite3Database.SQLITE_SCANSTAT_EXPLAIN, 1),\n ),\n est: must(stmt.scanStatus(idx, SQLite3Database.SQLITE_SCANSTAT_EST, 1)),\n });\n }\n\n return loops.sort((a, b) => a.selectId - b.selectId);\n}\n\n/**\n * Estimates the cost of a query based on scanstats from sqlite3_stmt_scanstatus_v2\n */\nfunction estimateCost(\n scanstats: ScanstatusLoop[],\n fanout: CostModelCost['fanout'],\n): CostModelCost {\n // Sort by selectId to process in execution order\n const sorted = scanstats.toSorted((a, b) => a.selectId - b.selectId);\n\n let totalRows = 0;\n let totalCost = 0;\n\n // Identify if there are multiple top-level (parentId=0) operations\n // If so, the first is typically the scan, and subsequent ones are sorts\n const topLevelOps = sorted.filter(s => s.parentId === 0);\n\n // We only consider top level ops since ZQL queries are single-table when hitting SQLite.\n // We do have a nested op in the case of `WHERE x IN (:arg)` but it is negligible\n // assuming :arg is small.\n let firstLoop = true;\n for (const op of topLevelOps) {\n if (firstLoop) {\n // First top-level op is the main scan\n // and determines the total number of rows output.\n totalRows = op.est;\n firstLoop = false;\n } else {\n if (op.explain.includes('ORDER BY')) {\n totalCost += btreeCost(totalRows);\n }\n }\n }\n\n return {\n rows: totalRows,\n startupCost: totalCost,\n fanout,\n };\n}\n\nexport function btreeCost(rows: number): number {\n // B-Tree construction is ~O(n log n) so we estimate the cost as such.\n // We divide the cost by 10 because sorting in SQLite is ~10x faster\n // than bringing the data into JS and sorting there.\n return (rows * Math.log2(rows)) / 10;\n}\n"],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"sqlite-stat-fanout.d.ts","sourceRoot":"","sources":["../../../../zqlite/src/sqlite-stat-fanout.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAC,QAAQ,EAAC,MAAM,SAAS,CAAC;AAEtC;;GAEG;AACH,MAAM,WAAW,YAAY;IAC3B;;;OAGG;IACH,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,GAAG,KAAK,GAAG,MAAM,CAAC;IAEpC;;;;;OAKG;IACH,MAAM,EAAE,OAAO,GAAG,OAAO,GAAG,SAAS,CAAC;CACvC;AAgBD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmDG;AACH,qBAAa,gBAAgB;;IAkB3B;;;;;;;;OAQG;IACH,YAAY,EAAE,EAAE,QAAQ,EAAE,aAAa,SAAI,EAyB1C;IAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OA6BG;IACH,SAAS,CAAC,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,GAAG,YAAY,CAgC5D;IAED;;;OAGG;IACH,UAAU,IAAI,IAAI,CAEjB;CAgQF"}
1
+ {"version":3,"file":"sqlite-stat-fanout.d.ts","sourceRoot":"","sources":["../../../../zqlite/src/sqlite-stat-fanout.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAC,QAAQ,EAAC,MAAM,SAAS,CAAC;AAGtC;;GAEG;AACH,MAAM,WAAW,YAAY;IAC3B;;;OAGG;IACH,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,GAAG,KAAK,GAAG,MAAM,CAAC;IAEpC;;;;;OAKG;IACH,MAAM,EAAE,OAAO,GAAG,OAAO,GAAG,SAAS,CAAC;CACvC;AAgBD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmDG;AACH,qBAAa,gBAAgB;;IAkB3B;;;;;;;;OAQG;IACH,YAAY,EAAE,EAAE,QAAQ,EAAE,aAAa,SAAI,EAyB1C;IAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OA6BG;IACH,SAAS,CAAC,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,GAAG,YAAY,CAgC5D;IAED;;;OAGG;IACH,UAAU,IAAI,IAAI,CAEjB;CA0NF"}
@@ -1,5 +1,6 @@
1
1
  import { getOrInsertComputed } from "../../shared/src/map.js";
2
2
  import { newArray } from "../../shared/src/arrays.js";
3
+ import { isSampleNull } from "./sqlite-stat4-sample.js";
3
4
  //#region ../zqlite/src/sqlite-stat-fanout.ts
4
5
  /**
5
6
  * Computes join fanout factors from SQLite statistics tables.
@@ -180,7 +181,7 @@ var SQLiteStatFanout = class {
180
181
  const neqParts = s.neq.split(" ");
181
182
  return {
182
183
  fanout: parseInt(neqParts[neqIndex] ?? neqParts[0], 10),
183
- isNull: this.#decodeSampleIsNull(s.sample)
184
+ isNull: isSampleNull(s.sample)
184
185
  };
185
186
  }).filter((s) => !s.isNull);
186
187
  if (nonNullSamples.length === 0) return {
@@ -286,31 +287,6 @@ var SQLiteStatFanout = class {
286
287
  const indexPrefixLower = new Set(indexPrefix.map((col) => col.toLowerCase()));
287
288
  return queryColumns.map((col) => col.toLowerCase()).every((queryCol) => indexPrefixLower.has(queryCol));
288
289
  }
289
- /**
290
- * Decodes a sqlite_stat4 sample value to check if it's NULL.
291
- *
292
- * SQLite record format (simplified):
293
- * - Varint: header size
294
- * - Serial types for each column (one byte each typically)
295
- * - Actual data
296
- *
297
- * Serial type 0 = NULL
298
- * Serial type 1 = 8-bit int
299
- * Serial type 2 = 16-bit int
300
- * Serial type 3 = 24-bit int
301
- * etc.
302
- *
303
- * We only need to check the first column's serial type.
304
- *
305
- * @param sample Binary-encoded sample from stat4
306
- * @returns true if the sample value is NULL
307
- */
308
- #decodeSampleIsNull(sample) {
309
- if (sample.length === 0) return true;
310
- const headerSize = sample[0];
311
- if (headerSize === 0 || headerSize >= sample.length) return true;
312
- return sample[1] === 0;
313
- }
314
290
  };
315
291
  //#endregion
316
292
  export { SQLiteStatFanout };
@@ -1 +1 @@
1
- {"version":3,"file":"sqlite-stat-fanout.js","names":["#db","#defaultFanout","#cache","#stat4Stmt","#stat1Stmt","#indexStmt","#getFanoutFromStat4","#getFanoutFromStat1","#findIndexForColumns","#decodeSampleIsNull","#isPrefixMatch"],"sources":["../../../../zqlite/src/sqlite-stat-fanout.ts"],"sourcesContent":["import {newArray} from '../../shared/src/arrays.ts';\nimport {getOrInsertComputed} from '../../shared/src/map.ts';\nimport type {Database} from './db.ts';\n\n/**\n * Result of fanout calculation from SQLite statistics.\n */\nexport interface FanoutResult {\n /**\n * The fanout value (average rows per distinct value of the join column).\n * For non-NULL joins, this represents how many child rows exist per parent key.\n */\n fanout: number;\n confidence: 'high' | 'med' | 'none';\n\n /**\n * Source of the fanout calculation.\n * - 'stat4': From sqlite_stat4 histogram (most accurate, excludes NULLs)\n * - 'stat1': From sqlite_stat1 average (includes NULLs, may overestimate)\n * - 'default': Fallback constant when statistics unavailable\n */\n source: 'stat4' | 'stat1' | 'default';\n}\n\n/**\n * Sample from sqlite_stat4 histogram.\n */\ninterface Stat4Sample {\n /** \"N1 N2\" = rows equal to sample (N1=first col, N2=if composite) */\n neq: string;\n /** \"N1 N2\" = rows less than sample */\n nlt: string;\n /** \"N1 N2\" = distinct values less than sample */\n ndlt: string;\n /** The actual sample value (binary encoded) */\n sample: Buffer;\n}\n\n/**\n * Computes join fanout factors from SQLite statistics tables.\n *\n * Fanout is the average number of child rows per distinct parent key value,\n * used to estimate join cardinality in query planning.\n *\n * ## Problem\n *\n * sqlite_stat1 includes NULL rows in its calculation, which can significantly\n * overestimate fanout for sparse foreign keys:\n *\n * ```\n * Example: 100 tasks, 20 with project_id, 80 with NULL\n * - stat1 reports: \"100 17\" → fanout = 17 (WRONG - includes NULLs)\n * - stat4 shows: NULL samples with fanout=80, non-NULL samples with fanout=4\n * - True fanout: 4 (CORRECT)\n * ```\n *\n * ## Solution\n *\n * This class uses sqlite_stat4 histogram to separate NULL and non-NULL samples,\n * providing accurate fanout for non-NULL joins.\n *\n * ## Usage\n *\n * ```typescript\n * const calculator = new SQLiteStatFanout(db);\n *\n * // Get fanout for posts.userId → users.id join\n * const result = calculator.getFanout('posts', 'userId');\n *\n * if (result.source === 'stat4') {\n * // Accurate: excludes NULLs, samples actual distribution\n * console.log(`Fanout: ${result.fanout} (from stat4)`);\n * } else if (result.source === 'stat1') {\n * // Conservative: includes NULLs, may overestimate\n * console.log(`Fanout: ${result.fanout} (from stat1, includes NULLs)`);\n * } else {\n * // Fallback: no statistics available\n * console.log(`Fanout: ${result.fanout} (default estimate)`);\n * }\n * ```\n *\n * ## Requirements\n *\n * - SQLite compiled with ENABLE_STAT4 (most builds include this)\n * - `ANALYZE` command run on the database\n * - Index exists on the join column\n *\n * @see https://sqlite.org/fileformat2.html#stat4tab\n * @see packages/zql/src/planner/SELECTIVITY_PLAN.md\n */\nexport class SQLiteStatFanout {\n readonly #db: Database;\n readonly #defaultFanout: number;\n\n /**\n * Cache of fanout results by table and columns.\n * Key format: \"tableName:col1,col2,col3\" (sorted alphabetically)\n */\n readonly #cache = new Map<string, FanoutResult>();\n\n /**\n * Prepared statements for querying SQLite statistics tables.\n * Prepared once in constructor for performance.\n */\n readonly #stat4Stmt: ReturnType<Database['prepare']>;\n readonly #stat1Stmt: ReturnType<Database['prepare']>;\n readonly #indexStmt: ReturnType<Database['prepare']>;\n\n /**\n * Creates a new fanout calculator.\n *\n * @param db Database instance\n * @param defaultFanout Default fanout when statistics unavailable (default: 3)\n * - 1: Conservative (assumes FK relationships)\n * - 3: Moderate (recommended, safe middle ground)\n * - 10: SQLite's default (optimistic)\n */\n constructor(db: Database, defaultFanout = 3) {\n this.#db = db;\n this.#defaultFanout = defaultFanout;\n\n // Prepare SQL statements once for reuse across multiple getFanout() calls\n this.#stat4Stmt = this.#db.prepare(`\n SELECT neq, nlt, ndlt, sample\n FROM sqlite_stat4\n WHERE tbl = ? AND idx = ?\n ORDER BY nlt\n `);\n\n this.#stat1Stmt = this.#db.prepare(`\n SELECT stat\n FROM sqlite_stat1\n WHERE tbl = ? AND idx = ?\n `);\n\n this.#indexStmt = this.#db.prepare(`\n SELECT il.name as index_name, ii.seqno, ii.name as column_name\n FROM pragma_index_list(?) il\n JOIN pragma_index_info(il.name) ii\n WHERE il.partial = 0\n ORDER BY il.seq, ii.seqno\n `);\n }\n\n /**\n * Gets the fanout factor for join column(s).\n *\n * Fanout = average number of child rows per distinct parent key value(s).\n *\n * ## Strategy\n *\n * 1. Try sqlite_stat4 (best): Histogram with separate NULL/non-NULL samples\n * 2. Fallback to sqlite_stat1: Average across all rows (includes NULLs)\n * 3. Fallback to default: When no statistics available\n *\n * ## Compound Indexes\n *\n * For multi-column joins, finds indexes where ALL columns appear as an\n * exact prefix. Uses the appropriate depth in stat1/stat4.\n *\n * Example:\n * - Columns: `['customerId', 'storeId']`\n * - Matches index: `(customerId, storeId, date)` at depth 2\n * - Uses stat1 parts[2] or stat4 neq[1] for accurate fanout\n *\n * ## Caching\n *\n * Results are cached per (table, columns) combination. Clear the cache if\n * you run ANALYZE to update statistics.\n *\n * @param tableName Table containing the join column(s)\n * @param columns Array of column names (one or more columns)\n * @returns Fanout result with value and source\n */\n getFanout(tableName: string, columns: string[]): FanoutResult {\n // Cache key uses sorted columns for consistency\n const cacheKey = `${tableName}:${columns.toSorted().join(',')}`;\n const cached = this.#cache.get(cacheKey);\n if (cached) {\n return cached;\n }\n\n // Strategy 1: Try stat4 first (most accurate)\n // NOTE: columns are NOT sorted - preserves Object.keys() order from constraint\n // Matching is order-independent (flexible), but we keep original order for consistency\n const stat4Result = this.#getFanoutFromStat4(tableName, columns);\n if (stat4Result) {\n this.#cache.set(cacheKey, stat4Result);\n return stat4Result;\n }\n\n // Strategy 2: Fallback to stat1 (includes NULLs)\n const stat1Result = this.#getFanoutFromStat1(tableName, columns);\n if (stat1Result) {\n this.#cache.set(cacheKey, stat1Result);\n return stat1Result;\n }\n\n // Strategy 3: Use default\n const defaultResult: FanoutResult = {\n fanout: this.#defaultFanout,\n confidence: 'none',\n source: 'default',\n };\n this.#cache.set(cacheKey, defaultResult);\n return defaultResult;\n }\n\n /**\n * Clears the fanout cache.\n * Call this after running ANALYZE to pick up updated statistics.\n */\n clearCache(): void {\n this.#cache.clear();\n }\n\n /**\n * Gets fanout from sqlite_stat4 histogram.\n *\n * Queries stat4 samples, decodes to identify NULLs, and returns\n * the median fanout of non-NULL samples.\n *\n * For compound indexes, uses the neq value at the appropriate depth.\n *\n * @param columns Array of column names to get fanout for\n * @returns Fanout result or undefined if stat4 unavailable\n */\n #getFanoutFromStat4(\n tableName: string,\n columns: string[],\n ): FanoutResult | undefined {\n try {\n // Find index containing the columns as a prefix\n const indexInfo = this.#findIndexForColumns(tableName, columns);\n if (!indexInfo) {\n return undefined;\n }\n\n // Query stat4 samples for this index (using prepared statement)\n const samples = this.#stat4Stmt.all(\n tableName,\n indexInfo.indexName,\n ) as Stat4Sample[];\n\n if (samples.length === 0) {\n return undefined;\n }\n\n // Decode samples and separate NULL from non-NULL\n // Use depth-1 for neq array index (depth is 1-based, array is 0-based)\n const neqIndex = indexInfo.depth - 1;\n const decodedSamples = samples.map(s => {\n const neqParts = s.neq.split(' ');\n return {\n fanout: parseInt(neqParts[neqIndex] ?? neqParts[0], 10),\n isNull: this.#decodeSampleIsNull(s.sample),\n };\n });\n\n const nonNullSamples = decodedSamples.filter(s => !s.isNull);\n\n if (nonNullSamples.length === 0) {\n // All samples are NULL - return fanout of 0 since NULLs don't match in joins\n return {\n fanout: 0,\n source: 'stat4',\n confidence: 'high',\n };\n }\n\n // Use median of non-NULL fanouts (more robust than average)\n const fanouts = nonNullSamples.map(s => s.fanout).sort((a, b) => a - b);\n const medianFanout =\n fanouts.length % 2 === 0\n ? Math.floor(\n (fanouts[fanouts.length / 2 - 1] + fanouts[fanouts.length / 2]) /\n 2,\n )\n : fanouts[Math.floor(fanouts.length / 2)];\n\n return {\n fanout: medianFanout,\n source: 'stat4',\n confidence: 'high',\n };\n } catch {\n // stat4 table may not exist or query may fail\n return undefined;\n }\n }\n\n /**\n * Gets fanout from sqlite_stat1 average.\n *\n * Note: This includes NULL rows in the calculation and may overestimate\n * fanout for sparse foreign keys.\n *\n * For compound indexes, uses the stat value at the appropriate depth.\n *\n * @param columns Array of column names to get fanout for\n * @returns Fanout result or undefined if stat1 unavailable\n */\n #getFanoutFromStat1(\n tableName: string,\n columns: string[],\n ): FanoutResult | undefined {\n try {\n // Find index containing the columns as a prefix\n const indexInfo = this.#findIndexForColumns(tableName, columns);\n if (!indexInfo) {\n return undefined;\n }\n\n // Query stat1 for this index (using prepared statement)\n const result = this.#stat1Stmt.get(tableName, indexInfo.indexName) as\n | {stat: string}\n | undefined;\n\n if (!result) {\n return undefined;\n }\n\n const parts = result.stat.split(' ');\n // Check if we have enough parts for the requested depth\n if (parts.length < indexInfo.depth + 1) {\n return undefined;\n }\n\n const fanout = parseInt(parts[indexInfo.depth], 10);\n if (isNaN(fanout)) {\n return undefined;\n }\n\n return {\n fanout,\n source: 'stat1',\n confidence: 'med',\n };\n } catch {\n return undefined;\n }\n }\n\n /**\n * Finds an index that can be used to get statistics for column(s).\n *\n * Uses pragma_index_list and pragma_index_info to reliably get index\n * column names, avoiding brittle SQL parsing. Includes all indices:\n * user-created (CREATE INDEX), PRIMARY KEY, and UNIQUE constraints.\n *\n * Uses flexible matching: Finds indexes where ALL columns appear in the\n * first N positions, regardless of order. This works because SQLite statistics\n * at depth N represent the fanout for the combination of the first N columns,\n * and combinations are order-independent.\n *\n * Example:\n * - columns: ['customerId', 'storeId']\n * - Matches: (customerId, storeId, date) at depth 2 ✅\n * - Matches: (storeId, customerId, date) at depth 2 ✅ (flexible order)\n * - Does NOT match: (date, customerId, storeId) ❌ (columns not in first 2 positions)\n * - Does NOT match: (customerId, date, storeId) ❌ (storeId not in first 2 positions)\n *\n * @param columns Array of column names (order-independent for matching)\n * @returns Index info with name and depth, or undefined if no match\n */\n #findIndexForColumns(\n tableName: string,\n columns: string[],\n ): {indexName: string; depth: number} | undefined {\n try {\n // Query returns all columns for all indexes (including PK/UNIQUE) in order\n const rows = this.#indexStmt.all(tableName) as {\n index_name: string;\n seqno: number;\n column_name: string;\n }[];\n\n // Group by index name\n const indexMap = new Map<string, string[]>();\n for (const row of rows) {\n getOrInsertComputed(indexMap, row.index_name, newArray).push(\n row.column_name,\n );\n }\n\n // Check each index for prefix match\n for (const [indexName, indexColumns] of indexMap) {\n if (this.#isPrefixMatch(columns, indexColumns)) {\n return {\n indexName,\n depth: columns.length,\n };\n }\n }\n\n return undefined;\n } catch {\n return undefined;\n }\n }\n\n /**\n * Checks if all queryColumns exist in the first N positions of indexColumns,\n * regardless of order.\n *\n * This allows flexible matching: constraint {a, b} matches both index (a, b, c)\n * and index (b, a, c) at depth 2, since both represent the fanout for the\n * combination of columns a and b.\n *\n * Gaps are NOT allowed: constraint {a, c} does NOT match index (a, b, c)\n * because no depth represents just (a, c) without b. Statistics are cumulative\n * from position 0.\n *\n * @param queryColumns Columns we're looking for (from constraint)\n * @param indexColumns Columns in the index (in order)\n * @returns true if all queryColumns exist in indexColumns[0...queryColumns.length-1]\n */\n #isPrefixMatch(queryColumns: string[], indexColumns: string[]): boolean {\n if (queryColumns.length > indexColumns.length) {\n return false;\n }\n\n // Get the prefix of the index that we're checking against\n const indexPrefix = indexColumns.slice(0, queryColumns.length);\n\n // Normalize to lowercase for case-insensitive comparison\n const indexPrefixLower = new Set(indexPrefix.map(col => col.toLowerCase()));\n const queryColumnsLower = queryColumns.map(col => col.toLowerCase());\n\n // Check if ALL query columns exist in the index prefix\n return queryColumnsLower.every(queryCol => indexPrefixLower.has(queryCol));\n }\n\n /**\n * Decodes a sqlite_stat4 sample value to check if it's NULL.\n *\n * SQLite record format (simplified):\n * - Varint: header size\n * - Serial types for each column (one byte each typically)\n * - Actual data\n *\n * Serial type 0 = NULL\n * Serial type 1 = 8-bit int\n * Serial type 2 = 16-bit int\n * Serial type 3 = 24-bit int\n * etc.\n *\n * We only need to check the first column's serial type.\n *\n * @param sample Binary-encoded sample from stat4\n * @returns true if the sample value is NULL\n */\n #decodeSampleIsNull(sample: Buffer): boolean {\n if (sample.length === 0) {\n return true;\n }\n\n // Read header size (varint - simplified: assume single byte)\n const headerSize = sample[0];\n\n if (headerSize === 0 || headerSize >= sample.length) {\n return true;\n }\n\n // Read first serial type (at position 1)\n const serialType = sample[1];\n\n // Serial type 0 = NULL\n return serialType === 0;\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"sqlite-stat-fanout.js","names":["#db","#defaultFanout","#cache","#stat4Stmt","#stat1Stmt","#indexStmt","#getFanoutFromStat4","#getFanoutFromStat1","#findIndexForColumns","#isPrefixMatch"],"sources":["../../../../zqlite/src/sqlite-stat-fanout.ts"],"sourcesContent":["import {newArray} from '../../shared/src/arrays.ts';\nimport {getOrInsertComputed} from '../../shared/src/map.ts';\nimport type {Database} from './db.ts';\nimport {isSampleNull} from './sqlite-stat4-sample.ts';\n\n/**\n * Result of fanout calculation from SQLite statistics.\n */\nexport interface FanoutResult {\n /**\n * The fanout value (average rows per distinct value of the join column).\n * For non-NULL joins, this represents how many child rows exist per parent key.\n */\n fanout: number;\n confidence: 'high' | 'med' | 'none';\n\n /**\n * Source of the fanout calculation.\n * - 'stat4': From sqlite_stat4 histogram (most accurate, excludes NULLs)\n * - 'stat1': From sqlite_stat1 average (includes NULLs, may overestimate)\n * - 'default': Fallback constant when statistics unavailable\n */\n source: 'stat4' | 'stat1' | 'default';\n}\n\n/**\n * Sample from sqlite_stat4 histogram.\n */\ninterface Stat4Sample {\n /** \"N1 N2\" = rows equal to sample (N1=first col, N2=if composite) */\n neq: string;\n /** \"N1 N2\" = rows less than sample */\n nlt: string;\n /** \"N1 N2\" = distinct values less than sample */\n ndlt: string;\n /** The actual sample value (binary encoded) */\n sample: Buffer;\n}\n\n/**\n * Computes join fanout factors from SQLite statistics tables.\n *\n * Fanout is the average number of child rows per distinct parent key value,\n * used to estimate join cardinality in query planning.\n *\n * ## Problem\n *\n * sqlite_stat1 includes NULL rows in its calculation, which can significantly\n * overestimate fanout for sparse foreign keys:\n *\n * ```\n * Example: 100 tasks, 20 with project_id, 80 with NULL\n * - stat1 reports: \"100 17\" → fanout = 17 (WRONG - includes NULLs)\n * - stat4 shows: NULL samples with fanout=80, non-NULL samples with fanout=4\n * - True fanout: 4 (CORRECT)\n * ```\n *\n * ## Solution\n *\n * This class uses sqlite_stat4 histogram to separate NULL and non-NULL samples,\n * providing accurate fanout for non-NULL joins.\n *\n * ## Usage\n *\n * ```typescript\n * const calculator = new SQLiteStatFanout(db);\n *\n * // Get fanout for posts.userId → users.id join\n * const result = calculator.getFanout('posts', 'userId');\n *\n * if (result.source === 'stat4') {\n * // Accurate: excludes NULLs, samples actual distribution\n * console.log(`Fanout: ${result.fanout} (from stat4)`);\n * } else if (result.source === 'stat1') {\n * // Conservative: includes NULLs, may overestimate\n * console.log(`Fanout: ${result.fanout} (from stat1, includes NULLs)`);\n * } else {\n * // Fallback: no statistics available\n * console.log(`Fanout: ${result.fanout} (default estimate)`);\n * }\n * ```\n *\n * ## Requirements\n *\n * - SQLite compiled with ENABLE_STAT4 (most builds include this)\n * - `ANALYZE` command run on the database\n * - Index exists on the join column\n *\n * @see https://sqlite.org/fileformat2.html#stat4tab\n * @see packages/zql/src/planner/SELECTIVITY_PLAN.md\n */\nexport class SQLiteStatFanout {\n readonly #db: Database;\n readonly #defaultFanout: number;\n\n /**\n * Cache of fanout results by table and columns.\n * Key format: \"tableName:col1,col2,col3\" (sorted alphabetically)\n */\n readonly #cache = new Map<string, FanoutResult>();\n\n /**\n * Prepared statements for querying SQLite statistics tables.\n * Prepared once in constructor for performance.\n */\n readonly #stat4Stmt: ReturnType<Database['prepare']>;\n readonly #stat1Stmt: ReturnType<Database['prepare']>;\n readonly #indexStmt: ReturnType<Database['prepare']>;\n\n /**\n * Creates a new fanout calculator.\n *\n * @param db Database instance\n * @param defaultFanout Default fanout when statistics unavailable (default: 3)\n * - 1: Conservative (assumes FK relationships)\n * - 3: Moderate (recommended, safe middle ground)\n * - 10: SQLite's default (optimistic)\n */\n constructor(db: Database, defaultFanout = 3) {\n this.#db = db;\n this.#defaultFanout = defaultFanout;\n\n // Prepare SQL statements once for reuse across multiple getFanout() calls\n this.#stat4Stmt = this.#db.prepare(`\n SELECT neq, nlt, ndlt, sample\n FROM sqlite_stat4\n WHERE tbl = ? AND idx = ?\n ORDER BY nlt\n `);\n\n this.#stat1Stmt = this.#db.prepare(`\n SELECT stat\n FROM sqlite_stat1\n WHERE tbl = ? AND idx = ?\n `);\n\n this.#indexStmt = this.#db.prepare(`\n SELECT il.name as index_name, ii.seqno, ii.name as column_name\n FROM pragma_index_list(?) il\n JOIN pragma_index_info(il.name) ii\n WHERE il.partial = 0\n ORDER BY il.seq, ii.seqno\n `);\n }\n\n /**\n * Gets the fanout factor for join column(s).\n *\n * Fanout = average number of child rows per distinct parent key value(s).\n *\n * ## Strategy\n *\n * 1. Try sqlite_stat4 (best): Histogram with separate NULL/non-NULL samples\n * 2. Fallback to sqlite_stat1: Average across all rows (includes NULLs)\n * 3. Fallback to default: When no statistics available\n *\n * ## Compound Indexes\n *\n * For multi-column joins, finds indexes where ALL columns appear as an\n * exact prefix. Uses the appropriate depth in stat1/stat4.\n *\n * Example:\n * - Columns: `['customerId', 'storeId']`\n * - Matches index: `(customerId, storeId, date)` at depth 2\n * - Uses stat1 parts[2] or stat4 neq[1] for accurate fanout\n *\n * ## Caching\n *\n * Results are cached per (table, columns) combination. Clear the cache if\n * you run ANALYZE to update statistics.\n *\n * @param tableName Table containing the join column(s)\n * @param columns Array of column names (one or more columns)\n * @returns Fanout result with value and source\n */\n getFanout(tableName: string, columns: string[]): FanoutResult {\n // Cache key uses sorted columns for consistency\n const cacheKey = `${tableName}:${columns.toSorted().join(',')}`;\n const cached = this.#cache.get(cacheKey);\n if (cached) {\n return cached;\n }\n\n // Strategy 1: Try stat4 first (most accurate)\n // NOTE: columns are NOT sorted - preserves Object.keys() order from constraint\n // Matching is order-independent (flexible), but we keep original order for consistency\n const stat4Result = this.#getFanoutFromStat4(tableName, columns);\n if (stat4Result) {\n this.#cache.set(cacheKey, stat4Result);\n return stat4Result;\n }\n\n // Strategy 2: Fallback to stat1 (includes NULLs)\n const stat1Result = this.#getFanoutFromStat1(tableName, columns);\n if (stat1Result) {\n this.#cache.set(cacheKey, stat1Result);\n return stat1Result;\n }\n\n // Strategy 3: Use default\n const defaultResult: FanoutResult = {\n fanout: this.#defaultFanout,\n confidence: 'none',\n source: 'default',\n };\n this.#cache.set(cacheKey, defaultResult);\n return defaultResult;\n }\n\n /**\n * Clears the fanout cache.\n * Call this after running ANALYZE to pick up updated statistics.\n */\n clearCache(): void {\n this.#cache.clear();\n }\n\n /**\n * Gets fanout from sqlite_stat4 histogram.\n *\n * Queries stat4 samples, decodes to identify NULLs, and returns\n * the median fanout of non-NULL samples.\n *\n * For compound indexes, uses the neq value at the appropriate depth.\n *\n * @param columns Array of column names to get fanout for\n * @returns Fanout result or undefined if stat4 unavailable\n */\n #getFanoutFromStat4(\n tableName: string,\n columns: string[],\n ): FanoutResult | undefined {\n try {\n // Find index containing the columns as a prefix\n const indexInfo = this.#findIndexForColumns(tableName, columns);\n if (!indexInfo) {\n return undefined;\n }\n\n // Query stat4 samples for this index (using prepared statement)\n const samples = this.#stat4Stmt.all(\n tableName,\n indexInfo.indexName,\n ) as Stat4Sample[];\n\n if (samples.length === 0) {\n return undefined;\n }\n\n // Decode samples and separate NULL from non-NULL\n // Use depth-1 for neq array index (depth is 1-based, array is 0-based)\n const neqIndex = indexInfo.depth - 1;\n const decodedSamples = samples.map(s => {\n const neqParts = s.neq.split(' ');\n return {\n fanout: parseInt(neqParts[neqIndex] ?? neqParts[0], 10),\n isNull: isSampleNull(s.sample),\n };\n });\n\n const nonNullSamples = decodedSamples.filter(s => !s.isNull);\n\n if (nonNullSamples.length === 0) {\n // All samples are NULL - return fanout of 0 since NULLs don't match in joins\n return {\n fanout: 0,\n source: 'stat4',\n confidence: 'high',\n };\n }\n\n // Use median of non-NULL fanouts (more robust than average)\n const fanouts = nonNullSamples.map(s => s.fanout).sort((a, b) => a - b);\n const medianFanout =\n fanouts.length % 2 === 0\n ? Math.floor(\n (fanouts[fanouts.length / 2 - 1] + fanouts[fanouts.length / 2]) /\n 2,\n )\n : fanouts[Math.floor(fanouts.length / 2)];\n\n return {\n fanout: medianFanout,\n source: 'stat4',\n confidence: 'high',\n };\n } catch {\n // stat4 table may not exist or query may fail\n return undefined;\n }\n }\n\n /**\n * Gets fanout from sqlite_stat1 average.\n *\n * Note: This includes NULL rows in the calculation and may overestimate\n * fanout for sparse foreign keys.\n *\n * For compound indexes, uses the stat value at the appropriate depth.\n *\n * @param columns Array of column names to get fanout for\n * @returns Fanout result or undefined if stat1 unavailable\n */\n #getFanoutFromStat1(\n tableName: string,\n columns: string[],\n ): FanoutResult | undefined {\n try {\n // Find index containing the columns as a prefix\n const indexInfo = this.#findIndexForColumns(tableName, columns);\n if (!indexInfo) {\n return undefined;\n }\n\n // Query stat1 for this index (using prepared statement)\n const result = this.#stat1Stmt.get(tableName, indexInfo.indexName) as\n | {stat: string}\n | undefined;\n\n if (!result) {\n return undefined;\n }\n\n const parts = result.stat.split(' ');\n // Check if we have enough parts for the requested depth\n if (parts.length < indexInfo.depth + 1) {\n return undefined;\n }\n\n const fanout = parseInt(parts[indexInfo.depth], 10);\n if (isNaN(fanout)) {\n return undefined;\n }\n\n return {\n fanout,\n source: 'stat1',\n confidence: 'med',\n };\n } catch {\n return undefined;\n }\n }\n\n /**\n * Finds an index that can be used to get statistics for column(s).\n *\n * Uses pragma_index_list and pragma_index_info to reliably get index\n * column names, avoiding brittle SQL parsing. Includes all indices:\n * user-created (CREATE INDEX), PRIMARY KEY, and UNIQUE constraints.\n *\n * Uses flexible matching: Finds indexes where ALL columns appear in the\n * first N positions, regardless of order. This works because SQLite statistics\n * at depth N represent the fanout for the combination of the first N columns,\n * and combinations are order-independent.\n *\n * Example:\n * - columns: ['customerId', 'storeId']\n * - Matches: (customerId, storeId, date) at depth 2 ✅\n * - Matches: (storeId, customerId, date) at depth 2 ✅ (flexible order)\n * - Does NOT match: (date, customerId, storeId) ❌ (columns not in first 2 positions)\n * - Does NOT match: (customerId, date, storeId) ❌ (storeId not in first 2 positions)\n *\n * @param columns Array of column names (order-independent for matching)\n * @returns Index info with name and depth, or undefined if no match\n */\n #findIndexForColumns(\n tableName: string,\n columns: string[],\n ): {indexName: string; depth: number} | undefined {\n try {\n // Query returns all columns for all indexes (including PK/UNIQUE) in order\n const rows = this.#indexStmt.all(tableName) as {\n index_name: string;\n seqno: number;\n column_name: string;\n }[];\n\n // Group by index name\n const indexMap = new Map<string, string[]>();\n for (const row of rows) {\n getOrInsertComputed(indexMap, row.index_name, newArray).push(\n row.column_name,\n );\n }\n\n // Check each index for prefix match\n for (const [indexName, indexColumns] of indexMap) {\n if (this.#isPrefixMatch(columns, indexColumns)) {\n return {\n indexName,\n depth: columns.length,\n };\n }\n }\n\n return undefined;\n } catch {\n return undefined;\n }\n }\n\n /**\n * Checks if all queryColumns exist in the first N positions of indexColumns,\n * regardless of order.\n *\n * This allows flexible matching: constraint {a, b} matches both index (a, b, c)\n * and index (b, a, c) at depth 2, since both represent the fanout for the\n * combination of columns a and b.\n *\n * Gaps are NOT allowed: constraint {a, c} does NOT match index (a, b, c)\n * because no depth represents just (a, c) without b. Statistics are cumulative\n * from position 0.\n *\n * @param queryColumns Columns we're looking for (from constraint)\n * @param indexColumns Columns in the index (in order)\n * @returns true if all queryColumns exist in indexColumns[0...queryColumns.length-1]\n */\n #isPrefixMatch(queryColumns: string[], indexColumns: string[]): boolean {\n if (queryColumns.length > indexColumns.length) {\n return false;\n }\n\n // Get the prefix of the index that we're checking against\n const indexPrefix = indexColumns.slice(0, queryColumns.length);\n\n // Normalize to lowercase for case-insensitive comparison\n const indexPrefixLower = new Set(indexPrefix.map(col => col.toLowerCase()));\n const queryColumnsLower = queryColumns.map(col => col.toLowerCase());\n\n // Check if ALL query columns exist in the index prefix\n return queryColumnsLower.every(queryCol => indexPrefixLower.has(queryCol));\n }\n}\n"],"mappings":"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"}
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Decoding of `sqlite_stat4.sample` values.
3
+ *
4
+ * A sample is a SQLite record: a header holding one serial type per column,
5
+ * followed by the column data. We only ever look at the serial types, which
6
+ * say what *kind* of value each column holds without revealing the value
7
+ * itself. Two callers need this:
8
+ *
9
+ * - `SQLiteStatFanout`, which separates NULL from non-NULL samples so that
10
+ * join fanout is not skewed by a sparse foreign key.
11
+ * - the `/plannerz` admin endpoint, which reports the shape of the histogram
12
+ * without returning row data.
13
+ *
14
+ * @see https://sqlite.org/fileformat2.html#record_format
15
+ * @see https://sqlite.org/fileformat2.html#stat4tab
16
+ */
17
+ /**
18
+ * The kind of value a stat4 sample column holds, derived from its serial type.
19
+ * `unknown` covers the internal serial types 10 and 11, which do not appear in
20
+ * a well-formed record.
21
+ */
22
+ export type SampleValueKind = 'null' | 'integer' | 'real' | 'text' | 'blob' | 'unknown';
23
+ /**
24
+ * Decodes the serial types in a stat4 sample's header.
25
+ *
26
+ * The sample's trailing rowid is included as a final column, matching the
27
+ * record SQLite stores.
28
+ *
29
+ * @returns one kind per column, or an empty array if `sample` is empty or its
30
+ * header is malformed.
31
+ */
32
+ export declare function decodeSampleKinds(sample: Buffer): SampleValueKind[];
33
+ /**
34
+ * Whether the first column of a sample is NULL.
35
+ */
36
+ export declare function isSampleNull(sample: Buffer): boolean;
37
+ //# sourceMappingURL=sqlite-stat4-sample.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sqlite-stat4-sample.d.ts","sourceRoot":"","sources":["../../../../zqlite/src/sqlite-stat4-sample.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH;;;;GAIG;AACH,MAAM,MAAM,eAAe,GACvB,MAAM,GACN,SAAS,GACT,MAAM,GACN,MAAM,GACN,MAAM,GACN,SAAS,CAAC;AAEd;;;;;;;;GAQG;AACH,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,MAAM,GAAG,eAAe,EAAE,CAuBnE;AAED;;GAEG;AACH,wBAAgB,YAAY,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAGpD"}
@@ -0,0 +1,61 @@
1
+ //#region ../zqlite/src/sqlite-stat4-sample.ts
2
+ /**
3
+ * Decodes the serial types in a stat4 sample's header.
4
+ *
5
+ * The sample's trailing rowid is included as a final column, matching the
6
+ * record SQLite stores.
7
+ *
8
+ * @returns one kind per column, or an empty array if `sample` is empty or its
9
+ * header is malformed.
10
+ */
11
+ function decodeSampleKinds(sample) {
12
+ const header = readVarint(sample, 0);
13
+ if (header === void 0) return [];
14
+ const [headerSize, serialTypesStart] = header;
15
+ if (headerSize < serialTypesStart || headerSize > sample.length) return [];
16
+ const kinds = [];
17
+ for (let pos = serialTypesStart; pos < headerSize;) {
18
+ const next = readVarint(sample, pos);
19
+ if (next === void 0) return [];
20
+ const [serialType, end] = next;
21
+ kinds.push(kindOfSerialType(serialType));
22
+ pos = end;
23
+ }
24
+ return kinds;
25
+ }
26
+ /**
27
+ * Whether the first column of a sample is NULL.
28
+ */
29
+ function isSampleNull(sample) {
30
+ const kinds = decodeSampleKinds(sample);
31
+ return kinds.length === 0 || kinds[0] === "null";
32
+ }
33
+ function kindOfSerialType(serialType) {
34
+ if (serialType === 0) return "null";
35
+ if (serialType <= 6 || serialType === 8 || serialType === 9) return "integer";
36
+ if (serialType === 7) return "real";
37
+ if (serialType <= 11) return "unknown";
38
+ return serialType % 2 === 0 ? "blob" : "text";
39
+ }
40
+ /**
41
+ * Reads the varint at `pos`.
42
+ *
43
+ * @returns the value and the offset just past it, or `undefined` if the varint
44
+ * runs off the end of the buffer. Values are read as `number`; a
45
+ * varint large enough to lose precision is not something a well-formed
46
+ * header or serial type contains.
47
+ */
48
+ function readVarint(buf, pos) {
49
+ let value = 0;
50
+ for (let i = 0; i < 9; i++) {
51
+ if (pos + i >= buf.length) return;
52
+ const byte = buf[pos + i];
53
+ if (i === 8) return [value * 256 + byte, pos + 9];
54
+ value = value * 128 + (byte & 127);
55
+ if ((byte & 128) === 0) return [value, pos + i + 1];
56
+ }
57
+ }
58
+ //#endregion
59
+ export { decodeSampleKinds, isSampleNull };
60
+
61
+ //# sourceMappingURL=sqlite-stat4-sample.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sqlite-stat4-sample.js","names":[],"sources":["../../../../zqlite/src/sqlite-stat4-sample.ts"],"sourcesContent":["/**\n * Decoding of `sqlite_stat4.sample` values.\n *\n * A sample is a SQLite record: a header holding one serial type per column,\n * followed by the column data. We only ever look at the serial types, which\n * say what *kind* of value each column holds without revealing the value\n * itself. Two callers need this:\n *\n * - `SQLiteStatFanout`, which separates NULL from non-NULL samples so that\n * join fanout is not skewed by a sparse foreign key.\n * - the `/plannerz` admin endpoint, which reports the shape of the histogram\n * without returning row data.\n *\n * @see https://sqlite.org/fileformat2.html#record_format\n * @see https://sqlite.org/fileformat2.html#stat4tab\n */\n\n/**\n * The kind of value a stat4 sample column holds, derived from its serial type.\n * `unknown` covers the internal serial types 10 and 11, which do not appear in\n * a well-formed record.\n */\nexport type SampleValueKind =\n | 'null'\n | 'integer'\n | 'real'\n | 'text'\n | 'blob'\n | 'unknown';\n\n/**\n * Decodes the serial types in a stat4 sample's header.\n *\n * The sample's trailing rowid is included as a final column, matching the\n * record SQLite stores.\n *\n * @returns one kind per column, or an empty array if `sample` is empty or its\n * header is malformed.\n */\nexport function decodeSampleKinds(sample: Buffer): SampleValueKind[] {\n const header = readVarint(sample, 0);\n if (header === undefined) {\n return [];\n }\n // `headerSize` counts the size varint itself, so the serial types run from\n // the end of that varint to `headerSize`, and cannot exceed the record.\n const [headerSize, serialTypesStart] = header;\n if (headerSize < serialTypesStart || headerSize > sample.length) {\n return [];\n }\n\n const kinds: SampleValueKind[] = [];\n for (let pos = serialTypesStart; pos < headerSize;) {\n const next = readVarint(sample, pos);\n if (next === undefined) {\n return [];\n }\n const [serialType, end] = next;\n kinds.push(kindOfSerialType(serialType));\n pos = end;\n }\n return kinds;\n}\n\n/**\n * Whether the first column of a sample is NULL.\n */\nexport function isSampleNull(sample: Buffer): boolean {\n const kinds = decodeSampleKinds(sample);\n return kinds.length === 0 || kinds[0] === 'null';\n}\n\nfunction kindOfSerialType(serialType: number): SampleValueKind {\n if (serialType === 0) {\n return 'null';\n }\n if (serialType <= 6 || serialType === 8 || serialType === 9) {\n return 'integer';\n }\n if (serialType === 7) {\n return 'real';\n }\n if (serialType <= 11) {\n return 'unknown'; // 10 and 11 are for internal use.\n }\n return serialType % 2 === 0 ? 'blob' : 'text';\n}\n\n/**\n * Reads the varint at `pos`.\n *\n * @returns the value and the offset just past it, or `undefined` if the varint\n * runs off the end of the buffer. Values are read as `number`; a\n * varint large enough to lose precision is not something a well-formed\n * header or serial type contains.\n */\nfunction readVarint(\n buf: Buffer,\n pos: number,\n): [value: number, end: number] | undefined {\n let value = 0;\n // A varint is at most 9 bytes, the last of which contributes all 8 bits.\n for (let i = 0; i < 9; i++) {\n if (pos + i >= buf.length) {\n return undefined;\n }\n const byte = buf[pos + i];\n if (i === 8) {\n return [value * 256 + byte, pos + 9];\n }\n value = value * 128 + (byte & 0x7f);\n if ((byte & 0x80) === 0) {\n return [value, pos + i + 1];\n }\n }\n return undefined;\n}\n"],"mappings":";;;;;;;;;;AAuCA,SAAgB,kBAAkB,QAAmC;CACnE,MAAM,SAAS,WAAW,QAAQ,CAAC;CACnC,IAAI,WAAW,KAAA,GACb,OAAO,CAAC;CAIV,MAAM,CAAC,YAAY,oBAAoB;CACvC,IAAI,aAAa,oBAAoB,aAAa,OAAO,QACvD,OAAO,CAAC;CAGV,MAAM,QAA2B,CAAC;CAClC,KAAK,IAAI,MAAM,kBAAkB,MAAM,aAAa;EAClD,MAAM,OAAO,WAAW,QAAQ,GAAG;EACnC,IAAI,SAAS,KAAA,GACX,OAAO,CAAC;EAEV,MAAM,CAAC,YAAY,OAAO;EAC1B,MAAM,KAAK,iBAAiB,UAAU,CAAC;EACvC,MAAM;CACR;CACA,OAAO;AACT;;;;AAKA,SAAgB,aAAa,QAAyB;CACpD,MAAM,QAAQ,kBAAkB,MAAM;CACtC,OAAO,MAAM,WAAW,KAAK,MAAM,OAAO;AAC5C;AAEA,SAAS,iBAAiB,YAAqC;CAC7D,IAAI,eAAe,GACjB,OAAO;CAET,IAAI,cAAc,KAAK,eAAe,KAAK,eAAe,GACxD,OAAO;CAET,IAAI,eAAe,GACjB,OAAO;CAET,IAAI,cAAc,IAChB,OAAO;CAET,OAAO,aAAa,MAAM,IAAI,SAAS;AACzC;;;;;;;;;AAUA,SAAS,WACP,KACA,KAC0C;CAC1C,IAAI,QAAQ;CAEZ,KAAK,IAAI,IAAI,GAAG,IAAI,GAAG,KAAK;EAC1B,IAAI,MAAM,KAAK,IAAI,QACjB;EAEF,MAAM,OAAO,IAAI,MAAM;EACvB,IAAI,MAAM,GACR,OAAO,CAAC,QAAQ,MAAM,MAAM,MAAM,CAAC;EAErC,QAAQ,QAAQ,OAAO,OAAO;EAC9B,KAAK,OAAO,SAAU,GACpB,OAAO,CAAC,OAAO,MAAM,IAAI,CAAC;CAE9B;AAEF"}
@@ -19,6 +19,13 @@ export type TableSourceOptions = {
19
19
  * pipeline driver does when it advances to the next snapshot.
20
20
  */
21
21
  skipUnobservableChanges?: boolean | undefined;
22
+ /**
23
+ * When set, pushed changes are held in an in-memory {@link PendingDelta}
24
+ * merged into every read instead of being written to the backing table.
25
+ * The delta is dropped when the source moves to the next snapshot. This is
26
+ * the initial mode; see {@link TableSource.setDeferWrites}.
27
+ */
28
+ deferWrites?: boolean | undefined;
22
29
  };
23
30
  /**
24
31
  * A source that is backed by a SQLite table.
@@ -43,6 +50,11 @@ export declare class TableSource implements Source {
43
50
  * also throw an error to abort the pipeline processing.
44
51
  */
45
52
  constructor(logContext: LogContext, logConfig: LogConfig, db: Database, tableName: string, columns: Record<string, SchemaValue>, primaryKey: PrimaryKey, shouldYield?: () => boolean, options?: TableSourceOptions);
53
+ /**
54
+ * The number of rows read from SQLite by fetches, over the lifetime of this
55
+ * source. Rows that SQLite scans and filters out itself are not counted.
56
+ */
57
+ get rowsRead(): number;
46
58
  get tableSchema(): {
47
59
  name: string;
48
60
  columns: Record<string, SchemaValue>;
@@ -53,11 +65,67 @@ export declare class TableSource implements Source {
53
65
  * algorithm for concurrent traversal of historic timelines.
54
66
  */
55
67
  setDB(db: Database): void;
68
+ /**
69
+ * Sets whether the changes pushed from now on are held in memory (see
70
+ * {@link TableSourceOptions.deferWrites}) or written to the backing
71
+ * snapshot. The two must not be mixed within one snapshot, so this may only
72
+ * be called before the first push after construction or {@link setDB}. To
73
+ * switch to writing through partway, see {@link writePendingChanges}.
74
+ */
75
+ setDeferWrites(deferWrites: boolean): void;
76
+ /**
77
+ * Drops the changes held in memory (see {@link setDeferWrites}), for an
78
+ * advancement that has been abandoned. The source then no longer reflects
79
+ * the changes pushed to it, so it must not be read again until it has moved
80
+ * to another snapshot ({@link setDB}), if it is used again at all.
81
+ */
82
+ discardPendingChanges(): void;
83
+ /**
84
+ * Writes the changes held in memory (see {@link setDeferWrites}) to the
85
+ * backing snapshot, which is left as if they had been written through (in
86
+ * the source's columns), and writes the changes pushed from then on through
87
+ * to it. Yields when the
88
+ * source's `shouldYield` says to, and must not be interleaved with a push.
89
+ */
90
+ writePendingChanges(): Stream<'yield'>;
56
91
  hasConnections(): boolean;
57
92
  connect(sort: Ordering | undefined, filters?: Condition, splitEditKeys?: Set<string>, debug?: DebugDelegate): SourceInput;
58
93
  toSQLiteRow(row: Row): Row;
59
94
  push(change: SourceChange): Stream<'yield'>;
95
+ /**
96
+ * The number of rows held in memory for changes that have been pushed but
97
+ * not written, i.e. with `deferWrites`, since the last {@link setDB}.
98
+ */
99
+ get pendingRows(): number;
100
+ /** Estimated bytes retained by deferred changes and their indexes. */
101
+ get pendingBytes(): number;
60
102
  genPush(change: SourceChange): Stream<'yield' | undefined>;
103
+ /**
104
+ * Reconciles a set of rows that the caller found in the *backing snapshot*
105
+ * with the changes this source has applied but not written.
106
+ *
107
+ * A view-syncer computes the rows a replicated change collides with -- by
108
+ * change-log key for a delete, by every unique key for a set -- against the
109
+ * snapshot it is reading. When derivation writes through, that snapshot has
110
+ * already absorbed the earlier changes of the same advancement, so the
111
+ * probe sees them. When derivation is deferred they live here instead, and
112
+ * the probe has to be brought up to date or the source is pushed a change
113
+ * that contradicts its own state (a delete of a row the batch already
114
+ * removed, an edit whose old row is stale).
115
+ *
116
+ * Two ways the batch can disagree with the probe, both handled here: a
117
+ * probed row may have been removed, or may have been edited (so the
118
+ * caller's copy is stale, or it no longer collides at all).
119
+ *
120
+ * The batch cannot have edited a row the probe missed *into* collision. The
121
+ * change log keeps one entry per row key, so every row the batch holds is
122
+ * that row's value at the advancement's target version, as is `next`, and
123
+ * two distinct rows of one version cannot share a non-null unique key.
124
+ *
125
+ * Returns `base` untouched when derivation is not deferred, which is what
126
+ * makes this a no-op for the write-through path.
127
+ */
128
+ reconcilePendingConflicts(base: readonly Row[], next: Row | null, rowKey: Row, uniqueKeys: readonly (readonly string[])[]): readonly Row[];
61
129
  /**
62
130
  * Retrieves a row from the backing DB by a unique key, or `undefined` if such a
63
131
  * row does not exist. This is not used in the IVM pipeline but is useful
@@ -1 +1 @@
1
- {"version":3,"file":"table-source.d.ts","sourceRoot":"","sources":["../../../../zqlite/src/table-source.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAC,UAAU,EAAC,MAAM,kBAAkB,CAAC;AAEjD,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,+BAA+B,CAAC;AAM7D,OAAO,KAAK,EAAC,SAAS,EAAE,QAAQ,EAAC,MAAM,gCAAgC,CAAC;AACxE,OAAO,KAAK,EAAC,GAAG,EAAQ,MAAM,iCAAiC,CAAC;AAChE,OAAO,KAAK,EAAC,UAAU,EAAC,MAAM,wCAAwC,CAAC;AACvE,OAAO,KAAK,EACV,WAAW,EACX,SAAS,EACV,MAAM,uCAAuC,CAAC;AAC/C,OAAO,KAAK,EAAC,aAAa,EAAC,MAAM,yCAAyC,CAAC;AAoB3E,OAAO,EACL,KAAK,MAAM,EACX,KAAK,YAAY,EACjB,KAAK,WAAW,EACjB,MAAM,6BAA6B,CAAC;AACrC,OAAO,KAAK,EAAC,MAAM,EAAC,MAAM,6BAA6B,CAAC;AAExD,OAAO,KAAK,EAAC,QAAQ,EAAY,MAAM,SAAS,CAAC;AAoBjD,MAAM,MAAM,kBAAkB,GAAG;IAC/B;;;;;;;;OAQG;IACH,uBAAuB,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;CAC/C,CAAC;AAEF;;;;;;;;;;;;;GAaG;AACH,qBAAa,WAAY,YAAW,MAAM;;IAmBxC;;;;;OAKG;IACH,YACE,UAAU,EAAE,UAAU,EACtB,SAAS,EAAE,SAAS,EACpB,EAAE,EAAE,QAAQ,EACZ,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,WAAW,CAAC,EACpC,UAAU,EAAE,UAAU,EACtB,WAAW,gBAAc,EACzB,OAAO,GAAE,kBAAuB,EAiBjC;IAED,IAAI,WAAW;QAEX,IAAI;QACJ,OAAO;QACP,UAAU;MAEb;IAED;;;OAGG;IACH,KAAK,CAAC,EAAE,EAAE,QAAQ,QAEjB;IA0FD,cAAc,IAAI,OAAO,CAExB;IAED,OAAO,CACL,IAAI,EAAE,QAAQ,GAAG,SAAS,EAC1B,OAAO,CAAC,EAAE,SAAS,EACnB,aAAa,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,EAC3B,KAAK,CAAC,EAAE,aAAa,eA8CtB;IAED,WAAW,CAAC,GAAG,EAAE,GAAG,GAAG,GAAG,CAOzB;IAkIA,IAAI,CAAC,MAAM,EAAE,YAAY,GAAG,MAAM,CAAC,OAAO,CAAC,CAM3C;IAEA,OAAO,CAAC,MAAM,EAAE,YAAY,GAAG,MAAM,CAAC,OAAO,GAAG,SAAS,CAAC,CA4B1D;IAkFD;;;;;;OAMG;IACH,MAAM,CAAC,MAAM,EAAE,GAAG,GAAG,GAAG,GAAG,SAAS,CAanC;CAmBF;AA4CD,wBAAgB,aAAa,CAC3B,OAAO,EAAE,SAAS,MAAM,EAAE,EAC1B,GAAG,EAAE,GAAG,EACR,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,WAAW,CAAC,GACvC,SAAS,OAAO,EAAE,CAEpB;AAED,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,SAAS,wCAa/C;AAED,wBAAgB,eAAe,CAC7B,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,WAAW,CAAC,EACvC,GAAG,EAAE,GAAG,EACR,SAAS,EAAE,MAAM,GAChB,GAAG,CAaL;AAyCD,qBAAa,qBAAsB,SAAQ,KAAK;CAAG"}
1
+ {"version":3,"file":"table-source.d.ts","sourceRoot":"","sources":["../../../../zqlite/src/table-source.ts"],"names":[],"mappings":"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"}