@rocicorp/zero 1.10.0-canary.1 → 1.10.0-canary.3

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 (180) hide show
  1. package/out/zero/package.js +1 -1
  2. package/out/zero/package.js.map +1 -1
  3. package/out/zero-cache/src/auth/write-authorizer.d.ts.map +1 -1
  4. package/out/zero-cache/src/auth/write-authorizer.js +8 -1
  5. package/out/zero-cache/src/auth/write-authorizer.js.map +1 -1
  6. package/out/zero-cache/src/config/normalize.d.ts.map +1 -1
  7. package/out/zero-cache/src/config/normalize.js +7 -2
  8. package/out/zero-cache/src/config/normalize.js.map +1 -1
  9. package/out/zero-cache/src/config/zero-config.d.ts +14 -4
  10. package/out/zero-cache/src/config/zero-config.d.ts.map +1 -1
  11. package/out/zero-cache/src/config/zero-config.js +26 -8
  12. package/out/zero-cache/src/config/zero-config.js.map +1 -1
  13. package/out/zero-cache/src/db/transaction-pool.d.ts +5 -2
  14. package/out/zero-cache/src/db/transaction-pool.d.ts.map +1 -1
  15. package/out/zero-cache/src/db/transaction-pool.js +5 -3
  16. package/out/zero-cache/src/db/transaction-pool.js.map +1 -1
  17. package/out/zero-cache/src/server/change-streamer.d.ts.map +1 -1
  18. package/out/zero-cache/src/server/change-streamer.js +24 -13
  19. package/out/zero-cache/src/server/change-streamer.js.map +1 -1
  20. package/out/zero-cache/src/server/runner/run-worker.js +1 -1
  21. package/out/zero-cache/src/server/worker-urls.d.ts +0 -1
  22. package/out/zero-cache/src/server/worker-urls.d.ts.map +1 -1
  23. package/out/zero-cache/src/server/worker-urls.js +1 -2
  24. package/out/zero-cache/src/server/worker-urls.js.map +1 -1
  25. package/out/zero-cache/src/services/change-source/pg/backfill-stream.js +1 -1
  26. package/out/zero-cache/src/services/change-source/pg/backfill-stream.js.map +1 -1
  27. package/out/zero-cache/src/services/change-source/pg/change-source.js +1 -1
  28. package/out/zero-cache/src/services/change-source/pg/decommission.d.ts.map +1 -1
  29. package/out/zero-cache/src/services/change-source/pg/decommission.js +20 -11
  30. package/out/zero-cache/src/services/change-source/pg/decommission.js.map +1 -1
  31. package/out/zero-cache/src/services/change-source/pg/initial-sync.d.ts.map +1 -1
  32. package/out/zero-cache/src/services/change-source/pg/initial-sync.js +51 -74
  33. package/out/zero-cache/src/services/change-source/pg/initial-sync.js.map +1 -1
  34. package/out/zero-cache/src/services/change-source/pg/logical-replication/stream.d.ts +20 -0
  35. package/out/zero-cache/src/services/change-source/pg/logical-replication/stream.d.ts.map +1 -1
  36. package/out/zero-cache/src/services/change-source/pg/logical-replication/stream.js +43 -3
  37. package/out/zero-cache/src/services/change-source/pg/logical-replication/stream.js.map +1 -1
  38. package/out/zero-cache/src/services/change-source/pg/replication-slots.d.ts +24 -1
  39. package/out/zero-cache/src/services/change-source/pg/replication-slots.d.ts.map +1 -1
  40. package/out/zero-cache/src/services/change-source/pg/replication-slots.js +35 -5
  41. package/out/zero-cache/src/services/change-source/pg/replication-slots.js.map +1 -1
  42. package/out/zero-cache/src/services/change-streamer/backup-cleanup-monitor-factory.d.ts +1 -4
  43. package/out/zero-cache/src/services/change-streamer/backup-cleanup-monitor-factory.d.ts.map +1 -1
  44. package/out/zero-cache/src/services/change-streamer/backup-cleanup-monitor-factory.js +11 -18
  45. package/out/zero-cache/src/services/change-streamer/backup-cleanup-monitor-factory.js.map +1 -1
  46. package/out/zero-cache/src/services/change-streamer/backup-monitor.d.ts.map +1 -1
  47. package/out/zero-cache/src/services/change-streamer/backup-monitor.js +8 -3
  48. package/out/zero-cache/src/services/change-streamer/backup-monitor.js.map +1 -1
  49. package/out/zero-cache/src/services/change-streamer/change-log-compare-digest.d.ts +34 -0
  50. package/out/zero-cache/src/services/change-streamer/change-log-compare-digest.d.ts.map +1 -0
  51. package/out/zero-cache/src/services/change-streamer/change-log-compare-digest.js +67 -0
  52. package/out/zero-cache/src/services/change-streamer/change-log-compare-digest.js.map +1 -0
  53. package/out/zero-cache/src/services/change-streamer/change-streamer-http.d.ts +2 -4
  54. package/out/zero-cache/src/services/change-streamer/change-streamer-http.d.ts.map +1 -1
  55. package/out/zero-cache/src/services/change-streamer/change-streamer-http.js +8 -2
  56. package/out/zero-cache/src/services/change-streamer/change-streamer-http.js.map +1 -1
  57. package/out/zero-cache/src/services/change-streamer/change-streamer-service.d.ts +34 -13
  58. package/out/zero-cache/src/services/change-streamer/change-streamer-service.d.ts.map +1 -1
  59. package/out/zero-cache/src/services/change-streamer/change-streamer-service.js +149 -27
  60. package/out/zero-cache/src/services/change-streamer/change-streamer-service.js.map +1 -1
  61. package/out/zero-cache/src/services/change-streamer/change-streamer.d.ts +3 -9
  62. package/out/zero-cache/src/services/change-streamer/change-streamer.d.ts.map +1 -1
  63. package/out/zero-cache/src/services/change-streamer/change-streamer.js.map +1 -1
  64. package/out/zero-cache/src/services/change-streamer/shard-sampling.d.ts +11 -0
  65. package/out/zero-cache/src/services/change-streamer/shard-sampling.d.ts.map +1 -0
  66. package/out/zero-cache/src/services/change-streamer/shard-sampling.js +19 -0
  67. package/out/zero-cache/src/services/change-streamer/shard-sampling.js.map +1 -0
  68. package/out/zero-cache/src/services/change-streamer/snapshot-reservations.d.ts +11 -1
  69. package/out/zero-cache/src/services/change-streamer/snapshot-reservations.d.ts.map +1 -1
  70. package/out/zero-cache/src/services/change-streamer/snapshot-reservations.js +38 -5
  71. package/out/zero-cache/src/services/change-streamer/snapshot-reservations.js.map +1 -1
  72. package/out/zero-cache/src/services/change-streamer/sqlite-change-log-catchup.d.ts +25 -1
  73. package/out/zero-cache/src/services/change-streamer/sqlite-change-log-catchup.d.ts.map +1 -1
  74. package/out/zero-cache/src/services/change-streamer/sqlite-change-log-catchup.js +95 -12
  75. package/out/zero-cache/src/services/change-streamer/sqlite-change-log-catchup.js.map +1 -1
  76. package/out/zero-cache/src/services/change-streamer/sqlite-change-log-comparator.d.ts +83 -0
  77. package/out/zero-cache/src/services/change-streamer/sqlite-change-log-comparator.d.ts.map +1 -0
  78. package/out/zero-cache/src/services/change-streamer/sqlite-change-log-comparator.js +417 -0
  79. package/out/zero-cache/src/services/change-streamer/sqlite-change-log-comparator.js.map +1 -0
  80. package/out/zero-cache/src/services/change-streamer/sqlite-change-log-read-router.d.ts +73 -0
  81. package/out/zero-cache/src/services/change-streamer/sqlite-change-log-read-router.d.ts.map +1 -0
  82. package/out/zero-cache/src/services/change-streamer/sqlite-change-log-read-router.js +157 -0
  83. package/out/zero-cache/src/services/change-streamer/sqlite-change-log-read-router.js.map +1 -0
  84. package/out/zero-cache/src/services/change-streamer/sqlite-change-log-reader.d.ts +1 -1
  85. package/out/zero-cache/src/services/change-streamer/sqlite-change-log-reader.d.ts.map +1 -1
  86. package/out/zero-cache/src/services/change-streamer/sqlite-change-log-reader.js +8 -3
  87. package/out/zero-cache/src/services/change-streamer/sqlite-change-log-reader.js.map +1 -1
  88. package/out/zero-cache/src/services/change-streamer/storer.d.ts +23 -0
  89. package/out/zero-cache/src/services/change-streamer/storer.d.ts.map +1 -1
  90. package/out/zero-cache/src/services/change-streamer/storer.js +40 -0
  91. package/out/zero-cache/src/services/change-streamer/storer.js.map +1 -1
  92. package/out/zero-cache/src/services/change-streamer/vfs-watermark-poller.d.ts +29 -0
  93. package/out/zero-cache/src/services/change-streamer/vfs-watermark-poller.d.ts.map +1 -0
  94. package/out/zero-cache/src/services/change-streamer/vfs-watermark-poller.js +154 -0
  95. package/out/zero-cache/src/services/change-streamer/vfs-watermark-poller.js.map +1 -0
  96. package/out/zero-cache/src/services/http-service.d.ts +1 -0
  97. package/out/zero-cache/src/services/http-service.d.ts.map +1 -1
  98. package/out/zero-cache/src/services/http-service.js +7 -3
  99. package/out/zero-cache/src/services/http-service.js.map +1 -1
  100. package/out/zero-cache/src/services/litestream/metrics.d.ts +1 -0
  101. package/out/zero-cache/src/services/litestream/metrics.d.ts.map +1 -1
  102. package/out/zero-cache/src/services/litestream/metrics.js +4 -1
  103. package/out/zero-cache/src/services/litestream/metrics.js.map +1 -1
  104. package/out/zero-cache/src/services/mutagen/mutagen.d.ts.map +1 -1
  105. package/out/zero-cache/src/services/mutagen/mutagen.js +1 -1
  106. package/out/zero-cache/src/services/mutagen/mutagen.js.map +1 -1
  107. package/out/zero-cache/src/services/replicator/change-log-db.d.ts +10 -0
  108. package/out/zero-cache/src/services/replicator/change-log-db.d.ts.map +1 -1
  109. package/out/zero-cache/src/services/replicator/change-log-db.js +15 -1
  110. package/out/zero-cache/src/services/replicator/change-log-db.js.map +1 -1
  111. package/out/zero-cache/src/services/replicator/sqlite-change-log-observability.d.ts +3 -2
  112. package/out/zero-cache/src/services/replicator/sqlite-change-log-observability.d.ts.map +1 -1
  113. package/out/zero-cache/src/services/replicator/sqlite-change-log-observability.js +8 -6
  114. package/out/zero-cache/src/services/replicator/sqlite-change-log-observability.js.map +1 -1
  115. package/out/zero-cache/src/services/view-syncer/client-handler.d.ts +3 -2
  116. package/out/zero-cache/src/services/view-syncer/client-handler.d.ts.map +1 -1
  117. package/out/zero-cache/src/services/view-syncer/client-handler.js +25 -17
  118. package/out/zero-cache/src/services/view-syncer/client-handler.js.map +1 -1
  119. package/out/zero-cache/src/services/view-syncer/cvr-store.d.ts.map +1 -1
  120. package/out/zero-cache/src/services/view-syncer/cvr-store.js +14 -1
  121. package/out/zero-cache/src/services/view-syncer/cvr-store.js.map +1 -1
  122. package/out/zero-cache/src/services/view-syncer/view-syncer.d.ts +3 -3
  123. package/out/zero-cache/src/services/view-syncer/view-syncer.d.ts.map +1 -1
  124. package/out/zero-cache/src/services/view-syncer/view-syncer.js +6 -2
  125. package/out/zero-cache/src/services/view-syncer/view-syncer.js.map +1 -1
  126. package/out/zero-cache/src/types/downstream.d.ts +11 -0
  127. package/out/zero-cache/src/types/downstream.d.ts.map +1 -0
  128. package/out/zero-cache/src/types/pg.d.ts.map +1 -1
  129. package/out/zero-cache/src/types/pg.js +14 -4
  130. package/out/zero-cache/src/types/pg.js.map +1 -1
  131. package/out/zero-cache/src/types/poke-chunk.d.ts +18 -0
  132. package/out/zero-cache/src/types/poke-chunk.d.ts.map +1 -0
  133. package/out/zero-cache/src/types/poke-chunk.js +57 -0
  134. package/out/zero-cache/src/types/poke-chunk.js.map +1 -0
  135. package/out/zero-cache/src/types/processes.js +1 -4
  136. package/out/zero-cache/src/types/processes.js.map +1 -1
  137. package/out/zero-cache/src/workers/connection.d.ts +22 -4
  138. package/out/zero-cache/src/workers/connection.d.ts.map +1 -1
  139. package/out/zero-cache/src/workers/connection.js +112 -14
  140. package/out/zero-cache/src/workers/connection.js.map +1 -1
  141. package/out/zero-client/src/client/version.js +1 -1
  142. package/out/zero-client/src/client/zero-poke-handler.d.ts +7 -2
  143. package/out/zero-client/src/client/zero-poke-handler.d.ts.map +1 -1
  144. package/out/zero-client/src/client/zero-poke-handler.js +52 -2
  145. package/out/zero-client/src/client/zero-poke-handler.js.map +1 -1
  146. package/out/zero-client/src/client/zero.d.ts.map +1 -1
  147. package/out/zero-client/src/client/zero.js +38 -6
  148. package/out/zero-client/src/client/zero.js.map +1 -1
  149. package/out/zero-protocol/src/down.d.ts +1 -1
  150. package/out/zero-protocol/src/poke.d.ts +89 -7
  151. package/out/zero-protocol/src/poke.d.ts.map +1 -1
  152. package/out/zero-protocol/src/poke.js +16 -8
  153. package/out/zero-protocol/src/poke.js.map +1 -1
  154. package/out/zero-protocol/src/protocol-version.d.ts +12 -7
  155. package/out/zero-protocol/src/protocol-version.d.ts.map +1 -1
  156. package/out/zero-protocol/src/protocol-version.js.map +1 -1
  157. package/out/zql/src/ivm/memory-source.d.ts +10 -2
  158. package/out/zql/src/ivm/memory-source.d.ts.map +1 -1
  159. package/out/zql/src/ivm/memory-source.js +14 -6
  160. package/out/zql/src/ivm/memory-source.js.map +1 -1
  161. package/out/zql/src/ivm/stream.d.ts +0 -1
  162. package/out/zql/src/ivm/stream.d.ts.map +1 -1
  163. package/out/zql/src/ivm/stream.js +1 -7
  164. package/out/zql/src/ivm/stream.js.map +1 -1
  165. package/out/zql/src/ivm/union-fan-in.d.ts +1 -1
  166. package/out/zql/src/ivm/union-fan-in.d.ts.map +1 -1
  167. package/out/zql/src/ivm/union-fan-in.js +11 -2
  168. package/out/zql/src/ivm/union-fan-in.js.map +1 -1
  169. package/out/zqlite/src/db.js +2 -2
  170. package/out/zqlite/src/sqlite-cost-model.js +5 -5
  171. package/out/zqlite/src/table-source.d.ts.map +1 -1
  172. package/out/zqlite/src/table-source.js +4 -4
  173. package/out/zqlite/src/table-source.js.map +1 -1
  174. package/package.json +1 -1
  175. package/out/zero-cache/src/server/backup-watermark-poller.js +0 -31
  176. package/out/zero-cache/src/server/backup-watermark-poller.js.map +0 -1
  177. package/out/zero-cache/src/services/litestream/vfs-watermark-poller.d.ts +0 -57
  178. package/out/zero-cache/src/services/litestream/vfs-watermark-poller.d.ts.map +0 -1
  179. package/out/zero-cache/src/services/litestream/vfs-watermark-poller.js +0 -137
  180. package/out/zero-cache/src/services/litestream/vfs-watermark-poller.js.map +0 -1
