@harperfast/harper 5.3.0 → 5.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (231) hide show
  1. package/bin/copyDb.ts +5 -1
  2. package/bin/deployRenderer.ts +3 -0
  3. package/components/Application.ts +66 -22
  4. package/components/DESIGN.md +89 -36
  5. package/components/componentLoader.ts +2 -0
  6. package/components/deploymentRecorder.ts +18 -1
  7. package/components/installFingerprint.ts +123 -0
  8. package/components/mcp/DESIGN.md +4 -4
  9. package/components/operations.js +29 -0
  10. package/config/DESIGN.md +5 -5
  11. package/dataLayer/DESIGN.md +37 -22
  12. package/dataLayer/backupRepository.ts +248 -0
  13. package/dataLayer/restoreMarker.ts +30 -75
  14. package/dataLayer/rocksdbBackup.ts +253 -110
  15. package/dist/bin/copyDb.js +3 -0
  16. package/dist/bin/copyDb.js.map +1 -1
  17. package/dist/bin/deployRenderer.js +3 -0
  18. package/dist/bin/deployRenderer.js.map +1 -1
  19. package/dist/components/Application.d.ts +5 -0
  20. package/dist/components/Application.js +51 -19
  21. package/dist/components/Application.js.map +1 -1
  22. package/dist/components/componentLoader.js +2 -0
  23. package/dist/components/componentLoader.js.map +1 -1
  24. package/dist/components/deploymentRecorder.js +18 -1
  25. package/dist/components/deploymentRecorder.js.map +1 -1
  26. package/dist/components/installFingerprint.d.ts +39 -0
  27. package/dist/components/installFingerprint.js +113 -0
  28. package/dist/components/installFingerprint.js.map +1 -0
  29. package/dist/components/operations.d.ts +2 -0
  30. package/dist/components/operations.js +31 -0
  31. package/dist/components/operations.js.map +1 -1
  32. package/dist/dataLayer/backupRepository.d.ts +33 -0
  33. package/dist/dataLayer/backupRepository.js +232 -0
  34. package/dist/dataLayer/backupRepository.js.map +1 -0
  35. package/dist/dataLayer/restoreMarker.d.ts +4 -1
  36. package/dist/dataLayer/restoreMarker.js +29 -68
  37. package/dist/dataLayer/restoreMarker.js.map +1 -1
  38. package/dist/dataLayer/rocksdbBackup.js +221 -112
  39. package/dist/dataLayer/rocksdbBackup.js.map +1 -1
  40. package/dist/index.d.ts +1 -0
  41. package/dist/index.js.map +1 -1
  42. package/dist/json/systemSchema.json +14 -0
  43. package/dist/resources/DatabaseTransaction.d.ts +140 -11
  44. package/dist/resources/DatabaseTransaction.js +1055 -464
  45. package/dist/resources/DatabaseTransaction.js.map +1 -1
  46. package/dist/resources/IterableEventQueue.d.ts +2 -1
  47. package/dist/resources/IterableEventQueue.js +16 -8
  48. package/dist/resources/IterableEventQueue.js.map +1 -1
  49. package/dist/resources/LMDBTransaction.d.ts +13 -2
  50. package/dist/resources/LMDBTransaction.js +219 -93
  51. package/dist/resources/LMDBTransaction.js.map +1 -1
  52. package/dist/resources/RecordEncoder.js +2 -3
  53. package/dist/resources/RecordEncoder.js.map +1 -1
  54. package/dist/resources/Resource.js +5 -6
  55. package/dist/resources/Resource.js.map +1 -1
  56. package/dist/resources/ResourceInterface.d.ts +3 -1
  57. package/dist/resources/ResourceInterface.js.map +1 -1
  58. package/dist/resources/RocksIndexStore.d.ts +2 -1
  59. package/dist/resources/RocksIndexStore.js +3 -0
  60. package/dist/resources/RocksIndexStore.js.map +1 -1
  61. package/dist/resources/Table.js +116 -12
  62. package/dist/resources/Table.js.map +1 -1
  63. package/dist/resources/analytics/write.js +6 -2
  64. package/dist/resources/analytics/write.js.map +1 -1
  65. package/dist/resources/auditStore.d.ts +71 -17
  66. package/dist/resources/auditStore.js +248 -64
  67. package/dist/resources/auditStore.js.map +1 -1
  68. package/dist/resources/branchDatabase.js +2 -0
  69. package/dist/resources/branchDatabase.js.map +1 -1
  70. package/dist/resources/databases.js +8 -2
  71. package/dist/resources/databases.js.map +1 -1
  72. package/dist/resources/indexes/fullTextNativeBinding.d.ts +6 -2
  73. package/dist/resources/indexes/fullTextNativeBinding.js +12 -2
  74. package/dist/resources/indexes/fullTextNativeBinding.js.map +1 -1
  75. package/dist/resources/indexes/fullTextQueryIndex.d.ts +4 -0
  76. package/dist/resources/indexes/fullTextQueryIndex.js +207 -10
  77. package/dist/resources/indexes/fullTextQueryIndex.js.map +1 -1
  78. package/dist/resources/models/Models.d.ts +11 -0
  79. package/dist/resources/models/Models.js +95 -4
  80. package/dist/resources/models/Models.js.map +1 -1
  81. package/dist/resources/models/backendRegistry.d.ts +8 -0
  82. package/dist/resources/models/backendRegistry.js +15 -1
  83. package/dist/resources/models/backendRegistry.js.map +1 -1
  84. package/dist/resources/models/bootstrap.d.ts +2 -0
  85. package/dist/resources/models/bootstrap.js +6 -0
  86. package/dist/resources/models/bootstrap.js.map +1 -1
  87. package/dist/resources/models/calibration.d.ts +88 -0
  88. package/dist/resources/models/calibration.js +244 -0
  89. package/dist/resources/models/calibration.js.map +1 -0
  90. package/dist/resources/models/calibrationStore.d.ts +163 -0
  91. package/dist/resources/models/calibrationStore.js +986 -0
  92. package/dist/resources/models/calibrationStore.js.map +1 -0
  93. package/dist/resources/models/decisionStore.d.ts +24 -0
  94. package/dist/resources/models/decisionStore.js +45 -0
  95. package/dist/resources/models/decisionStore.js.map +1 -1
  96. package/dist/resources/models/generativeDecision.js +44 -15
  97. package/dist/resources/models/generativeDecision.js.map +1 -1
  98. package/dist/resources/models/types.d.ts +7 -0
  99. package/dist/resources/recordLock.d.ts +8 -5
  100. package/dist/resources/recordLock.js +6 -4
  101. package/dist/resources/recordLock.js.map +1 -1
  102. package/dist/resources/replayLogs.js +8 -1
  103. package/dist/resources/replayLogs.js.map +1 -1
  104. package/dist/resources/scheduler/engine.d.ts +4 -0
  105. package/dist/resources/scheduler/engine.js +30 -2
  106. package/dist/resources/scheduler/engine.js.map +1 -1
  107. package/dist/resources/search.d.ts +16 -4
  108. package/dist/resources/search.js +117 -13
  109. package/dist/resources/search.js.map +1 -1
  110. package/dist/resources/transaction.js +85 -5
  111. package/dist/resources/transaction.js.map +1 -1
  112. package/dist/resources/transactionBroadcast.d.ts +8 -0
  113. package/dist/resources/transactionBroadcast.js +65 -0
  114. package/dist/resources/transactionBroadcast.js.map +1 -1
  115. package/dist/security/keys.d.ts +1 -1
  116. package/dist/security/keys.js +170 -120
  117. package/dist/security/keys.js.map +1 -1
  118. package/dist/server/http.js +10 -0
  119. package/dist/server/http.js.map +1 -1
  120. package/dist/server/serverHelpers/Request.js +11 -2
  121. package/dist/server/serverHelpers/Request.js.map +1 -1
  122. package/dist/server/serverHelpers/contentTypes.js +8 -3
  123. package/dist/server/serverHelpers/contentTypes.js.map +1 -1
  124. package/dist/server/serverHelpers/uwsServer.js +38 -7
  125. package/dist/server/serverHelpers/uwsServer.js.map +1 -1
  126. package/dist/server/threads/manageThreads.d.ts +3 -3
  127. package/dist/server/threads/manageThreads.js +15 -13
  128. package/dist/server/threads/manageThreads.js.map +1 -1
  129. package/dist/server/threads/threadServer.d.ts +3 -0
  130. package/dist/server/threads/threadServer.js +24 -6
  131. package/dist/server/threads/threadServer.js.map +1 -1
  132. package/dist/upgrade/directives/5-3-0.js +9 -3
  133. package/dist/upgrade/directives/5-3-0.js.map +1 -1
  134. package/dist/utility/durableFile.d.ts +36 -0
  135. package/dist/utility/durableFile.js +106 -0
  136. package/dist/utility/durableFile.js.map +1 -0
  137. package/dist/utility/errors/hdbError.d.ts +4 -0
  138. package/dist/utility/errors/hdbError.js +10 -1
  139. package/dist/utility/errors/hdbError.js.map +1 -1
  140. package/dist/utility/globalSchema.d.ts +9 -0
  141. package/dist/utility/hdbTerms.d.ts +1 -0
  142. package/dist/utility/hdbTerms.js +1 -0
  143. package/dist/utility/hdbTerms.js.map +1 -1
  144. package/dist/validation/configValidator.js +29 -0
  145. package/dist/validation/configValidator.js.map +1 -1
  146. package/index.ts +7 -0
  147. package/json/systemSchema.json +14 -0
  148. package/npm-shrinkwrap.json +22 -22
  149. package/package.json +2 -2
  150. package/resources/DESIGN.md +57 -272
  151. package/resources/DatabaseTransaction.ts +708 -87
  152. package/resources/FULL_TEXT.md +6 -2
  153. package/resources/IterableEventQueue.ts +14 -7
  154. package/resources/LMDBTransaction.ts +212 -84
  155. package/resources/RecordEncoder.ts +2 -3
  156. package/resources/Resource.ts +5 -6
  157. package/resources/ResourceInterface.ts +3 -1
  158. package/resources/RocksIndexStore.ts +5 -0
  159. package/resources/Table.ts +118 -15
  160. package/resources/analytics/write.ts +6 -2
  161. package/resources/audit-retention.md +203 -0
  162. package/resources/auditStore.ts +306 -84
  163. package/resources/branchDatabase.ts +2 -0
  164. package/resources/databases.ts +6 -2
  165. package/resources/indexes/DESIGN.md +120 -177
  166. package/resources/indexes/fullTextNativeBinding.ts +22 -3
  167. package/resources/indexes/fullTextQueryIndex.ts +274 -11
  168. package/resources/models/DESIGN.md +11 -3
  169. package/resources/models/Models.ts +127 -4
  170. package/resources/models/backendRegistry.ts +17 -0
  171. package/resources/models/bootstrap.ts +9 -1
  172. package/resources/models/calibration.ts +298 -0
  173. package/resources/models/calibrationStore.ts +1167 -0
  174. package/resources/models/decisionStore.ts +57 -0
  175. package/resources/models/generativeDecision.ts +54 -16
  176. package/resources/models/types.ts +6 -0
  177. package/resources/record-locks.md +155 -141
  178. package/resources/recordLock.ts +12 -6
  179. package/resources/replayLogs.ts +9 -2
  180. package/resources/scheduler/DESIGN.md +4 -3
  181. package/resources/scheduler/engine.ts +35 -2
  182. package/resources/search.ts +154 -16
  183. package/resources/transaction.ts +50 -5
  184. package/resources/transactionBroadcast.ts +69 -0
  185. package/security/DESIGN.md +9 -30
  186. package/security/keys.ts +175 -127
  187. package/server/DESIGN.md +70 -36
  188. package/server/http.ts +10 -0
  189. package/server/serverHelpers/Request.ts +10 -2
  190. package/server/serverHelpers/contentTypes.ts +5 -3
  191. package/server/serverHelpers/uwsServer.ts +34 -7
  192. package/server/threads/manageThreads.js +16 -14
  193. package/server/threads/threadServer.js +24 -6
  194. package/studio/web/assets/{Chat-CNEKMllJ.js → Chat-4LMxI0yB.js} +1 -1
  195. package/studio/web/assets/{FloatingChat-CEnWhHeR.js → FloatingChat-kWHApEtF.js} +3 -3
  196. package/studio/web/assets/{SearchDialog-CwhJdEdY.js → SearchDialog-B7ymna97.js} +1 -1
  197. package/studio/web/assets/{apiToken-DC5vsaaO.js → apiToken-CE3AAt9f.js} +1 -1
  198. package/studio/web/assets/{applications-DQZxDy0x.js → applications-DdN7CqsB.js} +1 -1
  199. package/studio/web/assets/{cssMode-Dz2DxF4e.js → cssMode-BpySZUSt.js} +1 -1
  200. package/studio/web/assets/{editor-B3mjd3tz.js → editor-myLCVv9C.js} +1 -1
  201. package/studio/web/assets/{html-BsRGM7tp.js → html-DGNYO55O.js} +1 -1
  202. package/studio/web/assets/{htmlMode-tUKJaA3o.js → htmlMode-BUgpQr41.js} +1 -1
  203. package/studio/web/assets/{index-BuqFjN5K.js → index-FN4KC9va.js} +39 -39
  204. package/studio/web/assets/index-QLZw6xrU.css +1 -0
  205. package/studio/web/assets/{index.lazy-CcpfQFY_.js → index.lazy-DZFuaumG.js} +1 -1
  206. package/studio/web/assets/{javascript-BZ_uRGHl.js → javascript-DiBzh4pj.js} +1 -1
  207. package/studio/web/assets/{jsonMode-DgB-aFMj.js → jsonMode-Bm312nmu.js} +1 -1
  208. package/studio/web/assets/{languageServices-BleuBw17.js → languageServices-Dz2K9rZN.js} +1 -1
  209. package/studio/web/assets/{lspLanguageFeatures-ByVYLSsQ.js → lspLanguageFeatures-CNu1L5xn.js} +1 -1
  210. package/studio/web/assets/{notifications-TuzN0IMi.js → notifications--nwFLGmM.js} +1 -1
  211. package/studio/web/assets/{notifications-D1Nf-Edn.js → notifications-BelKw5bR.js} +1 -1
  212. package/studio/web/assets/{profile-D7C7Lupz.js → profile-AU5FjOBx.js} +1 -1
  213. package/studio/web/assets/{regions-nGQCm-v8.js → regions-C0sTSEHH.js} +1 -1
  214. package/studio/web/assets/{register-C3x9Ze6P.js → register-DyNR2rGk.js} +2 -2
  215. package/studio/web/assets/{setComponentFile-DpjrRqeG.js → setComponentFile-BkWx8AN3.js} +1 -1
  216. package/studio/web/assets/{setup-CGiKLZ9e.js → setup-D6LZS78d.js} +2 -2
  217. package/studio/web/assets/{status-CGJwrNPH.js → status-BCWnn70o.js} +1 -1
  218. package/studio/web/assets/{toggleHighContrast-C-Ne6P7T.js → toggleHighContrast-pyeGJ6W1.js} +1 -1
  219. package/studio/web/assets/{tsMode-CSreYTys.js → tsMode-CGV3iaWC.js} +1 -1
  220. package/studio/web/assets/{typescript-MnxBk89W.js → typescript-Ci7Khvwo.js} +1 -1
  221. package/studio/web/assets/{useEntityRestURL-TNrh77-s.js → useEntityRestURL-DNiz3aZ0.js} +1 -1
  222. package/studio/web/assets/{workers-BfZlUNVI.js → workers-byzxjbqy.js} +1 -1
  223. package/studio/web/assets/{xml-BHd6qGH7.js → xml-DGnHQ3CX.js} +1 -1
  224. package/studio/web/assets/{yaml-C53R8OQq.js → yaml-BUoTnPWb.js} +1 -1
  225. package/studio/web/index.html +2 -2
  226. package/upgrade/directives/5-3-0.ts +10 -3
  227. package/utility/durableFile.ts +96 -0
  228. package/utility/errors/hdbError.ts +9 -0
  229. package/utility/hdbTerms.ts +1 -0
  230. package/validation/configValidator.ts +31 -0
  231. package/studio/web/assets/index-72cEH474.css +0 -1
