@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
package/bin/copyDb.ts CHANGED
@@ -15,7 +15,7 @@ import OpenEnvironmentObject from '../utility/lmdb/OpenEnvironmentObject.ts';
15
15
  import { OpenDBIObject } from '../utility/lmdb/OpenDBIObject.ts';
16
16
  import { INTERNAL_DBIS_NAME, AUDIT_STORE_NAME } from '../utility/lmdb/terms.ts';
17
17
  import { CONFIG_PARAMS, DATABASES_DIR_NAME, MIGRATING_DIR_SUFFIX } from '../utility/hdbTerms.ts';
18
- import { AUDIT_STORE_OPTIONS, auditRetention } from '../resources/auditStore.ts';
18
+ import { AUDIT_STORE_OPTIONS, auditRetention, stampDatabaseGeneration } from '../resources/auditStore.ts';
19
19
  import { blobsReadmeContent, copyBlobRootsByIndex } from '../dataLayer/blobBackup.ts';
20
20
  import { describeSchema } from '../dataLayer/schemaDescribe.ts';
21
21
  import { updateConfigValue } from '../config/configUtils.ts';
@@ -983,6 +983,10 @@ export async function copyDbToRocks(sourceRootStore, sourceDatabase: string, tar
983
983
  targetRootStore.putSync(REMOTE_NODE_IDS_KEY, asBinary(idMappingBytes));
984
984
  }
985
985
 
986
+ // flushed so the stamp is durable before the caller publishes the staging directory
987
+ stampDatabaseGeneration(targetRootStore, { carriesLog: false });
988
+ await targetRootStore.flush({ allowWriteStall: true });
989
+
986
990
  console.log('migrated database ' + sourceDatabase + ' to RocksDB');