@@ -0,0 +1,157 @@
1
+ import { Database } from "../../../../zqlite/src/db.js";
2
+ import { readChangeLogBounds, readChangeLogMeta } from "../replicator/change-log-db.js";
3
+ import { isSampledForShard } from "./shard-sampling.js";
4
+ //#region ../zero-cache/src/services/change-streamer/sqlite-change-log-read-router.ts
5
+ var DEFAULT_FAILURE_COOLDOWN_MS = 3e4;
6
+ /**
7
+ * Selects and pins the catchup source shared by `/snapshot` and `/changes`.
8
+ *
9
+ * A post-registration SQLite failure opens a process-local breaker. Requests
10
+ * use PG during the cooldown; the first request after it expires is the
11
+ * bounded probe. A successful readiness inspection closes the breaker, while
12
+ * another unavailable result rearms it. A writer failure opens it permanently
13
+ * because the writer remains disabled and its file stays absent for the life
14
+ * of the process.
15
+ */
16
+ var SQLiteChangeLogReadRouter = class {
17
+ #shard;
18
+ #readPercent;
19
+ #coldReadPercent;
20
+ #retentionMs;
21
+ #inspect;
22
+ #failureCooldownMs;
23
+ #now;
24
+ #pins = /* @__PURE__ */ new Map();
25
+ #breakerUntilMs;
26
+ #permanentlyDisabled = false;
27
+ constructor(opts) {
28
+ this.#shard = opts.shard;
29
+ this.#readPercent = opts.readPercent;
30
+ this.#coldReadPercent = opts.coldReadPercent ?? 0;
31
+ this.#retentionMs = opts.retentionMs;
32
+ this.#inspect = opts.inspect;
33
+ this.#failureCooldownMs = opts.failureCooldownMs ?? DEFAULT_FAILURE_COOLDOWN_MS;
34
+ this.#now = opts.now ?? Date.now;
35
+ }
36
+ /** Chooses and pins a source for a snapshot reservation. */
37
+ pin(taskID) {
38
+ const route = this.#choose(taskID);
39
+ this.#pins.set(taskID, route);
40
+ return route;
41
+ }
42
+ /** Returns the source pinned by an active snapshot reservation. */
43
+ peek(taskID) {
44
+ const route = this.#pins.get(taskID);
45
+ return route && this.#asPinned(route);
46
+ }
47
+ /** Consumes a snapshot pin, or makes the stable choice for a direct retry. */
48
+ consume(taskID) {
49
+ const route = this.#pins.get(taskID);
50
+ if (route === void 0) return this.#choose(taskID);
51
+ this.#pins.delete(taskID);
52
+ return this.#asPinned(route);
53
+ }
54
+ /** Releases a pin whose snapshot reservation ended before subscribing. */
55
+ release(taskID) {
56
+ this.#pins.delete(taskID);
57
+ }
58
+ /**
59
+ * Replaces a pinned SQLite route with PG, for a reservation whose backup
60
+ * the log cannot cover -- most often a log seeded after that backup.
61
+ *
62
+ * The pin moves, not just the reservation's advertised bounds. Confirming
63
+ * with PG bounds while the task's `/changes` request still resolves to
64
+ * SQLite is exactly the mismatch the reservation path exists to prevent,
65
+ * so the two have to change together.
66
+ *
67
+ * A task with no pin is left alone: `consume` makes it a fresh choice.
68
+ */
69
+ demote(taskID) {
70
+ const route = {
71
+ source: "pg",
72
+ reason: "backup-uncovered"
73
+ };
74
+ if (this.#pins.has(taskID)) this.#pins.set(taskID, route);
75
+ return route;
76
+ }
77
+ /** Opens the post-registration failure breaker. */
78
+ trip(permanent = false) {
79
+ if (permanent) {
80
+ this.#permanentlyDisabled = true;
81
+ this.#breakerUntilMs = void 0;
82
+ return;
83
+ }
84
+ if (!this.#permanentlyDisabled) this.#breakerUntilMs = this.#now() + this.#failureCooldownMs;
85
+ }
86
+ #choose(taskID) {
87
+ const now = this.#now();
88
+ if (this.#permanentlyDisabled || this.#breakerUntilMs !== void 0 && now < this.#breakerUntilMs) return {
89
+ source: "pg",
90
+ reason: "breaker-open"
91
+ };
92
+ let coverage;
93
+ try {
94
+ coverage = this.#inspect();
95
+ } catch {
96
+ coverage = void 0;
97
+ }
98
+ if (coverage === void 0) {
99
+ if (this.#breakerUntilMs !== void 0) this.#breakerUntilMs = now + this.#failureCooldownMs;
100
+ return {
101
+ source: "pg",
102
+ reason: "log-unavailable"
103
+ };
104
+ }
105
+ this.#breakerUntilMs = void 0;
106
+ const cold = now - coverage.seededAtMs < this.#retentionMs;
107
+ if (cold && !isSampledForShard(this.#shard, taskID, this.#coldReadPercent)) return {
108
+ source: "pg",
109
+ reason: "cold-log",
110
+ coverage
111
+ };
112
+ if (!isSampledForShard(this.#shard, taskID, this.#readPercent)) return {
113
+ source: "pg",
114
+ reason: "percentage",
115
+ coverage
116
+ };
117
+ return {
118
+ source: "sqlite",
119
+ reason: cold ? "selected-cold" : "selected",
120
+ coverage
121
+ };
122
+ }
123
+ #asPinned(route) {
124
+ return {
125
+ ...route,
126
+ pinned: true
127
+ };
128
+ }
129
+ };
130
+ /**
131
+ * Reads the coverage and validates the v2 schema and RMv2 identity in one
132
+ * short-lived readonly connection. It never creates the log.
133
+ */
134
+ function inspectSQLiteChangeLog(lc, changeLogFile, identity) {
135
+ let db;
136
+ try {
137
+ db = new Database(lc.withContext("component", "sqlite-change-log-read-router"), changeLogFile, { readonly: true });
138
+ const meta = readChangeLogMeta(db);
139
+ if (meta.schemaVersion !== 4 || meta.epoch !== identity.epoch || meta.generation !== identity.generation || meta.replicaID !== identity.replicaID) return;
140
+ const bounds = readChangeLogBounds(db);
141
+ if (bounds.minWatermark === null || bounds.headWatermark === null) return;
142
+ return {
143
+ seededAtMs: meta.seededAtMs,
144
+ seedWatermark: meta.seedWatermark,
145
+ minWatermark: bounds.minWatermark,
146
+ headWatermark: bounds.headWatermark
147
+ };
148
+ } catch {
149
+ return;
150
+ } finally {
151
+ db?.close();
152
+ }
153
+ }
154
+ //#endregion
155
+ export { SQLiteChangeLogReadRouter, inspectSQLiteChangeLog };
156
+
157
+ //# sourceMappingURL=sqlite-change-log-read-router.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sqlite-change-log-read-router.js","names":["#shard","#readPercent","#coldReadPercent","#retentionMs","#inspect","#failureCooldownMs","#now","#pins","#choose","#asPinned","#permanentlyDisabled","#breakerUntilMs"],"sources":["../../../../../../zero-cache/src/services/change-streamer/sqlite-change-log-read-router.ts"],"sourcesContent":["import type {LogContext} from '@rocicorp/logger';\nimport {Database} from '../../../../zqlite/src/db.ts';\nimport type {ShardID} from '../../types/shards.ts';\nimport {\n CHANGE_LOG_DB_SCHEMA_VERSION,\n readChangeLogBounds,\n readChangeLogMeta,\n type ChangeLogIdentity,\n} from '../replicator/change-log-db.ts';\nimport {isSampledForShard} from './shard-sampling.ts';\n\nexport type ChangeLogReadSource = 'pg' | 'sqlite';\n\nexport type SQLiteChangeLogCoverage = {\n readonly seededAtMs: number;\n readonly seedWatermark: string;\n readonly minWatermark: string;\n readonly headWatermark: string;\n};\n\nexport type ChangeLogReadRouteReason =\n | 'selected'\n // Selected while the log was still inside its warm-up window. Kept\n // distinct from 'selected' so bootstrap serving is separable in metrics\n // from steady-state serving.\n | 'selected-cold'\n | 'percentage'\n | 'cold-log'\n | 'log-unavailable'\n | 'breaker-open'\n // Pinned to SQLite, then demoted because the log could not cover the\n // backup the reservation is restoring from. See {@link\n // SQLiteChangeLogReadRouter.demote}.\n | 'backup-uncovered';\n\nexport type ChangeLogReadRoute = {\n readonly source: ChangeLogReadSource;\n readonly reason: ChangeLogReadRouteReason;\n readonly pinned?: boolean | undefined;\n readonly coverage?: SQLiteChangeLogCoverage | undefined;\n};\n\nexport type SQLiteChangeLogReadRouterOptions = {\n readonly shard: ShardID;\n readonly readPercent: number;\n /**\n * Percentage of eligible tasks served from a log that has not yet aged\n * through {@link retentionMs}. Defaults to zero, which keeps every such\n * task on PG.\n */\n readonly coldReadPercent?: number | undefined;\n readonly retentionMs: number;\n readonly inspect: () => SQLiteChangeLogCoverage | undefined;\n readonly failureCooldownMs?: number | undefined;\n readonly now?: (() => number) | undefined;\n};\n\nconst DEFAULT_FAILURE_COOLDOWN_MS = 30_000;\n\n/**\n * Selects and pins the catchup source shared by `/snapshot` and `/changes`.\n *\n * A post-registration SQLite failure opens a process-local breaker. Requests\n * use PG during the cooldown; the first request after it expires is the\n * bounded probe. A successful readiness inspection closes the breaker, while\n * another unavailable result rearms it. A writer failure opens it permanently\n * because the writer remains disabled and its file stays absent for the life\n * of the process.\n */\nexport class SQLiteChangeLogReadRouter {\n readonly #shard: ShardID;\n readonly #readPercent: number;\n readonly #coldReadPercent: number;\n readonly #retentionMs: number;\n readonly #inspect: () => SQLiteChangeLogCoverage | undefined;\n readonly #failureCooldownMs: number;\n readonly #now: () => number;\n readonly #pins = new Map<string, ChangeLogReadRoute>();\n\n #breakerUntilMs: number | undefined;\n #permanentlyDisabled = false;\n\n constructor(opts: SQLiteChangeLogReadRouterOptions) {\n this.#shard = opts.shard;\n this.#readPercent = opts.readPercent;\n this.#coldReadPercent = opts.coldReadPercent ?? 0;\n this.#retentionMs = opts.retentionMs;\n this.#inspect = opts.inspect;\n this.#failureCooldownMs =\n opts.failureCooldownMs ?? DEFAULT_FAILURE_COOLDOWN_MS;\n this.#now = opts.now ?? Date.now;\n }\n\n /** Chooses and pins a source for a snapshot reservation. */\n pin(taskID: string): ChangeLogReadRoute {\n const route = this.#choose(taskID);\n this.#pins.set(taskID, route);\n return route;\n }\n\n /** Returns the source pinned by an active snapshot reservation. */\n peek(taskID: string): ChangeLogReadRoute | undefined {\n const route = this.#pins.get(taskID);\n return route && this.#asPinned(route);\n }\n\n /** Consumes a snapshot pin, or makes the stable choice for a direct retry. */\n consume(taskID: string): ChangeLogReadRoute {\n const route = this.#pins.get(taskID);\n if (route === undefined) {\n return this.#choose(taskID);\n }\n this.#pins.delete(taskID);\n return this.#asPinned(route);\n }\n\n /** Releases a pin whose snapshot reservation ended before subscribing. */\n release(taskID: string): void {\n this.#pins.delete(taskID);\n }\n\n /**\n * Replaces a pinned SQLite route with PG, for a reservation whose backup\n * the log cannot cover -- most often a log seeded after that backup.\n *\n * The pin moves, not just the reservation's advertised bounds. Confirming\n * with PG bounds while the task's `/changes` request still resolves to\n * SQLite is exactly the mismatch the reservation path exists to prevent,\n * so the two have to change together.\n *\n * A task with no pin is left alone: `consume` makes it a fresh choice.\n */\n demote(taskID: string): ChangeLogReadRoute {\n const route: ChangeLogReadRoute = {\n source: 'pg',\n reason: 'backup-uncovered',\n };\n if (this.#pins.has(taskID)) {\n this.#pins.set(taskID, route);\n }\n return route;\n }\n\n /** Opens the post-registration failure breaker. */\n trip(permanent = false): void {\n if (permanent) {\n this.#permanentlyDisabled = true;\n this.#breakerUntilMs = undefined;\n return;\n }\n if (!this.#permanentlyDisabled) {\n this.#breakerUntilMs = this.#now() + this.#failureCooldownMs;\n }\n }\n\n #choose(taskID: string): ChangeLogReadRoute {\n const now = this.#now();\n if (\n this.#permanentlyDisabled ||\n (this.#breakerUntilMs !== undefined && now < this.#breakerUntilMs)\n ) {\n return {source: 'pg', reason: 'breaker-open'};\n }\n\n // Inspect eligibility even at readPercent=0. Slice 11 starts its rollout\n // at zero specifically so warm/cold/unavailable rates are visible before\n // any subscriber is routed to SQLite.\n let coverage: SQLiteChangeLogCoverage | undefined;\n try {\n coverage = this.#inspect();\n } catch {\n coverage = undefined;\n }\n if (coverage === undefined) {\n // This was a breaker probe rather than ordinary startup unavailability.\n // Rearm it so a busy retry loop does not turn into an open/read loop.\n if (this.#breakerUntilMs !== undefined) {\n this.#breakerUntilMs = now + this.#failureCooldownMs;\n }\n return {source: 'pg', reason: 'log-unavailable'};\n }\n // A successful probe restores eligibility. This happens before the gates\n // below so that declining a task -- for a cold log or for the percentage\n // -- does not leave the cooldown armed against the next ordinary\n // unavailability.\n this.#breakerUntilMs = undefined;\n // A log seeded less than a retention window ago holds less history than\n // the purger promises to keep, so it is the case that most often cannot\n // cover a follower. It is also the only path that has no PG to fall back\n // to once PG is retired, which is the argument for exercising it now\n // rather than meeting it at the cutover: `coldReadPercent` is that dial.\n const cold = now - coverage.seededAtMs < this.#retentionMs;\n if (\n cold &&\n !isSampledForShard(this.#shard, taskID, this.#coldReadPercent)\n ) {\n return {source: 'pg', reason: 'cold-log', coverage};\n }\n\n // Keeping the percentage decision after readiness makes zero-percent\n // rollouts emit eligibility metrics without serving reads.\n if (!isSampledForShard(this.#shard, taskID, this.#readPercent)) {\n return {source: 'pg', reason: 'percentage', coverage};\n }\n return {\n source: 'sqlite',\n reason: cold ? 'selected-cold' : 'selected',\n coverage,\n };\n }\n\n #asPinned(route: ChangeLogReadRoute): ChangeLogReadRoute {\n return {...route, pinned: true};\n }\n}\n\n/**\n * Reads the coverage and validates the v2 schema and RMv2 identity in one\n * short-lived readonly connection. It never creates the log.\n */\nexport function inspectSQLiteChangeLog(\n lc: LogContext,\n changeLogFile: string,\n identity: ChangeLogIdentity,\n): SQLiteChangeLogCoverage | undefined {\n let db: Database | undefined;\n try {\n db = new Database(\n lc.withContext('component', 'sqlite-change-log-read-router'),\n changeLogFile,\n {readonly: true},\n );\n const meta = readChangeLogMeta(db);\n if (\n meta.schemaVersion !== CHANGE_LOG_DB_SCHEMA_VERSION ||\n meta.epoch !== identity.epoch ||\n meta.generation !== identity.generation ||\n meta.replicaID !== identity.replicaID\n ) {\n return undefined;\n }\n const bounds = readChangeLogBounds(db);\n if (bounds.minWatermark === null || bounds.headWatermark === null) {\n return undefined;\n }\n return {\n seededAtMs: meta.seededAtMs,\n seedWatermark: meta.seedWatermark,\n minWatermark: bounds.minWatermark,\n headWatermark: bounds.headWatermark,\n };\n } catch {\n return undefined;\n } finally {\n db?.close();\n }\n}\n"],"mappings":"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"}
@@ -55,7 +55,7 @@ export declare class SQLiteChangeLogReader implements Disposable {
55
55
  #private;
56
56
  constructor(lc: LogContext, changeLogFile: string);
57
57
  plan(fromWatermark: string): CatchupPlan;
58
- read(fromWatermark: string, throughWatermark: string, batchSize: number, signal?: AbortSignal | undefined): AsyncIterable<readonly WatermarkedChange[]>;
58
+ read(fromWatermark: string, throughWatermark: string, batchSize: number, signal?: AbortSignal | undefined, maxRows?: number | undefined): AsyncIterable<readonly WatermarkedChange[]>;
59
59
  close(): void;
60
60
  [Symbol.dispose](): void;
61
61
  }
@@ -1 +1 @@
1
- {"version":3,"file":"sqlite-change-log-reader.d.ts","sourceRoot":"","sources":["../../../../../../zero-cache/src/services/change-streamer/sqlite-change-log-reader.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAC,UAAU,EAAC,MAAM,kBAAkB,CAAC;AASjD,OAAO,KAAK,EAAC,iBAAiB,EAAC,MAAM,sBAAsB,CAAC;AAE5D,MAAM,MAAM,WAAW,GACnB;IACE,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC;IACvB,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;CAChC,GACD;IAAC,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC;IAAC,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAA;CAAC,GACxD;IACE,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAC;IACzB,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;CAChC,GAKD;IAAC,QAAQ,CAAC,IAAI,EAAE,WAAW,CAAA;CAAC,CAAC;AAiBjC;;;;GAIG;AACH,eAAO,MAAM,8BAA8B,uNAQ1C,CAAC;AAEF;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,0BAA0B,ucAOtC,CAAC;AAEF,2EAA2E;AAC3E,eAAO,MAAM,gCAAgC,wOAW5C,CAAC;AAMF;;;;;;;;;;;;;;GAcG;AACH,qBAAa,qBAAsB,YAAW,UAAU;;gBAK1C,EAAE,EAAE,UAAU,EAAE,aAAa,EAAE,MAAM;IAQjD,IAAI,CAAC,aAAa,EAAE,MAAM,GAAG,WAAW;IAqBjC,IAAI,CACT,aAAa,EAAE,MAAM,EACrB,gBAAgB,EAAE,MAAM,EACxB,SAAS,EAAE,MAAM,EACjB,MAAM,CAAC,EAAE,WAAW,GAAG,SAAS,GAC/B,aAAa,CAAC,SAAS,iBAAiB,EAAE,CAAC;IA2D9C,KAAK,IAAI,IAAI;IAQb,CAAC,MAAM,CAAC,OAAO,CAAC,IAAI,IAAI;CAuCzB"}
1
+ {"version":3,"file":"sqlite-change-log-reader.d.ts","sourceRoot":"","sources":["../../../../../../zero-cache/src/services/change-streamer/sqlite-change-log-reader.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAC,UAAU,EAAC,MAAM,kBAAkB,CAAC;AASjD,OAAO,KAAK,EAAC,iBAAiB,EAAC,MAAM,sBAAsB,CAAC;AAE5D,MAAM,MAAM,WAAW,GACnB;IACE,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC;IACvB,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;CAChC,GACD;IAAC,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC;IAAC,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAA;CAAC,GACxD;IACE,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAC;IACzB,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;CAChC,GAKD;IAAC,QAAQ,CAAC,IAAI,EAAE,WAAW,CAAA;CAAC,CAAC;AAiBjC;;;;GAIG;AACH,eAAO,MAAM,8BAA8B,uNAQ1C,CAAC;AAEF;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,0BAA0B,ucAOtC,CAAC;AAEF,2EAA2E;AAC3E,eAAO,MAAM,gCAAgC,wOAW5C,CAAC;AAMF;;;;;;;;;;;;;;GAcG;AACH,qBAAa,qBAAsB,YAAW,UAAU;;gBAK1C,EAAE,EAAE,UAAU,EAAE,aAAa,EAAE,MAAM;IAQjD,IAAI,CAAC,aAAa,EAAE,MAAM,GAAG,WAAW;IAqBjC,IAAI,CACT,aAAa,EAAE,MAAM,EACrB,gBAAgB,EAAE,MAAM,EACxB,SAAS,EAAE,MAAM,EACjB,MAAM,CAAC,EAAE,WAAW,GAAG,SAAS,EAChC,OAAO,CAAC,EAAE,MAAM,GAAG,SAAS,GAC3B,aAAa,CAAC,SAAS,iBAAiB,EAAE,CAAC;IA6E9C,KAAK,IAAI,IAAI;IAQb,CAAC,MAAM,CAAC,OAAO,CAAC,IAAI,IAAI;CAuCzB"}
@@ -99,17 +99,21 @@ var SQLiteChangeLogReader = class {
99
99
  headWatermark
100
100
  };
101
101
  }
102
- async *read(fromWatermark, throughWatermark, batchSize, signal) {
102
+ async *read(fromWatermark, throughWatermark, batchSize, signal, maxRows) {
103
103
  assert(Number.isSafeInteger(batchSize) && batchSize > 0, "SQLite change log batch size must be a positive safe integer");
104
+ assert(maxRows === void 0 || Number.isSafeInteger(maxRows) && maxRows > 0, "SQLite change log row limit must be a positive safe integer");
104
105
  this.#assertOpen();
105
106
  const statements = this.#prepare();
106
107
  assert(statements !== void 0, "SQLite change log must be ready before it is read");
107
108
  let lastWatermark = fromWatermark;
108
109
  let lastPos = Number.MAX_SAFE_INTEGER;
109
110
  let lastTag;
111
+ let rowsRead = 0;
110
112
  while (true) {
111
113
  this.#throwIfAborted(signal);
112
- const rows = statements.readBatch.all(lastWatermark, lastPos, throughWatermark, batchSize);
114
+ const remainingRows = maxRows === void 0 ? batchSize : maxRows - rowsRead;
115
+ if (remainingRows === 0) break;
116
+ const rows = statements.readBatch.all(lastWatermark, lastPos, throughWatermark, Math.min(batchSize, remainingRows));
113
117
  this.#throwIfAborted(signal);
114
118
  if (rows.length === 0) break;
115
119
  const changes = rows.map(reconstructWatermarkedChange);
@@ -118,9 +122,10 @@ var SQLiteChangeLogReader = class {
118
122
  lastWatermark = last.watermark;
119
123
  lastPos = last.pos;
120
124
  lastTag = last.tag;
125
+ rowsRead += rows.length;
121
126
  yield changes;
122
127
  }
123
- if (fromWatermark !== throughWatermark) assert(lastWatermark === throughWatermark && lastTag === "commit", () => `SQLite change log did not end at pinned head ${throughWatermark}`);
128
+ if (fromWatermark !== throughWatermark && (maxRows === void 0 || rowsRead < maxRows)) assert(lastWatermark === throughWatermark && lastTag === "commit", () => `SQLite change log did not end at pinned head ${throughWatermark}`);
124
129
  }
125
130
  close() {
126
131
  if (this.#closed) return;
@@ -1 +1 @@
1
- {"version":3,"file":"sqlite-change-log-reader.js","names":["#db","#assertOpen","#prepare","#throwIfAborted","#closed","#statements","#streamTableExists"],"sources":["../../../../../../zero-cache/src/services/change-streamer/sqlite-change-log-reader.ts"],"sourcesContent":["import type {LogContext} from '@rocicorp/logger';\nimport {AbortError} from '../../../../shared/src/abort-error.ts';\nimport {assert} from '../../../../shared/src/asserts.ts';\nimport {Database, type Statement} from '../../../../zqlite/src/db.ts';\nimport {CHANGE_LOG_STREAM_TABLE} from '../replicator/change-log-db.ts';\nimport {\n reconstructWatermarkedChange,\n type ChangeLogEntry,\n} from './change-log-codec.ts';\nimport type {WatermarkedChange} from './change-streamer.ts';\n\nexport type CatchupPlan =\n | {\n readonly kind: 'range';\n readonly minWatermark: string;\n readonly headWatermark: string;\n }\n | {readonly kind: 'ahead'; readonly headWatermark: string}\n | {\n readonly kind: 'too-old';\n readonly minWatermark: string;\n readonly headWatermark: string;\n }\n // The log exists as a file but has no content to serve: the replicator has\n // not created or reconciled it yet. Subscriber selection treats this as\n // \"decline and fall back to PG catchup\", which is why it is a plan rather\n // than an error.\n | {readonly kind: 'not-ready'};\n\ntype PlanRow = {\n readonly headWatermark: string | null;\n readonly minWatermark: string | null;\n readonly boundaryExists: number;\n};\n\ntype ChangeLogRow = ChangeLogEntry & {\n readonly pos: number;\n};\n\ntype PreparedStatements = {\n readonly plan: Statement;\n readonly readBatch: Statement;\n};\n\n/**\n * Seeks to the final position so validating a boundary does not scan every row\n * of a large transaction. Exported so the focused query-plan test covers the\n * production subquery.\n */\nexport const SQLITE_CHANGE_LOG_BOUNDARY_SQL = /*sql*/ `\n SELECT\n \"precommit\" = @fromWatermark AND\n \"tag\" = 'commit' AS \"boundaryExists\"\n FROM \"${CHANGE_LOG_STREAM_TABLE}\"\n WHERE \"watermark\" = @fromWatermark\n ORDER BY \"pos\" DESC\n LIMIT 1\n`;\n\n/**\n * The log describes its own bounds; nothing here reads the replica.\n * `max(\"watermark\")` is the head because a transaction's rows commit atomically,\n * so the log never ends mid-transaction.\n *\n * `min` and `max` are deliberately kept as *separate* scalar subqueries: SQLite\n * applies its single-aggregate index optimization only when the aggregate is\n * alone in its query, so `SELECT min(x), max(x) FROM t` degrades to a full scan\n * while two scalar subqueries are two index seeks. The query-plan test pins\n * this.\n *\n * Exported so that test can cover the production query.\n */\nexport const SQLITE_CHANGE_LOG_PLAN_SQL = /*sql*/ `\n SELECT\n (SELECT max(\"watermark\") FROM \"${CHANGE_LOG_STREAM_TABLE}\")\n AS \"headWatermark\",\n (SELECT min(\"watermark\") FROM \"${CHANGE_LOG_STREAM_TABLE}\")\n AS \"minWatermark\",\n coalesce((${SQLITE_CHANGE_LOG_BOUNDARY_SQL}), 0) AS \"boundaryExists\"\n`;\n\n/** Exported so the focused query-plan test covers the production query. */\nexport const SQLITE_CHANGE_LOG_READ_BATCH_SQL = /*sql*/ `\n SELECT\n \"watermark\",\n \"pos\",\n \"tag\",\n \"change\"\n FROM \"${CHANGE_LOG_STREAM_TABLE}\"\n WHERE (\"watermark\", \"pos\") > (?, ?)\n AND \"watermark\" <= ?\n ORDER BY \"watermark\", \"pos\"\n LIMIT ?\n`;\n\nconst TABLE_EXISTS_SQL = /*sql*/ `\n SELECT 1 FROM \"sqlite_master\" WHERE \"type\" = 'table' AND \"name\" = ?\n`;\n\n/**\n * Reads the change log without retaining a SQLite snapshot while a consumer\n * processes a batch.\n *\n * The log lives in its own database beside the replica\n * (`changeLogFileName(replicaFile)`), which this reader opens read-only; it\n * never opens the replica. The connection therefore keeps the journal mode\n * configured by the change-log writer. Callers pin a head with {@link plan}\n * before passing it to {@link read}.\n *\n * A change-streamer can start before the replicator has created the log, so the\n * stream table may not exist yet. Statements are prepared on first use rather\n * than in the constructor, and {@link plan} reports `not-ready` until they can\n * be; a reader constructed too early becomes usable once the writer catches up.\n */\nexport class SQLiteChangeLogReader implements Disposable {\n readonly #db: Database;\n #statements: PreparedStatements | undefined;\n #closed = false;\n\n constructor(lc: LogContext, changeLogFile: string) {\n this.#db = new Database(\n lc.withContext('component', 'sqlite-change-log-reader'),\n changeLogFile,\n {readonly: true},\n );\n }\n\n plan(fromWatermark: string): CatchupPlan {\n this.#assertOpen();\n const statements = this.#prepare();\n if (statements === undefined) {\n return {kind: 'not-ready'};\n }\n const row = statements.plan.get<PlanRow>({fromWatermark});\n if (row.headWatermark === null || row.minWatermark === null) {\n return {kind: 'not-ready'};\n }\n const {headWatermark, minWatermark} = row;\n\n if (fromWatermark > headWatermark) {\n return {kind: 'ahead', headWatermark};\n }\n if (fromWatermark < minWatermark || row.boundaryExists === 0) {\n return {kind: 'too-old', minWatermark, headWatermark};\n }\n return {kind: 'range', minWatermark, headWatermark};\n }\n\n async *read(\n fromWatermark: string,\n throughWatermark: string,\n batchSize: number,\n signal?: AbortSignal | undefined,\n ): AsyncIterable<readonly WatermarkedChange[]> {\n assert(\n Number.isSafeInteger(batchSize) && batchSize > 0,\n 'SQLite change log batch size must be a positive safe integer',\n );\n this.#assertOpen();\n const statements = this.#prepare();\n assert(\n statements !== undefined,\n 'SQLite change log must be ready before it is read',\n );\n\n let lastWatermark = fromWatermark;\n // The first query must be strictly after the requested transaction. Every\n // real stream position is far below this sentinel.\n let lastPos = Number.MAX_SAFE_INTEGER;\n let lastTag: string | undefined;\n\n while (true) {\n this.#throwIfAborted(signal);\n const rows = statements.readBatch.all<ChangeLogRow>(\n lastWatermark,\n lastPos,\n throughWatermark,\n batchSize,\n );\n this.#throwIfAborted(signal);\n\n if (rows.length === 0) {\n break;\n }\n\n const changes = rows.map(reconstructWatermarkedChange);\n const last = rows.at(-1);\n assert(last !== undefined, 'non-empty batch must have a final row');\n lastWatermark = last.watermark;\n lastPos = last.pos;\n lastTag = last.tag;\n\n // all() has exhausted the SELECT and closed its implicit read\n // transaction. This yield therefore cannot pin the WAL while subscriber\n // flow control is pending.\n yield changes;\n }\n\n // `throughWatermark` was pinned from `max(\"watermark\")` of this same table,\n // so reaching it no longer depends on the log agreeing with the replica's\n // `_zero.replicationState`, which is what this assertion used to guard.\n // What remains is a concurrent purge of the head transaction, which the\n // purge policy (never past the latest durable transaction) does not do.\n if (fromWatermark !== throughWatermark) {\n assert(\n lastWatermark === throughWatermark && lastTag === 'commit',\n () =>\n `SQLite change log did not end at pinned head ${throughWatermark}`,\n );\n }\n }\n\n close(): void {\n if (this.#closed) {\n return;\n }\n this.#closed = true;\n this.#db.close();\n }\n\n [Symbol.dispose](): void {\n this.close();\n }\n\n /**\n * Prepares the statements once the stream table exists, memoizing them.\n * Returns `undefined` while the log has not been created, which is a normal\n * startup state rather than an error.\n */\n #prepare(): PreparedStatements | undefined {\n if (this.#statements === undefined && this.#streamTableExists()) {\n this.#statements = {\n plan: this.#db.prepare(SQLITE_CHANGE_LOG_PLAN_SQL),\n readBatch: this.#db.prepare(SQLITE_CHANGE_LOG_READ_BATCH_SQL),\n };\n }\n return this.#statements;\n }\n\n #streamTableExists(): boolean {\n return (\n this.#db\n .prepare(TABLE_EXISTS_SQL)\n .get<{1: number} | undefined>(CHANGE_LOG_STREAM_TABLE) !== undefined\n );\n }\n\n #assertOpen(): void {\n if (this.#closed) {\n throw new AbortError('SQLite change log reader is closed');\n }\n }\n\n #throwIfAborted(signal: AbortSignal | undefined): void {\n this.#assertOpen();\n if (signal?.aborted) {\n throw new AbortError('SQLite change log read aborted');\n }\n }\n}\n"],"mappings":";;;;;;;;;;;AAiDA,IAAa,iCAAyC;;;;UAI5C,wBAAwB;;;;;;;;;;;;;;;;;;AAmBlC,IAAa,6BAAqC;;qCAEb,wBAAwB;;qCAExB,wBAAwB;;gBAE7C,+BAA+B;;;AAI/C,IAAa,mCAA2C;;;;;;UAM9C,wBAAwB;;;;;;AAOlC,IAAM,mBAA2B;;;;;;;;;;;;;;;;;;AAmBjC,IAAa,wBAAb,MAAyD;CACvD;CACA;CACA,UAAU;CAEV,YAAY,IAAgB,eAAuB;EACjD,KAAKA,MAAM,IAAI,SACb,GAAG,YAAY,aAAa,0BAA0B,GACtD,eACA,EAAC,UAAU,KAAI,CACjB;CACF;CAEA,KAAK,eAAoC;EACvC,KAAKC,YAAY;EACjB,MAAM,aAAa,KAAKC,SAAS;EACjC,IAAI,eAAe,KAAA,GACjB,OAAO,EAAC,MAAM,YAAW;EAE3B,MAAM,MAAM,WAAW,KAAK,IAAa,EAAC,cAAa,CAAC;EACxD,IAAI,IAAI,kBAAkB,QAAQ,IAAI,iBAAiB,MACrD,OAAO,EAAC,MAAM,YAAW;EAE3B,MAAM,EAAC,eAAe,iBAAgB;EAEtC,IAAI,gBAAgB,eAClB,OAAO;GAAC,MAAM;GAAS;EAAa;EAEtC,IAAI,gBAAgB,gBAAgB,IAAI,mBAAmB,GACzD,OAAO;GAAC,MAAM;GAAW;GAAc;EAAa;EAEtD,OAAO;GAAC,MAAM;GAAS;GAAc;EAAa;CACpD;CAEA,OAAO,KACL,eACA,kBACA,WACA,QAC6C;EAC7C,OACE,OAAO,cAAc,SAAS,KAAK,YAAY,GAC/C,8DACF;EACA,KAAKD,YAAY;EACjB,MAAM,aAAa,KAAKC,SAAS;EACjC,OACE,eAAe,KAAA,GACf,mDACF;EAEA,IAAI,gBAAgB;EAGpB,IAAI,UAAU,OAAO;EACrB,IAAI;EAEJ,OAAO,MAAM;GACX,KAAKC,gBAAgB,MAAM;GAC3B,MAAM,OAAO,WAAW,UAAU,IAChC,eACA,SACA,kBACA,SACF;GACA,KAAKA,gBAAgB,MAAM;GAE3B,IAAI,KAAK,WAAW,GAClB;GAGF,MAAM,UAAU,KAAK,IAAI,4BAA4B;GACrD,MAAM,OAAO,KAAK,GAAG,EAAE;GACvB,OAAO,SAAS,KAAA,GAAW,uCAAuC;GAClE,gBAAgB,KAAK;GACrB,UAAU,KAAK;GACf,UAAU,KAAK;GAKf,MAAM;EACR;EAOA,IAAI,kBAAkB,kBACpB,OACE,kBAAkB,oBAAoB,YAAY,gBAEhD,gDAAgD,kBACpD;CAEJ;CAEA,QAAc;EACZ,IAAI,KAAKC,SACP;EAEF,KAAKA,UAAU;EACf,KAAKJ,IAAI,MAAM;CACjB;CAEA,CAAC,OAAO,WAAiB;EACvB,KAAK,MAAM;CACb;;;;;;CAOA,WAA2C;EACzC,IAAI,KAAKK,gBAAgB,KAAA,KAAa,KAAKC,mBAAmB,GAC5D,KAAKD,cAAc;GACjB,MAAM,KAAKL,IAAI,QAAQ,0BAA0B;GACjD,WAAW,KAAKA,IAAI,QAAQ,gCAAgC;EAC9D;EAEF,OAAO,KAAKK;CACd;CAEA,qBAA8B;EAC5B,OACE,KAAKL,IACF,QAAQ,gBAAgB,EACxB,IAA6B,uBAAuB,MAAM,KAAA;CAEjE;CAEA,cAAoB;EAClB,IAAI,KAAKI,SACP,MAAM,IAAI,WAAW,oCAAoC;CAE7D;CAEA,gBAAgB,QAAuC;EACrD,KAAKH,YAAY;EACjB,IAAI,QAAQ,SACV,MAAM,IAAI,WAAW,gCAAgC;CAEzD;AACF"}
1
+ {"version":3,"file":"sqlite-change-log-reader.js","names":["#db","#assertOpen","#prepare","#throwIfAborted","#closed","#statements","#streamTableExists"],"sources":["../../../../../../zero-cache/src/services/change-streamer/sqlite-change-log-reader.ts"],"sourcesContent":["import type {LogContext} from '@rocicorp/logger';\nimport {AbortError} from '../../../../shared/src/abort-error.ts';\nimport {assert} from '../../../../shared/src/asserts.ts';\nimport {Database, type Statement} from '../../../../zqlite/src/db.ts';\nimport {CHANGE_LOG_STREAM_TABLE} from '../replicator/change-log-db.ts';\nimport {\n reconstructWatermarkedChange,\n type ChangeLogEntry,\n} from './change-log-codec.ts';\nimport type {WatermarkedChange} from './change-streamer.ts';\n\nexport type CatchupPlan =\n | {\n readonly kind: 'range';\n readonly minWatermark: string;\n readonly headWatermark: string;\n }\n | {readonly kind: 'ahead'; readonly headWatermark: string}\n | {\n readonly kind: 'too-old';\n readonly minWatermark: string;\n readonly headWatermark: string;\n }\n // The log exists as a file but has no content to serve: the replicator has\n // not created or reconciled it yet. Subscriber selection treats this as\n // \"decline and fall back to PG catchup\", which is why it is a plan rather\n // than an error.\n | {readonly kind: 'not-ready'};\n\ntype PlanRow = {\n readonly headWatermark: string | null;\n readonly minWatermark: string | null;\n readonly boundaryExists: number;\n};\n\ntype ChangeLogRow = ChangeLogEntry & {\n readonly pos: number;\n};\n\ntype PreparedStatements = {\n readonly plan: Statement;\n readonly readBatch: Statement;\n};\n\n/**\n * Seeks to the final position so validating a boundary does not scan every row\n * of a large transaction. Exported so the focused query-plan test covers the\n * production subquery.\n */\nexport const SQLITE_CHANGE_LOG_BOUNDARY_SQL = /*sql*/ `\n SELECT\n \"precommit\" = @fromWatermark AND\n \"tag\" = 'commit' AS \"boundaryExists\"\n FROM \"${CHANGE_LOG_STREAM_TABLE}\"\n WHERE \"watermark\" = @fromWatermark\n ORDER BY \"pos\" DESC\n LIMIT 1\n`;\n\n/**\n * The log describes its own bounds; nothing here reads the replica.\n * `max(\"watermark\")` is the head because a transaction's rows commit atomically,\n * so the log never ends mid-transaction.\n *\n * `min` and `max` are deliberately kept as *separate* scalar subqueries: SQLite\n * applies its single-aggregate index optimization only when the aggregate is\n * alone in its query, so `SELECT min(x), max(x) FROM t` degrades to a full scan\n * while two scalar subqueries are two index seeks. The query-plan test pins\n * this.\n *\n * Exported so that test can cover the production query.\n */\nexport const SQLITE_CHANGE_LOG_PLAN_SQL = /*sql*/ `\n SELECT\n (SELECT max(\"watermark\") FROM \"${CHANGE_LOG_STREAM_TABLE}\")\n AS \"headWatermark\",\n (SELECT min(\"watermark\") FROM \"${CHANGE_LOG_STREAM_TABLE}\")\n AS \"minWatermark\",\n coalesce((${SQLITE_CHANGE_LOG_BOUNDARY_SQL}), 0) AS \"boundaryExists\"\n`;\n\n/** Exported so the focused query-plan test covers the production query. */\nexport const SQLITE_CHANGE_LOG_READ_BATCH_SQL = /*sql*/ `\n SELECT\n \"watermark\",\n \"pos\",\n \"tag\",\n \"change\"\n FROM \"${CHANGE_LOG_STREAM_TABLE}\"\n WHERE (\"watermark\", \"pos\") > (?, ?)\n AND \"watermark\" <= ?\n ORDER BY \"watermark\", \"pos\"\n LIMIT ?\n`;\n\nconst TABLE_EXISTS_SQL = /*sql*/ `\n SELECT 1 FROM \"sqlite_master\" WHERE \"type\" = 'table' AND \"name\" = ?\n`;\n\n/**\n * Reads the change log without retaining a SQLite snapshot while a consumer\n * processes a batch.\n *\n * The log lives in its own database beside the replica\n * (`changeLogFileName(replicaFile)`), which this reader opens read-only; it\n * never opens the replica. The connection therefore keeps the journal mode\n * configured by the change-log writer. Callers pin a head with {@link plan}\n * before passing it to {@link read}.\n *\n * A change-streamer can start before the replicator has created the log, so the\n * stream table may not exist yet. Statements are prepared on first use rather\n * than in the constructor, and {@link plan} reports `not-ready` until they can\n * be; a reader constructed too early becomes usable once the writer catches up.\n */\nexport class SQLiteChangeLogReader implements Disposable {\n readonly #db: Database;\n #statements: PreparedStatements | undefined;\n #closed = false;\n\n constructor(lc: LogContext, changeLogFile: string) {\n this.#db = new Database(\n lc.withContext('component', 'sqlite-change-log-reader'),\n changeLogFile,\n {readonly: true},\n );\n }\n\n plan(fromWatermark: string): CatchupPlan {\n this.#assertOpen();\n const statements = this.#prepare();\n if (statements === undefined) {\n return {kind: 'not-ready'};\n }\n const row = statements.plan.get<PlanRow>({fromWatermark});\n if (row.headWatermark === null || row.minWatermark === null) {\n return {kind: 'not-ready'};\n }\n const {headWatermark, minWatermark} = row;\n\n if (fromWatermark > headWatermark) {\n return {kind: 'ahead', headWatermark};\n }\n if (fromWatermark < minWatermark || row.boundaryExists === 0) {\n return {kind: 'too-old', minWatermark, headWatermark};\n }\n return {kind: 'range', minWatermark, headWatermark};\n }\n\n async *read(\n fromWatermark: string,\n throughWatermark: string,\n batchSize: number,\n signal?: AbortSignal | undefined,\n maxRows?: number | undefined,\n ): AsyncIterable<readonly WatermarkedChange[]> {\n assert(\n Number.isSafeInteger(batchSize) && batchSize > 0,\n 'SQLite change log batch size must be a positive safe integer',\n );\n assert(\n maxRows === undefined || (Number.isSafeInteger(maxRows) && maxRows > 0),\n 'SQLite change log row limit must be a positive safe integer',\n );\n this.#assertOpen();\n const statements = this.#prepare();\n assert(\n statements !== undefined,\n 'SQLite change log must be ready before it is read',\n );\n\n let lastWatermark = fromWatermark;\n // The first query must be strictly after the requested transaction. Every\n // real stream position is far below this sentinel.\n let lastPos = Number.MAX_SAFE_INTEGER;\n let lastTag: string | undefined;\n let rowsRead = 0;\n\n while (true) {\n this.#throwIfAborted(signal);\n const remainingRows =\n maxRows === undefined ? batchSize : maxRows - rowsRead;\n if (remainingRows === 0) {\n break;\n }\n const rows = statements.readBatch.all<ChangeLogRow>(\n lastWatermark,\n lastPos,\n throughWatermark,\n Math.min(batchSize, remainingRows),\n );\n this.#throwIfAborted(signal);\n\n if (rows.length === 0) {\n break;\n }\n\n const changes = rows.map(reconstructWatermarkedChange);\n const last = rows.at(-1);\n assert(last !== undefined, 'non-empty batch must have a final row');\n lastWatermark = last.watermark;\n lastPos = last.pos;\n lastTag = last.tag;\n rowsRead += rows.length;\n\n // all() has exhausted the SELECT and closed its implicit read\n // transaction. This yield therefore cannot pin the WAL while subscriber\n // flow control is pending.\n yield changes;\n }\n\n // `throughWatermark` was pinned from `max(\"watermark\")` of this same table,\n // so reaching it no longer depends on the log agreeing with the replica's\n // `_zero.replicationState`, which is what this assertion used to guard.\n // What remains is a concurrent purge of the head transaction, which the\n // purge policy (never past the latest durable transaction) does not do.\n // A read that stopped at `maxRows` cannot know whether more rows follow,\n // so completeness is only asserted for a read that ran out on its own.\n // Detecting a short bounded read is left to the caller, which set the\n // limit and is the only side that knows what a full range should hold.\n if (\n fromWatermark !== throughWatermark &&\n (maxRows === undefined || rowsRead < maxRows)\n ) {\n assert(\n lastWatermark === throughWatermark && lastTag === 'commit',\n () =>\n `SQLite change log did not end at pinned head ${throughWatermark}`,\n );\n }\n }\n\n close(): void {\n if (this.#closed) {\n return;\n }\n this.#closed = true;\n this.#db.close();\n }\n\n [Symbol.dispose](): void {\n this.close();\n }\n\n /**\n * Prepares the statements once the stream table exists, memoizing them.\n * Returns `undefined` while the log has not been created, which is a normal\n * startup state rather than an error.\n */\n #prepare(): PreparedStatements | undefined {\n if (this.#statements === undefined && this.#streamTableExists()) {\n this.#statements = {\n plan: this.#db.prepare(SQLITE_CHANGE_LOG_PLAN_SQL),\n readBatch: this.#db.prepare(SQLITE_CHANGE_LOG_READ_BATCH_SQL),\n };\n }\n return this.#statements;\n }\n\n #streamTableExists(): boolean {\n return (\n this.#db\n .prepare(TABLE_EXISTS_SQL)\n .get<{1: number} | undefined>(CHANGE_LOG_STREAM_TABLE) !== undefined\n );\n }\n\n #assertOpen(): void {\n if (this.#closed) {\n throw new AbortError('SQLite change log reader is closed');\n }\n }\n\n #throwIfAborted(signal: AbortSignal | undefined): void {\n this.#assertOpen();\n if (signal?.aborted) {\n throw new AbortError('SQLite change log read aborted');\n }\n }\n}\n"],"mappings":"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"}
@@ -8,6 +8,7 @@ import type { UpstreamStatusMessage } from '../change-source/protocol/current/st
8
8
  import { type CookieSet } from '../replicator/change-log-cookies.ts';
9
9
  import type { ReplicatorMode } from '../replicator/replicator.ts';
10
10
  import type { Service } from '../service.ts';
11
+ import { type ChangeLogEntry } from './change-log-codec.ts';
11
12
  import type { Subscriber } from './subscriber.ts';
12
13
  export type TuningOptions = {
13
14
  backPressureLimitHeapProportion: number;
@@ -73,6 +74,28 @@ export declare class Storer implements Service {
73
74
  cookies: CookieSet;
74
75
  }>;
75
76
  getMinWatermarkForCatchup(): Promise<string | null>;
77
+ /**
78
+ * Returns the retained Postgres catchup bounds.
79
+ * `minWatermark` is the oldest transaction, or `null` for a new replica.
80
+ * `lastWatermark` is the durable head.
81
+ */
82
+ getCatchupBounds(): Promise<{
83
+ minWatermark: string | null;
84
+ lastWatermark: string;
85
+ }>;
86
+ /**
87
+ * Lists at most `limit` committed transaction watermarks in
88
+ * `(afterWatermark, throughWatermark]`, in ascending order.
89
+ */
90
+ listCommitWatermarks(afterWatermark: string, throughWatermark: string, limit: number): Promise<string[]>;
91
+ /**
92
+ * Reads `(afterWatermark, throughWatermark]` in stream order and bounded batches.
93
+ * This query matches the subscriber catchup query.
94
+ *
95
+ * Both queries fail when an escaped NUL reaches `change->'tag'`.
96
+ * Postgres cannot convert that value to text.
97
+ */
98
+ readCatchupRange(afterWatermark: string, throughWatermark: string, batchRows?: number): AsyncIterable<ChangeLogEntry[]>;
76
99
  purgeRecordsBefore(watermark: string): Promise<number>;
77
100
  /**
78
101
  * @returns The JSON stringified stream message to be sent downstream.
@@ -1 +1 @@
1
- {"version":3,"file":"storer.d.ts","sourceRoot":"","sources":["../../../../../../zero-cache/src/services/change-streamer/storer.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAC,UAAU,EAAC,MAAM,kBAAkB,CAAC;AAUjD,OAAO,EAAC,eAAe,EAAC,MAAM,8BAA8B,CAAC;AAC7D,OAAO,EAAC,KAAK,UAAU,EAA2B,MAAM,mBAAmB,CAAC;AAC5E,OAAO,EAAY,KAAK,OAAO,EAAC,MAAM,uBAAuB,CAAC;AAE9D,OAAO,EAKL,KAAK,eAAe,EAMrB,MAAM,sCAAsC,CAAC;AAC9C,OAAO,EACL,KAAK,gBAAgB,EACrB,KAAK,MAAM,EACZ,MAAM,iDAAiD,CAAC;AACzD,OAAO,KAAK,EAAC,qBAAqB,EAAC,MAAM,6CAA6C,CAAC;AACvF,OAAO,EAIL,KAAK,SAAS,EAEf,MAAM,qCAAqC,CAAC;AAC7C,OAAO,KAAK,EAAC,cAAc,EAAC,MAAM,6BAA6B,CAAC;AAChE,OAAO,KAAK,EAAC,OAAO,EAAC,MAAM,eAAe,CAAC;AAc3C,OAAO,KAAK,EAAC,UAAU,EAAC,MAAM,iBAAiB,CAAC;AAsChD,MAAM,MAAM,aAAa,GAAG;IAC1B,+BAA+B,EAAE,MAAM,CAAC;IACxC,kBAAkB,EAAE,MAAM,CAAC;IAC3B,kBAAkB,EAAE,MAAM,CAAC;IAC3B,cAAc,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CACrC,CAAC;AAaF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,qBAAa,MAAO,YAAW,OAAO;;IACpC,QAAQ,CAAC,EAAE,YAAY;gBAoBrB,EAAE,EAAE,UAAU,EACd,KAAK,EAAE,OAAO,EACd,MAAM,EAAE,MAAM,EACd,gBAAgB,EAAE,MAAM,EACxB,iBAAiB,EAAE,MAAM,EACzB,EAAE,EAAE,UAAU,EACd,cAAc,EAAE,MAAM,EACtB,WAAW,EAAE,CAAC,CAAC,EAAE,MAAM,GAAG,qBAAqB,KAAK,IAAI,EACxD,OAAO,EAAE,CAAC,GAAG,EAAE,KAAK,KAAK,IAAI,EAC7B,EACE,+BAA+B,EAC/B,kBAAkB,EAClB,kBAAkB,EAClB,cAAuB,GACxB,EAAE,aAAa;IAiCZ,eAAe,CAAC,SAAS,CAAC,EAAE,SAAS,GAAG,IAAI;IA2BlD;;;;;;;;;;;;;;;;OAgBG;IACG,sCAAsC,IAAI,OAAO,CAAC;QACtD,aAAa,EAAE,MAAM,CAAC;QACtB,gBAAgB,EAAE,eAAe,EAAE,CAAC;QACpC,OAAO,EAAE,SAAS,CAAC;KACpB,CAAC;IAoDI,yBAAyB,IAAI,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC;IAOzD,kBAAkB,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAyCtD;;OAEG;IACH,KAAK,CAAC,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE,gBAAgB;IAkB/C,KAAK;IAIL,OAAO,CAAC,UAAU,EAAE,UAAU,EAAE,IAAI,EAAE,cAAc;IAOpD,YAAY,IAAI,OAAO,CAAC,IAAI,CAAC,GAAG,SAAS;IA6FzC;;;OAGG;IACG,GAAG;IAmdT;;;OAGG;IACG,YAAY;IAQlB;;;;OAIG;IACG,IAAI;CAcX;AAED,qBAAa,SAAS;;IAGpB,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAC;IAChC,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;gBAG5B,EAAE,EAAE,UAAU,EACd,EAAE,EAAE,eAAe,EACnB,cAAc,EAAE,MAAM,EACtB,SAAS,EAAE,MAAM;IAUb,OAAO;CAWd;AAED,qBAAa,WAAW;;gBAKV,EAAE,EAAE,UAAU,EAAE,KAAK,EAAE,OAAO,EAAE,EAAE,EAAE,UAAU;IAWpD,OAAO;CA4Bd"}
1
+ {"version":3,"file":"storer.d.ts","sourceRoot":"","sources":["../../../../../../zero-cache/src/services/change-streamer/storer.ts"],"names":[],"mappings":"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"}
@@ -161,6 +161,46 @@ var Storer = class {
161
161
  SELECT min(watermark) as "minWatermark" FROM ${this.#cdc("changeLog")}`;
162
162
  return minWatermark;
163
163
  }
164
+ /**
165
+ * Returns the retained Postgres catchup bounds.
166
+ * `minWatermark` is the oldest transaction, or `null` for a new replica.
167
+ * `lastWatermark` is the durable head.
168
+ */
169
+ async getCatchupBounds() {
170
+ const [bounds] = await this.#db`
171
+ SELECT
172
+ (SELECT min(watermark) FROM ${this.#cdc("changeLog")}) as "minWatermark",
173
+ (SELECT "lastWatermark" FROM ${this.#cdc("replicationState")}) as "lastWatermark"`;
174
+ return bounds;
175
+ }
176
+ /**
177
+ * Lists at most `limit` committed transaction watermarks in
178
+ * `(afterWatermark, throughWatermark]`, in ascending order.
179
+ */
180
+ async listCommitWatermarks(afterWatermark, throughWatermark, limit) {
181
+ return (await this.#db`
182
+ SELECT watermark FROM ${this.#cdc("changeLog")}
183
+ WHERE precommit IS NOT NULL
184
+ AND watermark > ${afterWatermark}
185
+ AND watermark <= ${throughWatermark}
186
+ ORDER BY watermark
187
+ LIMIT ${limit}`).map(({ watermark }) => watermark);
188
+ }
189
+ /**
190
+ * Reads `(afterWatermark, throughWatermark]` in stream order and bounded batches.
191
+ * This query matches the subscriber catchup query.
192
+ *
193
+ * Both queries fail when an escaped NUL reaches `change->'tag'`.
194
+ * Postgres cannot convert that value to text.
195
+ */
196
+ readCatchupRange(afterWatermark, throughWatermark, batchRows = 2e3) {
197
+ assert(Number.isSafeInteger(batchRows) && batchRows > 0, "Postgres change log batch size must be a positive safe integer");
198
+ return this.#db`
199
+ SELECT watermark, change->'tag' as tag, change::text FROM ${this.#cdc("changeLog")}
200
+ WHERE watermark > ${afterWatermark}
201
+ AND watermark <= ${throughWatermark}
202
+ ORDER BY watermark, pos`.cursor(batchRows);
203
+ }
164
204
  purgeRecordsBefore(watermark) {
165
205
  return runTx(this.#db, async (sql) => {
166
206
  await sql`