@@ -12,8 +12,10 @@ import { ClientError } from '../utility/errors/hdbError.ts';
12
12
  * Phase 1 layers cluster-wide exclusion on top without changing that: once the native key is held,
13
13
  * `Table.lock()` obtains a delegation for the key from its home node (see `recordLockCoordinator.ts`)
14
14
  * and calls `joinClusterRound()` on the handle. A live delegation serves repeat locks with no cluster
15
- * message at all. A node-scoped lock (`{ scope: 'node' }`), or any lock on a database with no
16
- * transport registered, skips the cluster step entirely and behaves exactly as it did in Phase 0.
15
+ * message at all. A node-scoped lock (`{ scope: 'node' }`) or a defaulted lock on a table declared
16
+ * `replicate: false` skips the cluster step entirely and behaves exactly as it did in Phase 0; so does
17
+ * a defaulted lock on a replicating table while no transport has ever been registered for the
18
+ * database — once one has been, its absence fails closed (`isClusterLockRequired`).
17
19
  */
18
20
 
19
21
  export const DEFAULT_LOCK_LEASE_MS = 30_000;
@@ -32,8 +34,9 @@ export interface RecordLockOptions {
32
34
  /** A held lock outlives the acquiring transaction; it is released by `unlock()` or by its lease */
33
35
  hold?: boolean;
34
36
  /**
35
- * `'cluster'` (default) is exclusive across every participating node; `'node'` keeps Phase 0
36
- * semantics and is exclusive only across this node's worker threads.
37
+ * `'cluster'` is exclusive across every participating node; `'node'` keeps Phase 0 semantics and is
38
+ * exclusive only across this node's worker threads. Defaults to `'cluster'` on a replicating table
39
+ * and to `'node'` on a table declared `replicate: false`, which has no cluster to lock across.
37
40
  */
38
41
  scope?: 'cluster' | 'node';
39
42
  }