987
991
  } finally {
988
992
  endPendingMigrationBlobSaves();
@@ -149,6 +149,9 @@ export class DeployRenderer {
149
149
  this.output.write(`error: ${e.message ?? message.data}${e.code ? ` (${e.code})` : ''}\n`);
150
150
  break;
151
151
  }
152
+ case 'warning':
153
+ this.output.write(`warning: ${(parsed as { message?: string })?.message ?? message.data}\n`);
154
+ break;
152
155
  case 'done':
153
156
  // Caller picks up final result via the SSE iterator; nothing to render here.
154
157
  break;
@@ -37,6 +37,14 @@ import {
37
37
  formatDeploymentProvenance,
38
38
  parseDeploymentProvenance,
39
39
  } from './deploymentProvenance.ts';
40
+ import {
41
+ fingerprintInstall,
42
+ gitSourceIdentity,
43
+ packedSourceIdentity,
44
+ PACKAGE_LOCK_FILES,
45
+ UNIDENTIFIED_SOURCE,
46
+ type InstallFingerprint,
47
+ } from './installFingerprint.ts';
40
48
 
41
49
  import { basename, dirname, extname, isAbsolute, join, relative, sep, win32 } from 'node:path';
42
50
  import {
@@ -490,8 +498,9 @@ async function resolveCommittish(application: Application, committish: string, c
490
498
  async function packGitReferenceWithoutScripts(
491
499
  application: Application,
492
500
  gitRef: GitReference,
493
- parentDirPath: string
494
- ): Promise<string> {
501
+ parentDirPath: string,
502
+ identifySource: boolean
503
+ ): Promise<{ path: string; commit?: string }> {
495
504
  const cloneDir = await mkdtemp(join(tmpdir(), 'harper-git-clone-'));
496
505
  try {
497
506
  const { code: cloneCode, stderr: cloneStderr } = await nonInteractiveSpawn(
@@ -537,22 +546,37 @@ async function packGitReferenceWithoutScripts(
537
546
  await writeFile(manifestPath, JSON.stringify(manifest, null, 2));
538
547
  }
539
548
 
540
- return await runNpmPack(application, ['pack', '--json', '--ignore-scripts', cloneDir], parentDirPath);
549
+ const commit = identifySource ? await checkedOutCommit(application, cloneDir) : undefined;
550
+ const packed = await runNpmPack(application, ['pack', '--json', '--ignore-scripts', cloneDir], parentDirPath);
551
+ return { path: packed.path, commit };
541
552
  } finally {
542
553
  await rm(cloneDir, { recursive: true, force: true });
543
554
  }
544
555
  }
545
556
 
557
+ /** Naming the source is only a report, so git failing to answer never fails the deploy. */
558
+ async function checkedOutCommit(application: Application, cloneDir: string): Promise<string | undefined> {
559
+ try {
560
+ const { code, stdout } = await nonInteractiveSpawn(application.name, 'git', ['rev-parse', 'HEAD'], cloneDir);
561
+ return code === 0 ? stdout : undefined;
562
+ } catch {
563
+ return undefined;
564
+ }
565
+ }
566
+
567
+ type PackedTarball = { path: string; name?: string; version?: string; integrity?: string };
568
+
546
569
  /**
547
- * Runs `npm pack` with the given args and returns the resulting tarball's path under `cwd`. Shared
548
- * between the git-reference reclone path above and the plain identifier path in extractApplication.
570
+ * Runs `npm pack` with the given args and returns the resulting tarball's path under `cwd`, with the name, version
571
+ * and integrity npm reported for it. Shared between the git-reference reclone path above and the plain identifier
572
+ * path in extractApplication.
549
573
  */
550
574
  async function runNpmPack(
551
575
  application: Application,
552
576
  packArgs: string[],
553
577
  cwd: string,
554
578
  gitCredentialEnv?: Record<string, string>
555
- ): Promise<string> {
579
+ ): Promise<PackedTarball> {
556
580
  const { stdout, code, stderr } = await nonInteractiveSpawn(
557
581
  application.name,
558
582
  'npm',
@@ -573,7 +597,7 @@ async function runNpmPack(
573
597
  throw new Error(`Failed to download package ${application.packageIdentifier}: ${stderr}`);
574
598
  }
575
599
 
576
- let packResult: Array<{ filename: string }>;
600
+ let packResult: Array<{ filename: string; name?: unknown; version?: unknown; integrity?: unknown }>;
577
601
  try {
578
602
  packResult = JSON.parse(stdout.slice(stdout.indexOf('[')));
579
603
  } catch (err) {
@@ -585,7 +609,14 @@ async function runNpmPack(
585
609
  throw new Error(`Unexpected npm pack output for ${application.packageIdentifier}:\n${stdout}`);
586
610
  }
587
611
 
588
- return join(cwd, packResult[0].filename);
612
+ const [packed] = packResult;
613
+ const text = (value: unknown) => (typeof value === 'string' ? value : undefined);
614
+ return {
615
+ path: join(cwd, packed.filename),
616
+ name: text(packed.name),
617
+ version: text(packed.version),
618
+ integrity: text(packed.integrity),
619
+ };
589
620
  }
590
621
 
591
622
  // Hidden directory under the components root holding component versions renamed aside
@@ -656,15 +687,6 @@ type ExtractionContext = Pick<Application, 'name' | 'dirPath' | 'logger'>;
656
687
  // module (both in source and in dist), holds no secret, and is inert without a live session.
657
688
  export const GIT_CREDENTIAL_HELPER_PATH = join(__dirname, 'gitCredentialHelper.js');
658
689
 
659
- const PACKAGE_LOCK_FILES = [
660
- 'package-lock.json',
661
- 'npm-shrinkwrap.json',
662
- 'pnpm-lock.yaml',
663
- 'yarn.lock',
664
- 'bun.lock',
665
- 'bun.lockb',
666
- ];
667
-
668
690
  type InstalledPackageMetadata = {
669
691
  files: Map<string, Buffer>;
670
692
  readable: boolean;
@@ -783,7 +805,10 @@ type ResolvedTarball =
783
805
  | { kind: 'link'; sourceDirPath: string };
784
806
 
785
807
  /** Resolve `payload` or `package` into a tarball stream. Touches neither the live tree nor staging. */
786
- async function resolveApplicationTarball(application: Application): Promise<ResolvedTarball> {
808
+ async function resolveApplicationTarball(
809
+ application: Application,
810
+ { identifySource = false }: { identifySource?: boolean } = {}
811
+ ): Promise<ResolvedTarball> {
787
812
  let tarballPath: string | undefined;
788
813
  let tarball: Readable;
789
814
  let shouldDeleteTarball = false;
@@ -809,6 +834,8 @@ async function resolveApplicationTarball(application: Application): Promise<Reso
809
834
  // If the package identifier is a file path we need to check if its a tarball or a directory
810
835
  if (application.packageIdentifier.startsWith('file:')) {
811
836
  const packagePath = application.packageIdentifier.slice(5);
837
+ // Each node reads its own copy of a local path, so nothing here names what it holds.
838
+ if (identifySource) application.sourceIdentity = UNIDENTIFIED_SOURCE;
812
839
  try {
813
840
  // Have to remove the 'file:' prefix in order to use fs methods
814
841
  const stats = await stat(packagePath);
@@ -880,7 +907,9 @@ async function resolveApplicationTarball(application: Application): Promise<Reso
880
907
  }
881
908
 
882
909
  if (gitRef) {
883
- tarballPath = await packGitReferenceWithoutScripts(application, gitRef, parentDirPath);
910
+ const packed = await packGitReferenceWithoutScripts(application, gitRef, parentDirPath, identifySource);
911
+ tarballPath = packed.path;
912
+ if (identifySource) application.sourceIdentity = gitSourceIdentity(packed.commit);
884
913
  } else {
885
914
  const packArgs = ['pack', '--json', packageIdentifierForPack];
886
915
  if (!allowScripts) {
@@ -892,7 +921,15 @@ async function resolveApplicationTarball(application: Application): Promise<Reso
892
921
  `can read the git credential. Unset install_allow_scripts to keep the credential out of their reach.`
893
922
  );
894
923
  }
895
- tarballPath = await runNpmPack(application, packArgs, parentDirPath, application.gitCredentialEnv);
924
+ const packed = await runNpmPack(application, packArgs, parentDirPath, application.gitCredentialEnv);
925
+ tarballPath = packed.path;
926
+ if (identifySource) {
927
+ const fromRegistry =
928
+ packageIdentifierForPack === application.packageIdentifier &&
929
+ !looksLikeGitReference(packageIdentifierForPack) &&
930
+ !/^https?:\/\//i.test(packageIdentifierForPack);
931
+ application.sourceIdentity = packedSourceIdentity(fromRegistry, packed);
932
+ }
896
933
  }
897
934
  shouldDeleteTarball = true;
898
935
  tarball = createReadStream(tarballPath);
@@ -3534,7 +3571,7 @@ async function discardCandidate(application: Application, deploymentId: string):
3534
3571
  export async function buildCandidateApplication(
3535
3572
  application: Application,
3536
3573
  deploymentId: string,
3537
- options: { rejectLinkSource?: boolean } = {}
3574
+ options: { rejectLinkSource?: boolean; fingerprint?: boolean } = {}
3538
3575
  ): Promise<string> {
3539
3576
  const deploymentDirPath = candidateDeploymentDirPath(application.dirPath, deploymentId);
3540
3577
  const candidateDirPath = candidateApplicationPath(application.dirPath, deploymentId);
@@ -3543,7 +3580,7 @@ export async function buildCandidateApplication(
3543
3580
  try {
3544
3581
  // Replaced, not extracted into: a prior attempt on this id may have left a partial tree.
3545
3582
  await rm(candidateDirPath, { recursive: true, force: true });
3546
- const resolved = await resolveApplicationTarball(application);
3583
+ const resolved = await resolveApplicationTarball(application, { identifySource: options.fingerprint });
3547
3584
  if (resolved.kind === 'link' && options.rejectLinkSource) {
3548
3585
  // What the operator asked for, not a server fault: the same component deploys immediately.
3549
3586
  throw new ClientError(
@@ -3578,6 +3615,9 @@ export async function buildCandidateApplication(
3578
3615
  if (resolved.kind !== 'link') {
3579
3616
  await writeDeploymentProvenance(candidateDirPath, application.name, deploymentId);
3580
3617
  }
3618
+ if (options.fingerprint) {
3619
+ application.installFingerprint = await fingerprintInstall(candidateDirPath, application.sourceIdentity);
3620
+ }
3581
3621
  return candidateDirPath;
3582
3622
  } catch (error) {
3583
3623
  await discardCandidate(application, deploymentId);
@@ -4534,6 +4574,8 @@ export class Application {
4534
4574
  packageMetadataChanged: boolean = false;
4535
4575
  installationIsOpaque: boolean = false;
4536
4576
  alreadyActive: boolean = false;
4577
+ sourceIdentity?: string;
4578
+ installFingerprint?: InstallFingerprint;
4537
4579
 
4538
4580
  constructor({ name, payload, packageIdentifier, install, onInstallLine, credentials }: ApplicationOptions) {
4539
4581
  this.name = name;
@@ -4721,6 +4763,7 @@ export type PrepareApplicationOptions = {
4721
4763
  describeArtifact?: () => { rootConfig: Record<string, unknown> | null; isolated: boolean };
4722
4764
  /** `activate` only: admit the artifact's isolation intent under the preparation lock, before the swap. */
4723
4765
  admitIsolation?: (descriptor: ArtifactDescriptor) => Promise<void>;
4766
+ fingerprintInstall?: boolean;
4724
4767
  };
4725
4768
 
4726
4769
  export async function prepareApplication(application: Application, options: PrepareApplicationOptions = {}) {
@@ -4789,6 +4832,7 @@ export async function prepareApplication(application: Application, options: Prep
4789
4832
  await application.startGitCredentialSession();
4790
4833
  candidateDirPath = await buildCandidateApplication(application, artifactId, {
4791
4834
  rejectLinkSource: mode === 'stage',
4835
+ fingerprint: options.fingerprintInstall,
4792
4836
  });
4793
4837
  } finally {
4794
4838
  await application.cleanupGitCredentialSession();
@@ -41,7 +41,8 @@ The ordering is the design. Three things used to be wrong, the first two in a wa
41
41
 
42
42
  - **The live tree was moved aside first**, so the component was broken for the whole extract +
43
43
  `npm install`. Worse than unavailable — the live path held the _new_ code before its dependencies were
44
- installed, so requests during a deploy hit an unrunnable tree. `stage-swap-availability.test.ts`
44
+ installed, so requests during a deploy hit an unrunnable tree.
45
+ `integrationTests/deploy/stage-swap-availability.test.ts`
45
46
  samples the live path through a deliberately blocked install and fails against the old ordering.
46
47
  - **Validation ran after the swap committed**, so a component that installed cleanly but threw at load
47
48
  went live anyway while the operation returned an error. Validation is now a callback preparation
@@ -248,8 +249,7 @@ boundary settles config to the same end state as the tree. `deploy_component` on
248
249
  (`describeArtifact`); `prepareApplication` turns the declaration into a `RootConfigEffect`
249
250
  (`components/rootConfigPublication.ts`) and `activateCandidateApplication` owns applying it:
250
251
 
251
- - `set` — a package build publishes the entry it was declared with, replacing the component's entry whole,
252
- as `addConfig` did.
252
+ - `set` — a package build publishes the entry it was declared with, replacing the component's entry whole.
253
253
  - `unset-package` — a payload build removes `package`, `install` and `credentials` and keeps the rest
254
254
  (`isolated`, `urlPath`, `host`, `branchedDatabases`), removing the entry if nothing remains. "No package"
255
255
  is an opinion: left in place, a cold install resolves the old package over the payload release that is
@@ -303,9 +303,9 @@ could never settle. The reverse direction is not covered: a build before this ch
303
303
  fails that component closed, so settle every interrupted activation (a clean start does) before downgrading.
304
304
 
305
305
  **One writer, one lock, durable.** `applyRootConfigEffect` is the only runtime read-modify-write of the root
306
- config document besides `set_configuration`, which takes the same lock around `updateConfigValue` — and that
307
- reads and writes the same file the lock is keyed by, the one boot reads. `addConfig` and `deleteConfigFromFile`
308
- are gone — the latter wrote to a path rebuilt from the document's `rootPath` rather than the file it parsed. The
306
+ config document besides `set_configuration` and the deprecated `drop_custom_function_project`'s removal from
307
+ the legacy `apps` list, both of which take the same lock around `updateConfigValue` — and that reads and writes
308
+ the same file the lock is keyed by, the one boot reads, not a path rebuilt from the document's `rootPath`. The
309
309
  lock is the component preparation lock primitive keyed by `getRootConfigFilePath()`, which names the file boot
310
310
  reads whenever a boot source exists and so is fixed for the life of the process — not a configured path
311
311
  `set_configuration` could move under a concurrent writer. Lock order is always
@@ -372,7 +372,8 @@ removed — doing that for retained builds on every pass made a healthy componen
372
372
  sibling threads at boot and be deferred with nothing in progress. A lock a live deploy holds is still recorded as
373
373
  that same deferral: "do not delete" is not "safe to load". The deploy path prunes inside the settlement scan
374
374
  it already runs under the lock, before building, so a deploy pays one traversal of the staging root.
375
- `dropComponentDirectory` reclaims the dropped component's dormant builds, since no later deploy of that
375
+ `drop_component` reclaims the dropped component's dormant builds (`retireComponentDirectory(...).discard()`),
376
+ since no later deploy of that
376
377
  name will. Only ENOENT is absence; any other read error keeps the entry and moves on. Pruning is disk
377
378
  hygiene: it never fails a component closed and never replaces a deploy's own error, so the bound is
378
379
  best-effort under filesystem failure and is not a storage quota — journaled, unsettled and unowned
@@ -454,13 +455,13 @@ it. An older build reads a record as residue and removes it, and a kept release
454
455
  can activate; the marker is an ignored file to it. The journal format is unchanged. A peer on an older build
455
456
  answers 404 to a consumed id, as before. `deployment_stagingRetention_maxCount` (default 5) now bounds kept
456
457
  releases with staged builds, and each is a whole installed tree, so at the default a component holds up to five
457
- extra copies of itself; `0` keeps no previous release, and the rest of this section still applies. Until #2315
458
- step 7, a package component reinstalls from its source on the first start after each package deploy; that
459
- reinstall keeps no record, but the certified tree it displaces is kept, which is the one worth keeping.
458
+ extra copies of itself; `0` keeps no previous release, and the rest of this section still applies. A package
459
+ component reinstalls from its source on the first start after each package deploy (#2315 step 7 shipped as
460
+ reporting only, #2929); that reinstall keeps no record, but the certified tree it displaces is kept, which is the one worth keeping.
460
461
 
461
462
  ## Component preparation is serialized across worker threads
462
463
 
463
- `prepareApplication()` performs one transaction per component: build the replacement, validate it, then swap it in (see "A deploy builds off to the side" below). Deploy operations can execute on worker threads as well as main, so a module-local promise queue is insufficient—each worker has its own module registry. `withComponentPreparationLock()` (`components/componentPreparationLock.ts`) instead acquires an atomic filesystem lock keyed by the absolute component path. The deprecated `install_node_modules` operation uses the same lock, so it cannot run npm concurrently with a deploy.
464
+ `prepareApplication()` performs one transaction per component: build the replacement, validate it, then swap it in (see "A deploy builds off to the side" above). Deploy operations can execute on worker threads as well as main, so a module-local promise queue is insufficient—each worker has its own module registry. `withComponentPreparationLock()` (`components/componentPreparationLock.ts`) instead acquires an atomic filesystem lock keyed by the absolute component path. The deprecated `install_node_modules` operation uses the same lock, so it cannot run npm concurrently with a deploy.
464
465
 
465
466
  The deploy lifecycle broadcast deliberately sits _outside_ the lock. Overlapping requests therefore increment the existing per-component lifecycle refcount before queueing; watchers remain suppressed continuously until the final queued preparation ends. The lock itself covers credential materialization, extraction, and installation. Its fully-written owner record is published with an atomic rename, so contenders never observe a partially initialized lock. A preparation caller never steals a lock from a known-live owner based on elapsed wall time: installs can be long-running and clocks can jump. Locks from a dead process are reclaimed, and a same-process contender asks the main thread whether the owning worker still exists so a worker crash does not wedge that component until Harper restarts. A ticket its owner could not remove — a Windows sharing violation, a scanner holding the file — would name a finished holder that is still alive, so a release whose unlink keeps failing publishes a `<lockName>.released.<token>` marker instead, which contenders honour by clearing both. Only the ticket's owner writes one, after confirming it still owns the ticket — or, when a scanner holding the ticket without read sharing hides its record, knowing it can only be its own, since the ticket's name carries its token — and tokens are never reused, so a marker cannot retire a holder that has not finished. A process running an older build ignores markers, so overlapping processes of mixed versions do not get this guarantee, and a ticket left live-looking before the upgrade stays until that process restarts. Windows refuses to open a claim whose unlink is in progress (`EPERM`) until the unlinking handle closes, so a contender retries that read until the claim reads or is gone; one that stays unreadable fails the scan instead of reading as absent, since dropping a live ticket would admit a second holder. An older build fails the acquire on that `EPERM`. The boot-time bulk-recovery probe is deliberately different: it never renews its 250 ms deadline, even behind another live recovery, so it can defer that component and let the worker bind its listener.
466
467
 
@@ -482,29 +483,11 @@ candidate tree is complete before validation starts and nothing else writes to i
482
483
  wait for. `unitTests/components/componentLoader.test.js` ("deploy validation loads never wait on a
483
484
  deploy") holds both routes and the plain timeout.
484
485
 
485
- ### The component load lock is keyed by plugin type, so a plugin's promise is everyone's clock
486
-
487
- `sequentiallyHandleApplication` (`components/componentLoader.ts`) holds a cross-thread lock keyed by the
488
- plugin TYPE name — `graphqlSchema`, `rest`, … — not by the component. That is deliberate. Plugin modules
489
- are per-thread singletons carrying module-level state (`server/http.ts`'s `universalHeaders` ownership
490
- array, `resources/graphql.ts`'s `knownGraphQLDirectives`, the scheduler's register-inside-the-lock
491
- contract), and applications load _concurrently_: `serializeComponentLoad` serializes per application
492
- name and all applications go into one `Promise.all`. Without this key two applications' `handleApplication`
493
- for the same plugin would interleave on a single thread, not merely across threads.
494
-
495
- The price of that key is that whatever a plugin does inside the lock is paid by every other application.
496
- So a plugin must return a promise that settles with its real outcome: the `withDeployAwareTimeout`
497
- watchdog exists for a _hang_, never as the reporting path for a failure the plugin already diagnosed. A
498
- success-only wait is what turned one unparseable schema into 30s of instance-wide gating per broken
499
- component (#1917). `Scope.waitForInitialLoads()` is that promise — it resolves once the entry handler's
500
- initial scan and every operation that scan started have completed, and rejects with the first failure,
501
- after draining the rest so no sibling operation outlives the lock. The watchdog can still cut that drain
502
- short, so the serialization the lock buys is bounded by the timeout rather than absolute.
503
-
504
- Extraction renames an existing component aside before writing the replacement and keeps it until
505
- dependency installation and metadata verification complete. Any preparation failure atomically
506
- renames the partial tree into hidden staging before restoring the prior tree, so a live writer cannot
507
- wedge rollback with `ENOTEMPTY`; cleanup completes while the same-component lock is still held.
486
+ Since #2345 no preparation extracts in place; the live tree moves aside only at activation. What
487
+ remains is the legacy recovery pass (`recoverOrCleanupStaleExtractionPaths`), whose
488
+ `rollbackExtractedDirectory` atomically renames whatever holds the live path into hidden staging before
489
+ restoring the prior tree, so a live writer cannot wedge rollback with `ENOTEMPTY`; cleanup completes
490
+ while the same-component lock is still held.
508
491
  On non-root POSIX systems, rollback uses a mode-`000` placeholder to keep that writer out between
509
492
  retries. Before moving or removing it, rollback verifies the placeholder's device/inode identity and
510
493
  restores owner permissions because a cross-parent directory move updates `..` and requires write
@@ -521,8 +504,9 @@ and configuration mutations under that lock, so cleanup residue cannot resurrect
521
504
  component and a concurrent deploy cannot interleave with the drop. Peer replication begins after
522
505
  the local lock is released, and each peer serializes its own drop independently. Full-component drops
523
506
  rename the live tree into staging before best-effort cleanup, avoiding an in-place recursive-delete
524
- race with the running worker. Recovery is durable across a process crash. It relies on rename/create
525
- ordering rather than `fsync`, so a host power loss can lose the marker.
507
+ race with the running worker. Recovery is durable across a process crash. The legacy pass relies on
508
+ rename/create ordering rather than `fsync`, so a host power loss can lose its marker; activation and drop
509
+ flush theirs.
526
510
 
527
511
  A package-manager timeout must not release this lock while npm descendants are still mutating `node_modules`. POSIX spawns therefore run in a dedicated process group; timeout sends the group `SIGTERM`, escalates to `SIGKILL`, and waits for exit before rejecting. Windows uses `taskkill /T /F` for the equivalent process-tree termination. `manageThreads` tracks each spawned process tree by its owning Harper thread and force-terminates it if that worker exits, preventing detached installers from surviving a worker restart or Harper shutdown. `SIGKILL`/`taskkill` only queue termination, so a worker's dead-owner reclamation (above) waits for that thread's tracked process groups to be confirmed gone, not merely signaled—otherwise a replacement preparation could start while the old writer might still be alive. A process group a dead worker's own event loop spawned is never reaped from another thread, so it persists as a zombie rather than fully disappearing; since a zombie can no longer touch the filesystem, confirmation treats a zombie the same as a fully reaped exit.
528
512
 
@@ -553,6 +537,36 @@ type detection remains asynchronous in extraction. Bare absolute Windows directo
553
537
  npm's copy/pack behavior rather than becoming live links; explicit `file:` and relative directory
554
538
  inputs retain their existing symlink behavior.
555
539
 
540
+ ### The component load lock is keyed by application and plugin
541
+
542
+ `sequentiallyHandleApplication` (`components/componentLoader.ts`) holds a cross-thread lock keyed by the
543
+ application and the plugin type, `${appName}\0${pluginName}` (#2884, for #3184): one application's load of one
544
+ plugin is serialized across threads. Keyed by the plugin type alone, one application's hung
545
+ `handleApplication` timed out every other application's load of that plugin; NUL separates the two
546
+ because `appName` can contain dots and slashes.
547
+
548
+ Before #2884 the plugin-wide key was also deliberate for a reason #2884 did not revisit: plugin
549
+ modules are per-thread singletons carrying module-level state (`server/http.ts`'s `universalHeaders`
550
+ ownership array, `resources/graphql.ts`'s `knownGraphQLDirectives`, the scheduler's
551
+ register-inside-the-lock contract), and applications load _concurrently_ — `serializeComponentLoad`
552
+ serializes per application name and all applications go into one `Promise.all` — so two
553
+ applications' `handleApplication` for the same plugin can now interleave on a single thread. Each of
554
+ those three tolerates it: the http plugin's `handleApplication` is synchronous and only its first
555
+ (root) invocation owns the security headers, `knownGraphQLDirectives` is filled at module load and
556
+ only read in `handleApplication`, and the scheduler keys registration by application name behind an
557
+ idempotent `startSchedulerEngine()`. A plugin that keeps per-thread state across an `await` inside
558
+ `handleApplication` would not.
559
+
560
+ The price of the lock is that whatever a plugin does inside it is paid by that application's load of
561
+ the plugin on every other thread.
562
+ So a plugin must return a promise that settles with its real outcome: the `withDeployAwareTimeout`
563
+ watchdog exists for a _hang_, never as the reporting path for a failure the plugin already diagnosed. A
564
+ success-only wait is what turned one unparseable schema into 30s of instance-wide gating per broken
565
+ component (#1917). `Scope.waitForInitialLoads()` is that promise — it resolves once the entry handler's
566
+ initial scan and every operation that scan started have completed, and rejects with the first failure,
567
+ after draining the rest so no sibling operation outlives the lock. The watchdog can still cut that drain
568
+ short, so the serialization the lock buys is bounded by the timeout rather than absolute.
569
+
556
570
  ## Peer-side deploy_component payload read: retryable blob stalls and `Readable.from()` cancellation
557
571
 
558
572
  `readPayloadBlobWithRetry` (`components/deploymentRecorder.ts`) wraps the peer's read of a replicated `hdb_deployment` row's `payload_blob` so a transient 503 `BlobReadError` (`BLOB_UNAVAILABLE_STATUS`, `resources/blob.ts`) — content bytes not arriving within `blobReadTimeout`, e.g. a parked blob send on the origin — retries instead of failing the whole deploy. Two non-obvious constraints shaped the design:
@@ -585,6 +599,45 @@ the outcome there is unknown.
585
599
  It stays a `failed` peer result, because `getFailedPeers()` counts only that status, and a new one would
586
600
  read as success.
587
601
 
602
+ ## A replicated deploy reports each node's install fingerprint, and never acts on it
603
+
604
+ Every node of a replicated `deploy_component` resolves and installs the release itself, so two nodes can end
605
+ up with different code and nothing says so (#2295). Moving the release between nodes was ruled out: shipping
606
+ the built tree (#2917, closed) was too heavy, and a strict tree check would refuse a mixed-platform cluster.
607
+ So each node records what it installed, and the origin reports a difference without acting on it.
608
+
609
+ - **The fingerprint** (`components/installFingerprint.ts`) is taken only for a deploy or a stage, after the
610
+ install and before the swap (`fingerprintInstall`, a `prepareApplication` option). It has two parts:
611
+ - `source` is the resolver's own name for what it packed, never a second hash. It is `git:<commit>` from
612
+ Harper's clone, `npm:<name>@<version>` from `npm pack --json` for a registry spec (a tag resolves to one),
613
+ or npm's reported `integrity:<sri>` for a git spec npm packed itself, or a tarball URL. A source the
614
+ resolver could not name is `unidentified`, which never matches. So is a local `file:` path, because each
615
+ node reads its own copy. The one exception is an absolute directory path given without `file:` on Windows:
616
+ Harper copies it through `npm pack` instead of linking it, so npm's reported integrity for that copy names
617
+ it. A payload has no `source`: its peers read the origin's blob. Only a fingerprinting build asks git for the
618
+ commit, and a git failure there leaves the source `unidentified` rather than failing the deploy.
619
+ - `lockfiles` is the sha256 of each root lockfile in `PACKAGE_LOCK_FILES`, by name. An absent lockfile is
620
+ not listed. One that can't be read is `{ unreadable: <code> }`, never absent.
621
+ - **The comparison** runs once, on the origin, on the aggregate `response.replicated`, just before the
622
+ recorder re-records it (`markInstallComparisons`, `components/operations.js`). So the per-peer callback's
623
+ entries are replaced rather than contradicted.
624
+ - Each peer gets `install_matches` and `install_differs`. `install_matches` is `false` when anything
625
+ compared differs. Otherwise it is `null` when any evidence is missing, unreadable or `unidentified`, as
626
+ from an older Harper or a failed peer, and `true` only when everything compared matches.
627
+ - Only lockfile names this node recognizes are compared.
628
+ - **Where it shows:**
629
+ - one `warning` event, which the CLI prints;
630
+ - a sentence on the final message, added after staging replaces the message;
631
+ - the per-peer fields in `peer_results`, which `normalizePeerResult` keeps only once a comparison ran;
632
+ - the origin's own fingerprint, in the row's `install_fingerprint`.
633
+ - **It never changes the outcome.** Drift alone keeps success. A real peer failure still fails the deploy,
634
+ after the warning has fired.
635
+
636
+ A match means equal evidence, not identical trees. A custom `install_command` can install different
637
+ dependencies and leave the same lockfile, or none. Lockfiles written against different registry mirrors
638
+ differ while the code matches. This prevents nothing, and a restart can still re-resolve a package component
639
+ (`installConfiguredApplication`) with no report.
640
+
588
641
  ## A dangling symlink silently truncates the deploy tarball (`components/packageComponent.ts`)
589
642
 
590
643
  Packaging uses `tar-fs.pack(dir, { dereference: true })` by default (`skip_symlinks` off).
@@ -50,6 +50,7 @@ import * as mqtt from '../server/mqtt.ts';
50
50
  import { getConfigFilePath, getConfigObj, getConfigPath } from '../config/configUtils.ts';
51
51
  import { bootstrapModels, startModelsConfigHotReload } from '../resources/models/bootstrap.ts';
52
52
  import { declareDecisionTablesAtBoot } from '../resources/models/decisionStore.ts';
53
+ import { declareCalibrationsTableAtBoot } from '../resources/models/calibrationStore.ts';
53
54
  import { ErrorResource } from '../resources/ErrorResource.ts';
54
55
  import { Scope } from './Scope.ts';
55
56
  import { ApplicationScope } from './ApplicationScope.ts';
@@ -895,6 +896,7 @@ export async function loadComponent(
895
896
  await bootstrapModels(config);
896
897
  startModelsConfigHotReload();
897
898
  declareDecisionTablesAtBoot();
899
+ declareCalibrationsTableAtBoot();
898
900
  }
899
901
 
900
902
  // The `env:` block declares the component's environment expectations (string literal →
@@ -17,6 +17,7 @@ import { transaction, contextStorage } from '../resources/transaction.ts';
17
17
  import { createBlob, isSaving, deleteBlob, BLOB_UNAVAILABLE_STATUS } from '../resources/blob.ts';
18
18
  import * as terms from '../utility/hdbTerms.ts';
19
19
  import type { CredentialReference } from './secretOperations.ts';
20
+ import { isInstallFingerprint } from './installFingerprint.ts';
20
21
  import { ClientError } from '../utility/errors/hdbError.ts';
21
22
  import { logger } from '../utility/logging/logger.ts';
22
23
  import { hostname } from 'node:os';
@@ -93,11 +94,13 @@ interface CreateOptions {
93
94
  */
94
95
  function withIsolatedTransaction<T>(write: () => Promise<T>, timeoutMs?: number): Promise<T> {
95
96
  const ambient = contextStorage.getStore();
97
+ // No `signal`: tracking is observability and the deploy must still succeed, so a deploy client that
98
+ // stops waiting (CLI ^C, proxy idle timeout) must not poison these writes and strand the row in a
99
+ // non-terminal phase.
96
100
  const context = {
97
101
  user: ambient?.user,
98
102
  originatingOperation: ambient?.originatingOperation,
99
103
  session: ambient?.session,
100
- signal: ambient?.signal,
101
104
  };
102
105
  return transaction(context, (txn) => {
103
106
  if (timeoutMs != null) {
@@ -742,6 +745,20 @@ function normalizePeerResult(raw: unknown): Record<string, unknown> {
742
745
  : null,
743
746
  started_at: r.started_at ?? null,
744
747
  completed_at: r.completed_at ?? null,
748
+ ...installComparisonFields(r),
749
+ };
750
+ }
751
+
752
+ /** Present only once the origin compared this peer's install fingerprint with its own. */
753
+ function installComparisonFields(r: Record<string, unknown>): Record<string, unknown> {
754
+ if (!('install_matches' in r)) return {};
755
+ const install = r.install ?? (r.value as any)?.install ?? (r.body as any)?.install;
756
+ return {
757
+ install: isInstallFingerprint(install) ? install : null,
758
+ install_matches: typeof r.install_matches === 'boolean' ? r.install_matches : null,
759
+ install_differs: Array.isArray(r.install_differs)
760
+ ? r.install_differs.filter((name) => typeof name === 'string')
761
+ : [],
745
762
  };
746
763
  }
747
764
 
@@ -0,0 +1,123 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { createReadStream } from 'node:fs';
3
+ import { join } from 'node:path';
4
+
5
+ export const PACKAGE_LOCK_FILES = [
6
+ 'package-lock.json',
7
+ 'npm-shrinkwrap.json',
8
+ 'pnpm-lock.yaml',
9
+ 'yarn.lock',
10
+ 'bun.lock',
11
+ 'bun.lockb',
12
+ ];
13
+
14
+ export type UnreadableLockfile = { unreadable: string };
15
+
16
+ /** A packed source the resolver could not name: evidence of nothing, so it never matches. */
17
+ export const UNIDENTIFIED_SOURCE = 'unidentified';
18
+
19
+ /**
20
+ * What an install came from, as the resolver already identified it — `npm:<name>@<version>`, `git:<commit>`, or
21
+ * npm's `integrity:<sri>` for a source that has neither — and each root lockfile it left, by file name: its sha256,
22
+ * or why it could not be read. An absent lockfile is not listed; a payload has no `source`.
23
+ */
24
+ export type InstallFingerprint = { source?: string; lockfiles: Record<string, string | UnreadableLockfile> };
25
+
26
+ /** `matches` is false when anything differs, else null when any evidence is missing, unreadable or unidentified. */
27
+ export type InstallComparison = { matches: boolean | null; differs: string[]; peerSource?: string };
28
+
29
+ export async function fingerprintInstall(treePath: string, source?: string): Promise<InstallFingerprint> {
30
+ const digests = await Promise.all(PACKAGE_LOCK_FILES.map((name) => hashLockfile(join(treePath, name))));
31
+ const lockfiles: InstallFingerprint['lockfiles'] = {};
32
+ PACKAGE_LOCK_FILES.forEach((name, index) => {
33
+ if (digests[index] !== undefined) lockfiles[name] = digests[index];
34
+ });
35
+ return source === undefined ? { lockfiles } : { source, lockfiles };
36
+ }
37
+
38
+ async function hashLockfile(filePath: string): Promise<string | UnreadableLockfile | undefined> {
39
+ const hash = createHash('sha256');
40
+ try {
41
+ for await (const chunk of createReadStream(filePath)) hash.update(chunk);
42
+ } catch (error) {
43
+ const code = (error as NodeJS.ErrnoException | undefined)?.code;
44
+ return code === 'ENOENT' ? undefined : { unreadable: typeof code === 'string' ? code : 'EUNKNOWN' };
45
+ }
46
+ return hash.digest('hex');
47
+ }
48
+
49
+ const COMMIT = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
50
+
51
+ export function gitSourceIdentity(commit: string | undefined): string {
52
+ const trimmed = commit?.trim();
53
+ return trimmed && COMMIT.test(trimmed) ? `git:${trimmed}` : UNIDENTIFIED_SOURCE;
54
+ }
55
+
56
+ /**
57
+ * A registry spec, a tag included, resolves to a `name@version` that can never change. A git spec npm packed itself,
58
+ * or a tarball URL, has no such name, so npm's own integrity for what it packed stands in.
59
+ */
60
+ export function packedSourceIdentity(
61
+ fromRegistry: boolean,
62
+ packed: { name?: string; version?: string; integrity?: string }
63
+ ): string {
64
+ if (fromRegistry) return packed.name && packed.version ? `npm:${packed.name}@${packed.version}` : UNIDENTIFIED_SOURCE;
65
+ return packed.integrity ? `integrity:${packed.integrity}` : UNIDENTIFIED_SOURCE;
66
+ }
67
+
68
+ const SHA256_HEX = /^[0-9a-f]{64}$/;
69
+ const SOURCE = /^(?:(?:npm|git|integrity):\S{1,512}|unidentified)$/;
70
+ const identified = (source: string | undefined) => source !== undefined && source !== UNIDENTIFIED_SOURCE;
71
+
72
+ export function isInstallFingerprint(value: unknown): value is InstallFingerprint {
73
+ const fingerprint = value as InstallFingerprint | undefined;
74
+ const lockfiles = fingerprint?.lockfiles;
75
+ if (!lockfiles || typeof lockfiles !== 'object' || Array.isArray(lockfiles)) return false;
76
+ if (
77
+ fingerprint.source !== undefined &&
78
+ !(typeof fingerprint.source === 'string' && SOURCE.test(fingerprint.source))
79
+ ) {
80
+ return false;
81
+ }
82
+ return Object.values(lockfiles).every((digest) =>
83
+ typeof digest === 'string' ? SHA256_HEX.test(digest) : typeof digest?.unreadable === 'string'
84
+ );
85
+ }
86
+
87
+ /** Only the lockfile names this node recognizes are compared, so a peer that recognizes more is not a difference. */
88
+ export function compareInstallFingerprints(own: InstallFingerprint | undefined, peer: unknown): InstallComparison {
89
+ if (!isInstallFingerprint(own) || !isInstallFingerprint(peer)) return { matches: null, differs: [] };
90
+ const differs: string[] = [];
91
+ let unavailable = false;
92
+ if (own.source !== undefined || peer.source !== undefined) {
93
+ if (!identified(own.source) || !identified(peer.source)) unavailable = true;
94
+ else if (own.source !== peer.source) differs.push('source');
95
+ }
96
+ for (const name of PACKAGE_LOCK_FILES) {
97
+ const mine = own.lockfiles[name];
98
+ const theirs = peer.lockfiles[name];
99
+ if (mine === undefined && theirs === undefined) continue;
100
+ if (mine === undefined || theirs === undefined) differs.push(name);
101
+ else if (typeof mine !== 'string' || typeof theirs !== 'string') unavailable = true;
102
+ else if (mine !== theirs) differs.push(name);
103
+ }
104
+ const comparison: InstallComparison = { matches: differs.length > 0 ? false : unavailable ? null : true, differs };
105
+ if (differs.includes('source')) comparison.peerSource = peer.source;
106
+ return comparison;
107
+ }
108
+
109
+ export function describeInstallDrift(
110
+ peers: Array<{ node: string | null; comparison: InstallComparison }>
111
+ ): string | undefined {
112
+ const differing = peers.filter((peer) => peer.comparison.matches === false);
113
+ if (differing.length === 0) return undefined;
114
+ const detail = differing
115
+ .map(({ node, comparison }) => {
116
+ const fields = comparison.differs.map((field) =>
117
+ field === 'source' && comparison.peerSource ? `source ${comparison.peerSource}` : field
118
+ );
119
+ return `${node ?? 'unknown'} (${fields.join(', ')})`;
120
+ })
121
+ .join(', ');
122
+ return `Install fingerprints differ from this node's on ${differing.length} of ${peers.length} peer node(s): ${detail}.`;
123
+ }
@@ -51,12 +51,12 @@ A handful of design points are non-obvious and easy to break:
51
51
  honor its `post`/`patch` overrides, matching REST — previously they bound to the base table class and
52
52
  silently bypassed those overrides. Advertised CRUD output schemas are still table-derived, so an overridden
53
53
  write verb whose return diverges from `{ id }`/`{ ok }`/`{ deleted }` advertises a subset shape (the in-use
54
- SDK tolerates supersets; tightening per-override envelopes is sibling-issue work — see the `derive.ts`
54
+ SDK tolerates supersets; tightening per-override envelopes is sibling-issue work — see the `tools/schemas/derive.ts`
55
55
  envelope note).
56
56
 
57
57
  - **Resource subscriptions are row-backed via the audit log.** `resources/subscribe` resolves the URI to a
58
- Resource and drives `Table.subscribe` off the audit-store `'committed'` path (same machinery as the
59
- "Audit-store `'committed'` notification batching" section above). The targeting is the subtle part:
58
+ Resource and drives `Table.subscribe` off the audit-store `'committed'` path (same machinery as
59
+ [Audit-store `'committed'` notification batching](../../resources/DESIGN.md#audit-store-committed-notification-batching-transactionbroadcastts)). The targeting is the subtle part:
60
60
  `getMatch` returns the matched Resource plus the remaining path on `relativeURL`, and `subscribeToResource`
61
61
  sets **both** `request.id` (the record key, or `undefined`) **and** `request.isCollection` from it. A record
62
62
  URI (`…/WorkItem/42`) watches that record; a collection URI (`…/WorkItem`, what `resources/list` advertises)
@@ -81,7 +81,7 @@ A handful of design points are non-obvious and easy to break:
81
81
  - **Test seams avoid loading thread/audit machinery in unit tests.** `_setSubscribeImplForTest`
82
82
  (`resources.ts`) and `_setItcForTest` (`serverRequests.ts`) inject fakes so the unit suite needn't spin up
83
83
  the audit log or ITC. Consequence: the subscribe **targeting** logic (`id`/`isCollection` derivation) is
84
- _bypassed_ by the seam and is therefore covered at the **integration** level (`sse-listchanged.test.ts` N3
84
+ _bypassed_ by the seam and is therefore covered at the **integration** level (`integrationTests/mcp/sse-listchanged.test.ts` N3
85
85
  record / N4 collection), not in unit tests.
86
86
 
87
87
  - **Discovery filtering must mirror dispatch's ORDER _and_ its name namespace.**