@@ -122,12 +125,15 @@ function requireDuration(name: string, value: unknown, fallback: number, min: nu
122
125
  return value;
123
126
  }
124
127
 
125
- export function resolveLockOptions(options?: RecordLockOptions | null): ResolvedRecordLockOptions {
128
+ export function resolveLockOptions(
129
+ options: RecordLockOptions | null | undefined,
130
+ tableReplicates: boolean
131
+ ): ResolvedRecordLockOptions {
126
132
  if (options != null && typeof options !== 'object')
127
133
  throw new ClientError(`Lock options must be an object, but received ${typeof options}`);
128
134
  const hold = options?.hold ?? false;
129
135
  if (typeof hold !== 'boolean') throw new ClientError(`Lock option hold must be a boolean, but received ${hold}`);
130
- const scope = options?.scope ?? 'cluster';
136
+ const scope = options?.scope ?? (tableReplicates ? 'cluster' : 'node');
131
137
  if (scope !== 'cluster' && scope !== 'node')
132
138
  throw new ClientError(`Lock option scope must be 'cluster' or 'node', but received ${scope}`);
133
139
  return {
@@ -13,7 +13,7 @@ import {
13
13
  shouldAbortSlowReplay,
14
14
  REPLAY_WALL_CLOCK_LIMIT_MS,
15
15
  } from './replayLogsGuards.ts';
16
- import { purgeAgedLogs } from './auditStore.ts';
16
+ import { LOCAL_ONLY, purgeAgedLogs } from './auditStore.ts';
17
17
  import { get as envGet } from '../utility/environment/environmentManager.ts';
18
18
  import { CONFIG_PARAMS } from '../utility/hdbTerms.ts';
19
19
 
@@ -291,7 +291,14 @@ export function replayLogs(rootStore: RocksDatabase, tables: any, electedReplaye
291
291
  transaction.isReplay = true;
292
292
  }
293
293
  context.transaction = transaction;
294
- const options = { context, residencyId, nodeId, originatingOperation, version };
294
+ const options = {
295
+ context,
296
+ residencyId,
297
+ nodeId,
298
+ originatingOperation,
299
+ version,
300
+ localOnly: (extendedType & LOCAL_ONLY) !== 0,
301
+ };
295
302
  writes++;
296
303
  stagedWrites++;
297
304
  switch (type) {
@@ -25,11 +25,12 @@ non-obvious part is what Harper's substrate does and does not offer for that:
25
25
  is taken by the legacy jobs subsystem). On constrained/directional replication topologies the
26
26
  lease may not reach every node; that limitation is inherent.
27
27
  - The alphabetical node-name tie-break deliberately mirrors replication's deterministic failover
28
- convention (sorted node names in `subscriptionManager.ts`); the escalation ladder
28
+ convention (sorted node names in harper-pro `replication/subscriptionManager.ts`); the escalation ladder
29
29
  (`promotionWaitMs`) exists because a dead alphabetically-first node must not deadlock a
30
30
  leaderless cluster (each successive node waits one more `2 × watcher interval` rung).
31
- - Thread-once vs cluster-once are separate layers: `getWorkerIndex() === 0` gates to one worker per
32
- node (correct in every threading mode incl. `threads: 0`); the lease gates across nodes.
31
+ - Thread-once vs cluster-once are separate layers: `isApplicationPrimaryWorker()` (worker 0, or an
32
+ isolated application's dedicated worker, #2524) gates to one worker per node (correct in every
33
+ threading mode incl. `threads: 0`); the lease gates across nodes.
33
34
  `handleApplication` holds a cross-thread load lock with a 30s timeout, so the plugin only
34
35
  registers there — election, scheduling, and catch-up run async after.
35
36
  - Catch-up fires at most ONE missed occurrence per cron job, and a new job's `firstSeenAt` baseline
@@ -241,7 +241,36 @@ export function findMissedCronOccurrence(
241
241
  * repeatedly — a reload or redeploy replaces the component's whole job set.
242
242
  */
243
243
  export function registerComponentJobs(componentName: string, jobs: ScheduledJob[]): void {
244
- unregisterComponentJobs(componentName);
244
+ if (isInternalName(componentName)) {
245
+ schedulerLogger.error?.(
246
+ `Component name '${componentName}' is reserved for Harper's own jobs; its jobs were not registered`
247
+ );
248
+ return;
249
+ }
250
+ installJobs(componentName, jobs);
251
+ }
252
+
253
+ /** Jobs Harper itself owns, under a reserved name no component can register or unregister. */
254
+ export function registerInternalJobs(name: string, jobs: ScheduledJob[]): void {
255
+ installJobs(INTERNAL_PREFIX + name, jobs);
256
+ }
257
+
258
+ export function unregisterInternalJobs(name: string): void {
259
+ removeJobs(INTERNAL_PREFIX + name);
260
+ }
261
+
262
+ export function internalJobOwner(name: string): string {
263
+ return INTERNAL_PREFIX + name;
264
+ }
265
+
266
+ const INTERNAL_PREFIX = 'harper:';
267
+
268
+ function isInternalName(componentName: string): boolean {
269
+ return componentName.startsWith(INTERNAL_PREFIX);
270
+ }
271
+
272
+ function installJobs(componentName: string, jobs: ScheduledJob[]): void {
273
+ removeJobs(componentName);
245
274
  const jobMap = new Map<string, RegisteredJob>();
246
275
  for (const job of jobs) {
247
276
  jobMap.set(job.name, { ...job, running: false });
@@ -267,6 +296,10 @@ export function registerComponentJobs(componentName: string, jobs: ScheduledJob[
267
296
  * harmless and resolves on the next worker restart.
268
297
  */
269
298
  export function unregisterComponentJobs(componentName: string): void {
299
+ if (!isInternalName(componentName)) removeJobs(componentName);
300
+ }
301
+
302
+ function removeJobs(componentName: string): void {
270
303
  const jobMap = jobsByComponent.get(componentName);
271
304
  if (!jobMap) return;
272
305
  for (const job of jobMap.values()) {
@@ -312,7 +345,7 @@ export function runFailoverCheckForTests(): Promise<void> {
312
345
  /** Reset all engine state and timers. Intended for tests. */
313
346
  export function stopSchedulerEngine(): void {
314
347
  for (const componentName of [...jobsByComponent.keys()]) {
315
- unregisterComponentJobs(componentName);
348
+ removeJobs(componentName);
316
349
  }
317
350
  if (heartbeatTimer) clearInterval(heartbeatTimer);
318
351
  if (failoverWatcherTimer) clearInterval(failoverWatcherTimer);
@@ -129,7 +129,7 @@ export function executeConditions(
129
129
  // matches — the index decides whether building it beats evaluating the predicate.
130
130
  const candidateKeys =
131
131
  pushdownIndex.candidateKeyFilter && siblings.length > 0
132
- ? planCandidateKeys(siblings, table, txn, !recordGuards)
132
+ ? planCandidateKeys(siblings, table, txn, !recordGuards, Boolean(pushdownIndex.candidateKeyProbe))
133
133
  : undefined;
134
134
  // Execute a COPY carrying the pushed-down predicate, never mutating the caller's condition
135
135
  // object (which may be reused across requests with different users / guards).
@@ -412,6 +412,16 @@ function composeRecordFilter(recordFilters, table, context): (primaryKey: Id, su
412
412
  export interface CandidateKeyPlan {
413
413
  /** Planner estimate of the matching record count; the index sizes its work budget from it. */
414
414
  estimatedCount: number;
415
+ /** Optional point-membership gate for exact conditions an index store can probe directly. */
416
+ probe?: {
417
+ /** Whether the probe covers every pushed-down condition and no opaque record guard remains. */
418
+ complete: boolean;
419
+ /** Planner estimate for the conditions this probe actually covers. */
420
+ estimatedCount: number;
421
+ /** Worst-case synchronous index reads performed by one call to `has`. */
422
+ maxReadsPerCandidate: number;
423
+ has(primaryKey: Id): boolean;
424
+ };
415
425
  /**
416
426
  * The matching primary keys, with `complete` true when the set alone decides admission, so the
417
427
  * index may drop the traversal predicate. That needs every pushed-down condition covered, no
@@ -420,7 +430,13 @@ export interface CandidateKeyPlan {
420
430
  * superset that gates the predicate instead of replacing it. Null when even the narrowest term
421
431
  * overruns, which means the whole plan was not worth what it read.
422
432
  */
423
- collect(maxKeys: number): { keys: CandidateKeySet; complete: boolean } | null;
433
+ collect(maxKeys: number): CandidateKeyCollection | null;
434
+ collect(maxKeys: number, cooperate: () => Promise<void>): Promise<CandidateKeyCollection | null>;
435
+ }
436
+
437
+ interface CandidateKeyCollection {
438
+ keys: CandidateKeySet;
439
+ complete: boolean;
424
440
  }
425
441
 
426
442
  /**
@@ -470,6 +486,9 @@ interface CandidateKeyScan {
470
486
  index: any;
471
487
  range: any;
472
488
  estimatedCount: number;
489
+ predicateExact: boolean;
490
+ probeValue?: any;
491
+ canProbe?: boolean;
473
492
  }
474
493
  /** Scans whose UNION is one AND term; a single-element `scans` is a leaf condition. */
475
494
  interface CandidateKeyTerm {
@@ -498,7 +517,7 @@ const CANDIDATE_KEY_COMPARATORS = new Set([
498
517
  * Plan one leaf condition as an index range scan, or undefined when its matches cannot be read off
499
518
  * an index exactly, or when the scan's size cannot be estimated.
500
519
  */
501
- function planCandidateKeyScan(condition, table): CandidateKeyScan | undefined {
520
+ function planCandidateKeyScan(condition, table, includePointProbe: boolean): CandidateKeyScan | undefined {
502
521
  if (condition.negated) return undefined;
503
522
  const attributeName = condition.attribute ?? condition[0];
504
523
  if (typeof attributeName !== 'string' || attributeName === table.primaryKey) return undefined;
@@ -516,6 +535,16 @@ function planCandidateKeyScan(condition, table): CandidateKeyScan | undefined {
516
535
  return undefined;
517
536
  let value = condition[1] ?? condition.value;
518
537
  if (value instanceof Date) value = value.getTime();
538
+ const attributeType = findAttribute(table.attributes, attributeName)?.type;
539
+ const predicateExact =
540
+ comparator !== 'equals' ||
541
+ value === null ||
542
+ typeof value === 'string' ||
543
+ typeof value === 'boolean' ||
544
+ attributeType === 'Int' ||
545
+ attributeType === 'Long' ||
546
+ attributeType === 'Float' ||
547
+ attributeType === 'BigInt';
519
548
  let start;
520
549
  let end;
521
550
  let inclusiveEnd = false;
@@ -585,22 +614,67 @@ function planCandidateKeyScan(condition, table): CandidateKeyScan | undefined {
585
614
  if (!(belowTrue >= 0)) return undefined;
586
615
  estimatedCount += belowTrue;
587
616
  }
588
- return { index, range: { start, end, inclusiveEnd, exclusiveStart }, estimatedCount };
617
+ const canProbe = includePointProbe && comparator === 'equals' && typeof index.hasIndexEntry === 'function';
618
+ return {
619
+ index,
620
+ range: { start, end, inclusiveEnd, exclusiveStart },
621
+ estimatedCount,
622
+ predicateExact,
623
+ ...(includePointProbe ? { probeValue: canProbe ? value : undefined, canProbe } : null),
624
+ };
589
625
  }
590
626
 
591
- function planCandidateKeys(conditions, table, transaction, guardFree: boolean): CandidateKeyPlan | undefined {
627
+ function planCandidateKeys(
628
+ conditions,
629
+ table,
630
+ transaction,
631
+ guardFree: boolean,
632
+ includePointProbe: boolean
633
+ ): CandidateKeyPlan | undefined {
592
634
  const terms: CandidateKeyTerm[] = [];
593
- const planned = collectCandidateKeyTerms(conditions, table, terms) && guardFree;
635
+ const planned = collectCandidateKeyTerms(conditions, table, terms, includePointProbe) && guardFree;
594
636
  if (terms.length === 0) return undefined;
595
637
  const estimatedCount = terms.reduce((narrowest, term) => Math.min(narrowest, term.estimatedCount), Infinity);
596
- return {
597
- estimatedCount,
598
- collect: (maxKeys) => collectCandidateKeys(terms, transaction, maxKeys, planned),
638
+ function collect(maxKeys: number): CandidateKeyCollection | null;
639
+ function collect(maxKeys: number, cooperate: () => Promise<void>): Promise<CandidateKeyCollection | null>;
640
+ function collect(maxKeys: number, cooperate?: () => Promise<void>) {
641
+ return cooperate
642
+ ? collectCandidateKeysCooperatively(terms, transaction, maxKeys, planned, cooperate)
643
+ : collectCandidateKeys(terms, transaction, maxKeys, planned);
644
+ }
645
+ const plan: CandidateKeyPlan = { estimatedCount, collect };
646
+ if (!includePointProbe) return plan;
647
+ const probeTerms = terms.filter((term) => term.scans.every((scan) => scan.canProbe));
648
+ if (probeTerms.length === 0) return plan;
649
+ const probeEstimatedCount = probeTerms.reduce(
650
+ (narrowest, term) => Math.min(narrowest, term.estimatedCount),
651
+ Infinity
652
+ );
653
+ const maxProbeReadsPerCandidate = probeTerms.reduce((total, term) => total + term.scans.length, 0);
654
+ const probeOptions = { transaction };
655
+ plan.probe = {
656
+ complete: planned && probeTerms.length === terms.length,
657
+ estimatedCount: probeEstimatedCount,
658
+ maxReadsPerCandidate: maxProbeReadsPerCandidate,
659
+ has(primaryKey) {
660
+ for (const term of probeTerms) {
661
+ let matched = false;
662
+ for (const scan of term.scans) {
663
+ if (scan.index.hasIndexEntry(scan.probeValue, primaryKey, probeOptions)) {
664
+ matched = true;
665
+ break;
666
+ }
667
+ }
668
+ if (!matched) return false;
669
+ }
670
+ return true;
671
+ },
599
672
  };
673
+ return plan;
600
674
  }
601
675
 
602
676
  /** Returns whether every condition was covered. */
603
- function collectCandidateKeyTerms(conditions, table, terms: CandidateKeyTerm[]): boolean {
677
+ function collectCandidateKeyTerms(conditions, table, terms: CandidateKeyTerm[], includePointProbe: boolean): boolean {
604
678
  let complete = true;
605
679
  for (const condition of conditions) {
606
680
  if (condition.conditions) {
@@ -609,7 +683,7 @@ function collectCandidateKeyTerms(conditions, table, terms: CandidateKeyTerm[]):
609
683
  // that branch matches, and admission may over-admit but never omit.
610
684
  const scans: CandidateKeyScan[] = [];
611
685
  for (const child of condition.conditions) {
612
- const scan = child.conditions ? undefined : planCandidateKeyScan(child, table);
686
+ const scan = child.conditions ? undefined : planCandidateKeyScan(child, table, includePointProbe);
613
687
  if (!scan) {
614
688
  complete = false;
615
689
  break;
@@ -618,17 +692,22 @@ function collectCandidateKeyTerms(conditions, table, terms: CandidateKeyTerm[]):
618
692
  }
619
693
  if (scans.length === condition.conditions.length) {
620
694
  terms.push({ scans, estimatedCount: scans.reduce((total, scan) => total + scan.estimatedCount, 0) });
695
+ if (scans.some((scan) => !scan.predicateExact)) complete = false;
621
696
  }
622
- } else if (!collectCandidateKeyTerms(condition.conditions, table, terms)) complete = false;
697
+ } else if (!collectCandidateKeyTerms(condition.conditions, table, terms, includePointProbe)) complete = false;
623
698
  continue;
624
699
  }
625
- const scan = planCandidateKeyScan(condition, table);
626
- if (scan) terms.push({ scans: [scan], estimatedCount: scan.estimatedCount });
627
- else complete = false;
700
+ const scan = planCandidateKeyScan(condition, table, includePointProbe);
701
+ if (scan) {
702
+ terms.push({ scans: [scan], estimatedCount: scan.estimatedCount });
703
+ if (!scan.predicateExact) complete = false;
704
+ } else complete = false;
628
705
  }
629
706
  return complete;
630
707
  }
631
708
 
709
+ const CANDIDATE_KEY_COLLECTION_YIELD_INTERVAL = 256;
710
+
632
711
  /**
633
712
  * Run the planned scans narrowest-estimate first, intersecting on storage key identity. `maxKeys`
634
713
  * bounds index entries READ, not keys retained — a wide later scan whose intersection is tiny still
@@ -641,7 +720,7 @@ function collectCandidateKeys(
641
720
  transaction,
642
721
  maxKeys: number,
643
722
  planned: boolean
644
- ): { keys: CandidateKeySet; complete: boolean } | null {
723
+ ): CandidateKeyCollection | null {
645
724
  const ordered = terms.length > 1 ? terms.slice().sort((a, b) => a.estimatedCount - b.estimatedCount) : terms;
646
725
  let matched: CandidateKeySet | undefined;
647
726
  let complete = planned;
@@ -675,6 +754,65 @@ function collectCandidateKeys(
675
754
  return { keys: matched!, complete };
676
755
  }
677
756
 
757
+ function* cooperativeCandidateKeyCollectionSteps(
758
+ terms: CandidateKeyTerm[],
759
+ transaction,
760
+ maxKeys: number,
761
+ planned: boolean
762
+ ): Generator<void, CandidateKeyCollection | null> {
763
+ const ordered = terms.length > 1 ? terms.slice().sort((a, b) => a.estimatedCount - b.estimatedCount) : terms;
764
+ let matched: CandidateKeySet | undefined;
765
+ let complete = planned;
766
+ let scanned = 0;
767
+ for (const term of ordered) {
768
+ if (matched && term.estimatedCount > maxKeys - scanned) {
769
+ complete = false;
770
+ continue;
771
+ }
772
+ const next = new CandidateKeySet();
773
+ let overran = false;
774
+ for (const scan of term.scans) {
775
+ for (const { value: primaryKey } of scan.index.getRange({ ...scan.range, values: true, transaction })) {
776
+ if (++scanned > maxKeys) {
777
+ overran = true;
778
+ break;
779
+ }
780
+ if (!matched || matched.has(primaryKey)) next.add(primaryKey);
781
+ if (scanned % CANDIDATE_KEY_COLLECTION_YIELD_INTERVAL === 0) yield;
782
+ }
783
+ if (overran) break;
784
+ }
785
+ if (overran) {
786
+ if (!matched) return null;
787
+ complete = false;
788
+ break;
789
+ }
790
+ matched = next;
791
+ if (matched.size === 0) break;
792
+ }
793
+ return { keys: matched!, complete };
794
+ }
795
+
796
+ async function collectCandidateKeysCooperatively(
797
+ terms: CandidateKeyTerm[],
798
+ transaction,
799
+ maxKeys: number,
800
+ planned: boolean,
801
+ cooperate: () => Promise<void>
802
+ ): Promise<CandidateKeyCollection | null> {
803
+ const steps = cooperativeCandidateKeyCollectionSteps(terms, transaction, maxKeys, planned);
804
+ try {
805
+ let next = steps.next();
806
+ while (!next.done) {
807
+ await cooperate();
808
+ next = steps.next();
809
+ }
810
+ return next.value;
811
+ } finally {
812
+ steps.return(null);
813
+ }
814
+ }
815
+
678
816
  /**
679
817
  * A record's index entries are not adjacent — the composite `[indexedValue, primaryKey]` key sorts
680
818
  * on the indexed value first — so comparing neighbours cannot collapse them and the scan has to
@@ -7,6 +7,7 @@ import {
7
7
  type Transaction,
8
8
  } from './DatabaseTransaction.ts';
9
9
  import { AsyncLocalStorage } from 'async_hooks';
10
+ import * as harperLogger from '../utility/logging/harper_logger.ts';
10
11
 
11
12
  export const contextStorage = new AsyncLocalStorage<Context>();
12
13
 
@@ -53,6 +54,13 @@ export function transaction<T>(
53
54
  if (context.replicatedConfirmation) transaction.replicatedConfirmation = context.replicatedConfirmation;
54
55
  if (context.sourceApply) transaction.sourceApply = true;
55
56
  transaction.setContext(context);
57
+
58
+ // Cancellation belongs to the request (harper#2001): once its signal aborts, the chain refuses new
59
+ // writes and releases what it staged (DatabaseTransaction.admitRequestWrite). sourceApply is exempt,
60
+ // having no resume path (harper-pro#348); work that must outlive the client runs on a context without
61
+ // the signal (resources/DESIGN.md).
62
+ if (!transaction.sourceApply) transaction.requestSignal = context.signal;
63
+
56
64
  let result;
57
65
  try {
58
66
  result =
@@ -68,18 +76,55 @@ export function transaction<T>(
68
76
  return onComplete(result);
69
77
  // when the transaction function completes, run this to commit the transaction
70
78
  function onComplete(result) {
71
- const committed = transaction.commit({ doneWriting: true });
79
+ let committed;
80
+ try {
81
+ committed = transaction.commit({ doneWriting: true });
82
+ } catch (error) {
83
+ return onCommitError(error, result);
84
+ }
72
85
  if ((committed as any).then) {
73
- return (committed as any).then(() => {
74
- return result;
75
- });
86
+ return (committed as any).then(
87
+ () => result,
88
+ (error) => onCommitError(error, result)
89
+ );
76
90
  } else {
77
91
  return result;
78
92
  }
79
93
  }
94
+ function onCommitError(error, result) {
95
+ try {
96
+ if (typeof result?.onDone === 'function') result.onDone();
97
+ } catch (cleanupError) {
98
+ harperLogger.debug?.('closing results after a failed commit', cleanupError);
99
+ }
100
+ abortAndThrow(error, false);
101
+ }
80
102
  // if the transaction function throws an error, we abort
81
103
  function onError(error) {
82
- transaction.abort();
104
+ abortAndThrow(error, true);
105
+ }
106
+ function abortAndThrow(error, callbackThrew: boolean): never {
107
+ // A commit attempt that has not reached its native outcome owns its own teardown — a handler that
108
+ // fired txn.commit() without awaiting it can get here while it is still running, and aborting
109
+ // would clear the writes it is committing and abort the handle it is committing them through.
110
+ // Ownership of the scope still ends here, or that attempt would rotate the instance back OPEN with
111
+ // no wrapper left to commit or abort it; abandonScope() also defers the iterator cleanup below to
112
+ // the point where the attempt settles.
113
+ if (transaction.isChainCommitting()) {
114
+ transaction.abandonScope();
115
+ } else {
116
+ try {
117
+ // "retain only while read iterators still own the handle", the same rule
118
+ // abortAfterCommitError uses — so the two layers cannot undo each other one frame apart.
119
+ transaction.abort(true);
120
+ } catch (abortError) {
121
+ harperLogger.debug?.('aborting transaction after an error', abortError);
122
+ }
123
+ // Only when the callback threw: then nothing was returned, so no live response can own an iterator
124
+ // it opened, and the retained handle would wait on an onDone() nobody will call. A callback that
125
+ // completed may have handed an iterator out; the monitor reclaims it if that consumer abandons it.
126
+ if (callbackThrew) transaction.closeOwnedReadIterators();
127
+ }
83
128
  throw error;
84
129
  }
85
130
  }
@@ -1,10 +1,33 @@
1
+ import { basename } from 'node:path';
1
2
  import { warn } from '../utility/logging/harper_logger.js';
3
+ import { DatabaseClosingError, DatabaseGenerationChangedError } from '../utility/errors/hdbError.ts';
2
4
  import { IterableEventQueue } from './IterableEventQueue.ts';
3
5
  import { keyArrayToString } from './Resources.ts';
4
6
  import type { Id } from './ResourceInterface.ts';
5
7
 
6
8
  const allSubscriptions = Object.create(null); // using it as a map that doesn't change much
7
9
  const allSameThreadSubscriptions = Object.create(null); // using it as a map that doesn't change much
10
+ // The handle each database path was last opened with on this thread, kept as a token so a retired database's closed
11
+ // store graph is not retained. A registration through any other handle would join the entry the current store's
12
+ // commits drive while reading through that handle's closed stores.
13
+ const HANDLE_TOKEN = Symbol('subscription-handle');
14
+ const currentHandles = new Map<
15
+ string,
16
+ { token: symbol; generationId: string | undefined; tracksGeneration: boolean }
17
+ >();
18
+
19
+ // A store with no generation (LMDB) is never replaced by a copy, so its reopen is only ever a close.
20
+ function generationChanged(
21
+ generationId: string | null | undefined,
22
+ currentId: string | null | undefined,
23
+ tracksGeneration: boolean
24
+ ): boolean {
25
+ return tracksGeneration && !(generationId != null && generationId === currentId);
26
+ }
27
+
28
+ function closingError(auditStore: any, path: string): DatabaseClosingError {
29
+ return new DatabaseClosingError(auditStore?.rootStore?.databaseName ?? basename(path));
30
+ }
8
31
  /**
9
32
  * This module/function is responsible for the main work of tracking subscriptions and listening for new transactions
10
33
  * that have occurred on any thread, and then reading through the transaction log to notify listeners. This is
@@ -24,6 +47,15 @@ export function addSubscription(table, key, listener?: (key) => any, startTime?:
24
47
  if (!path) {
25
48
  throw new Error('No path for table primary store');
26
49
  }
50
+ const generationId = table.auditStore?.databaseGeneration?.id ?? null;
51
+ const current = currentHandles.get(path);
52
+ const replaced = current !== undefined && table.auditStore?.[HANDLE_TOKEN] !== current.token;
53
+ if (replaced || table.auditStore?.rootStore?.status === 'closed') {
54
+ if (options?.scope === 'full-database') return;
55
+ throw replaced && generationChanged(generationId, current.generationId, current.tracksGeneration)
56
+ ? new DatabaseGenerationChangedError()
57
+ : closingError(table.auditStore, path);
58
+ }
27
59
  if (options?.crossThreads === false) {
28
60
  // we are only listening for commits on our own thread, so we use a separate subscriber and sequencer tracker
29
61
  databaseSubscriptions = allSameThreadSubscriptions[path] || (allSameThreadSubscriptions[path] = []);
@@ -60,6 +92,7 @@ export function addSubscription(table, key, listener?: (key) => any, startTime?:
60
92
  }
61
93
  }
62
94
  databaseSubscriptions.auditStore = table.auditStore;
95
+ databaseSubscriptions.generationId ??= generationId;
63
96
  if (databaseSubscriptions.lastTxnTime == null) {
64
97
  databaseSubscriptions.lastTxnTime = Date.now();
65
98
  }
@@ -92,6 +125,42 @@ export function addSubscription(table, key, listener?: (key) => any, startTime?:
92
125
  return subscription;
93
126
  }
94
127
 
128
+ /**
129
+ * End every subscription this thread registered on the database before `auditStore` reopened it: its commit
130
+ * listener and its table's stores belong to the closed handle, so it can never deliver again. On a store that
131
+ * tracks generations, another or an unknown one must resynchronize; otherwise the retryable
132
+ * `DatabaseClosingError`. `auditStore` becomes the only handle `addSubscription` accepts on the path before
133
+ * any listener runs.
134
+ */
135
+ export function endSubscriptionsFromEarlierHandles(auditStore: any, tracksGeneration: boolean): void {
136
+ const path = auditStore.rootStore.path;
137
+ const generationId = auditStore.databaseGeneration?.id;
138
+ const token = Symbol(basename(path));
139
+ auditStore[HANDLE_TOKEN] = token;
140
+ currentHandles.set(path, { token, generationId, tracksGeneration });
141
+ for (const registry of [allSubscriptions, allSameThreadSubscriptions]) {
142
+ const databaseSubscriptions = registry[path];
143
+ if (!databaseSubscriptions) continue;
144
+ delete registry[path];
145
+ const changed = generationChanged(databaseSubscriptions.generationId, generationId, tracksGeneration);
146
+ for (const tableId in databaseSubscriptions) {
147
+ const tableSubscriptions = databaseSubscriptions[tableId];
148
+ if (!(tableSubscriptions instanceof Map)) continue;
149
+ for (const keySubscriptions of tableSubscriptions.values()) {
150
+ for (const subscription of [...keySubscriptions]) {
151
+ try {
152
+ subscription.close(changed ? new DatabaseGenerationChangedError() : closingError(auditStore, path));
153
+ } catch (error) {
154
+ try {
155
+ warn(error);
156
+ } catch {}
157
+ }
158
+ }
159
+ }
160
+ }
161
+ }
162
+ }
163
+
95
164
  /**
96
165
  * This is the class that is returned from subscribe calls and provide the interface to set a callback, end the
97
166
  * subscription and get the initial state.
@@ -29,7 +29,7 @@ Four constraints that look like choices but are not:
29
29
  3. **`createOperationToken`, not `createTokens`.** `createTokens` overwrites `hdb_user.refresh_token` as a side effect, so minting for CI would silently revoke whatever credential that user already held (#2018) — the exact problem this feature removes.
30
30
  4. **The role is the boundary; the per-policy `operations` allowlist only narrows it.** Least privilege is primarily the role of the user the policy names. A policy may _optionally_ carry an `operations` scope, which can only subtract from that role — never add to it. It is deliberately not merged into `permission.operations`: gate 2 in `operation_authorization.ts` treats an explicit listing of an SU-only operation as a deliberate grant, so reusing that field would _widen_ where this must only narrow. The scope is carried as a separate `tokenOperations` claim and intersected ahead of every early return, including the super_user bypass.
31
31
 
32
- Its enforcement surface is the operations API and SQL (`verifyPerms` / `verifyPermsAST`) — **not** the application REST/GraphQL resource path, which authorizes through table-level `checkPermission` and does not consult the scope. A scoped token therefore still carries its role's full CRUD there, which is why the role has to be least-privilege on its own; the scope is defense in depth, not a substitute. Closing that gap is a follow-up on the same surface as CORE-3061. Because a second authorization mechanism beside roles is one more place for the two to disagree, whether to keep this at all is an open design question on #2173 rather than a settled constraint.
32
+ Its enforcement surface is the operations API and SQL (`verifyPerms` / `verifyPermsAST`) — **not** the application REST/GraphQL resource path, which authorizes through table-level `checkPermission` and does not consult the scope. A scoped token therefore still carries its role's full CRUD there, which is why the role has to be least-privilege on its own; the scope is defense in depth, not a substitute. Closing that gap is tracked in #2201. The scope shipped with #2173 and is documented in the operations API reference; whether to keep a second authorization mechanism beside roles at all — one more place for the two to disagree — was left open there.
33
33
 
34
34
  Naming `sql` in a scope grants the SQL interface, not unrestricted DML through it: a write statement additionally requires its matching data operation (`insert`/`update`/`delete`) in scope. That is what keeps `read_only` — which expands to include `sql` — from admitting a DELETE, given that `verifyPermsAST` returns early for a super_user before any table check runs.
35
35
 
@@ -99,32 +99,11 @@ early-returns (super_user, structure_user, system-table allowances): persisted r
99
99
  `super_user` with other permission keys, but inline roles can combine `structure_user` with an
100
100
  allowlist, and the gate ordering is what keeps unlisted schema ops unreachable.
101
101
 
102
- ## TLS hot-reload: cert vs. private key follow two different propagation paths (`security/keys.ts`)
103
-
104
- A renewed **certificate** and a renewed **private key** reach a worker's live TLS secure context
105
- by completely separate routes, and the two must reconverge or HTTPS breaks on that worker.
106
- Certificates propagate through data: only the main thread watches the cert file (`isMainThread`
107
- guard in `loadCertificates`) and writes the new PEM into the `system.hdb_certificate` table; every
108
- worker is subscribed to that table and rebuilds its secure contexts (`updateTLS` inside
109
- `createTLSSelector`) on the notification. Private keys never touch the table — each worker watches
110
- its own key file (the private-key `loadAndWatch` has no `isMainThread` guard) and loads the PEM
111
- straight into its in-thread `privateKeys` map. `getPrivateKeyByName` reads that map first, so an
112
- already-built secure context has the key bytes baked in (`setCert`/`setKey` at build time); a later
113
- map update does not touch contexts already built.
114
-
115
- The hazard when a rotation changes **both**: the cert can win the race to a worker (table write +
116
- subscription) and trigger `updateTLS` before that worker has reloaded the matching key, producing a
117
- context that pairs the new cert with the old key — every handshake on it then fails, and nothing
118
- rebuilds it until the _next_ cert-table change. The fix: a private-key reload (`handlePrivateKeyReload`,
119
- the single sink for both the chokidar watcher and PR #1394's periodic poll) triggers a debounced
120
- rebuild of every live selector via the module-level `liveTLSRebuilders` set, so the worker reconverges
121
- on its own. Subtleties to preserve: the rotation guard (`previous !== undefined && previous !== key`)
122
- must skip both the initial load and identical-content reloads or watchers thrash; transient one-shot
123
- selectors (`getReplicationCert`) pass `liveReload=false` so they don't accumulate in the never-pruned
124
- set; and the cert subscription shares the same debounced `scheduleRebuild` (same 1500ms cadence), so
125
- its coalescing must stay a superset-safe no-op for the single-swap #586 case. Regression coverage:
126
- `integrationTests/security/cert-key-reload.test.ts` deterministically pins the cert-before-key ordering
127
- (it fails by design without the rebuild trigger); `cert-reload.test.ts` guards the cert-only #586 path.
102
+ ## TLS hot-reload publishes matching configured pairs (`security/keys.ts`)
103
+
104
+ `loadCertificates` publishes a configured leaf only after `X509Certificate.checkPrivateKey` validates the exact PEM being stored. Only the main thread watches the certificate and key directories; its filename-filtered, shallow watches and five-minute safety poll share a cert/key fingerprint. An incomplete pair returns `false` so it is retried, with a pending-pair warning and an error after 30 seconds (immediate when the new certificate name has no non-self-signed record, then hourly). Restoring the last applied files after an aborted renewal re-enters the loader to clear the pending alarm. CA-only records remain independent of the leaf key. Workers use the `hdb_certificate` subscription as their reload trigger and read configured key files when building contexts; no worker key watcher or poll races that notification. Configured keys remain in `getPrivateKeys()` for key retrieval, but entries populated from files are refreshed for retrieval and rebuilds, retaining their cached value while the file is temporarily unreadable; explicit values that differ from the last file-derived key still take precedence. A directory that cannot be watched because it is not readable falls back to Node file polling, which remains armed while configured files are absent, independently of the optional five-minute safety poll. Enforced by the rename-order, per-worker, poll-disabled cases in `integrationTests/security/cert-key-reload.test.ts` and loader tests in `unitTests/security/keys.test.js`.
105
+
106
+ An unrelated table notification during a key-first installation gap, or a second rotation between publication and a worker's key read, can still produce a transient mismatch; the trust-aware last-good retention below protects serving while the next complete pair propagates. The older-file guard still prevents disk from replacing a newer replicated certificate.
128
107
 
129
108
  **Publication is transactional (#2382).** `updateTLS` builds the entire replacement state —
130
109
  hostname→context map, CA map, and default candidate — into pass-local candidates and reconciles the
@@ -144,9 +123,9 @@ the old state (availability outranks the drop in that corner) while the failure
144
123
  row is a pass failure like any other (reported through the signature throttle, armed for retry) and
145
124
  its trust drops until it heals. A failed pass arms a
146
125
  self-retry on the shared debounce with a per-signature backoff (1.5s doubling to 5min) and
147
- signature-throttled logging; external triggers (table subscription, key reload) stay at the plain
148
- debounce. `loadAndWatch` latches its mtime before the callback for chokidar/poll dedupe, but rolls
149
- the latch back on a synchronous throw or a rejected callback promise (equality-guarded so a stale
126
+ signature-throttled logging; external triggers (table subscription) stay at the plain
127
+ debounce. `loadAndWatch` latches its cert/key fingerprint before the callback for chokidar/poll dedupe, but rolls
128
+ the latch back on a false result, synchronous throw, or rejected callback promise (equality-guarded so a stale
150
129
  rejection cannot unlatch a newer reload) — the latch means "last successfully applied", so the
151
130
  periodic poll can heal a lost `hdb_certificate` write instead of deduplicating it forever.
152
131