@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.
- package/bin/copyDb.ts +5 -1
- package/bin/deployRenderer.ts +3 -0
- package/components/Application.ts +66 -22
- package/components/DESIGN.md +89 -36
- package/components/componentLoader.ts +2 -0
- package/components/deploymentRecorder.ts +18 -1
- package/components/installFingerprint.ts +123 -0
- package/components/mcp/DESIGN.md +4 -4
- package/components/operations.js +29 -0
- package/config/DESIGN.md +5 -5
- package/dataLayer/DESIGN.md +37 -22
- package/dataLayer/backupRepository.ts +248 -0
- package/dataLayer/restoreMarker.ts +30 -75
- package/dataLayer/rocksdbBackup.ts +253 -110
- package/dist/bin/copyDb.js +3 -0
- package/dist/bin/copyDb.js.map +1 -1
- package/dist/bin/deployRenderer.js +3 -0
- package/dist/bin/deployRenderer.js.map +1 -1
- package/dist/components/Application.d.ts +5 -0
- package/dist/components/Application.js +51 -19
- package/dist/components/Application.js.map +1 -1
- package/dist/components/componentLoader.js +2 -0
- package/dist/components/componentLoader.js.map +1 -1
- package/dist/components/deploymentRecorder.js +18 -1
- package/dist/components/deploymentRecorder.js.map +1 -1
- package/dist/components/installFingerprint.d.ts +39 -0
- package/dist/components/installFingerprint.js +113 -0
- package/dist/components/installFingerprint.js.map +1 -0
- package/dist/components/operations.d.ts +2 -0
- package/dist/components/operations.js +31 -0
- package/dist/components/operations.js.map +1 -1
- package/dist/dataLayer/backupRepository.d.ts +33 -0
- package/dist/dataLayer/backupRepository.js +232 -0
- package/dist/dataLayer/backupRepository.js.map +1 -0
- package/dist/dataLayer/restoreMarker.d.ts +4 -1
- package/dist/dataLayer/restoreMarker.js +29 -68
- package/dist/dataLayer/restoreMarker.js.map +1 -1
- package/dist/dataLayer/rocksdbBackup.js +221 -112
- package/dist/dataLayer/rocksdbBackup.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js.map +1 -1
- package/dist/json/systemSchema.json +14 -0
- package/dist/resources/DatabaseTransaction.d.ts +140 -11
- package/dist/resources/DatabaseTransaction.js +1055 -464
- package/dist/resources/DatabaseTransaction.js.map +1 -1
- package/dist/resources/IterableEventQueue.d.ts +2 -1
- package/dist/resources/IterableEventQueue.js +16 -8
- package/dist/resources/IterableEventQueue.js.map +1 -1
- package/dist/resources/LMDBTransaction.d.ts +13 -2
- package/dist/resources/LMDBTransaction.js +219 -93
- package/dist/resources/LMDBTransaction.js.map +1 -1
- package/dist/resources/RecordEncoder.js +2 -3
- package/dist/resources/RecordEncoder.js.map +1 -1
- package/dist/resources/Resource.js +5 -6
- package/dist/resources/Resource.js.map +1 -1
- package/dist/resources/ResourceInterface.d.ts +3 -1
- package/dist/resources/ResourceInterface.js.map +1 -1
- package/dist/resources/RocksIndexStore.d.ts +2 -1
- package/dist/resources/RocksIndexStore.js +3 -0
- package/dist/resources/RocksIndexStore.js.map +1 -1
- package/dist/resources/Table.js +116 -12
- package/dist/resources/Table.js.map +1 -1
- package/dist/resources/analytics/write.js +6 -2
- package/dist/resources/analytics/write.js.map +1 -1
- package/dist/resources/auditStore.d.ts +71 -17
- package/dist/resources/auditStore.js +248 -64
- package/dist/resources/auditStore.js.map +1 -1
- package/dist/resources/branchDatabase.js +2 -0
- package/dist/resources/branchDatabase.js.map +1 -1
- package/dist/resources/databases.js +8 -2
- package/dist/resources/databases.js.map +1 -1
- package/dist/resources/indexes/fullTextNativeBinding.d.ts +6 -2
- package/dist/resources/indexes/fullTextNativeBinding.js +12 -2
- package/dist/resources/indexes/fullTextNativeBinding.js.map +1 -1
- package/dist/resources/indexes/fullTextQueryIndex.d.ts +4 -0
- package/dist/resources/indexes/fullTextQueryIndex.js +207 -10
- package/dist/resources/indexes/fullTextQueryIndex.js.map +1 -1
- package/dist/resources/models/Models.d.ts +11 -0
- package/dist/resources/models/Models.js +95 -4
- package/dist/resources/models/Models.js.map +1 -1
- package/dist/resources/models/backendRegistry.d.ts +8 -0
- package/dist/resources/models/backendRegistry.js +15 -1
- package/dist/resources/models/backendRegistry.js.map +1 -1
- package/dist/resources/models/bootstrap.d.ts +2 -0
- package/dist/resources/models/bootstrap.js +6 -0
- package/dist/resources/models/bootstrap.js.map +1 -1
- package/dist/resources/models/calibration.d.ts +88 -0
- package/dist/resources/models/calibration.js +244 -0
- package/dist/resources/models/calibration.js.map +1 -0
- package/dist/resources/models/calibrationStore.d.ts +163 -0
- package/dist/resources/models/calibrationStore.js +986 -0
- package/dist/resources/models/calibrationStore.js.map +1 -0
- package/dist/resources/models/decisionStore.d.ts +24 -0
- package/dist/resources/models/decisionStore.js +45 -0
- package/dist/resources/models/decisionStore.js.map +1 -1
- package/dist/resources/models/generativeDecision.js +44 -15
- package/dist/resources/models/generativeDecision.js.map +1 -1
- package/dist/resources/models/types.d.ts +7 -0
- package/dist/resources/recordLock.d.ts +8 -5
- package/dist/resources/recordLock.js +6 -4
- package/dist/resources/recordLock.js.map +1 -1
- package/dist/resources/replayLogs.js +8 -1
- package/dist/resources/replayLogs.js.map +1 -1
- package/dist/resources/scheduler/engine.d.ts +4 -0
- package/dist/resources/scheduler/engine.js +30 -2
- package/dist/resources/scheduler/engine.js.map +1 -1
- package/dist/resources/search.d.ts +16 -4
- package/dist/resources/search.js +117 -13
- package/dist/resources/search.js.map +1 -1
- package/dist/resources/transaction.js +85 -5
- package/dist/resources/transaction.js.map +1 -1
- package/dist/resources/transactionBroadcast.d.ts +8 -0
- package/dist/resources/transactionBroadcast.js +65 -0
- package/dist/resources/transactionBroadcast.js.map +1 -1
- package/dist/security/keys.d.ts +1 -1
- package/dist/security/keys.js +170 -120
- package/dist/security/keys.js.map +1 -1
- package/dist/server/http.js +10 -0
- package/dist/server/http.js.map +1 -1
- package/dist/server/serverHelpers/Request.js +11 -2
- package/dist/server/serverHelpers/Request.js.map +1 -1
- package/dist/server/serverHelpers/contentTypes.js +8 -3
- package/dist/server/serverHelpers/contentTypes.js.map +1 -1
- package/dist/server/serverHelpers/uwsServer.js +38 -7
- package/dist/server/serverHelpers/uwsServer.js.map +1 -1
- package/dist/server/threads/manageThreads.d.ts +3 -3
- package/dist/server/threads/manageThreads.js +15 -13
- package/dist/server/threads/manageThreads.js.map +1 -1
- package/dist/server/threads/threadServer.d.ts +3 -0
- package/dist/server/threads/threadServer.js +24 -6
- package/dist/server/threads/threadServer.js.map +1 -1
- package/dist/upgrade/directives/5-3-0.js +9 -3
- package/dist/upgrade/directives/5-3-0.js.map +1 -1
- package/dist/utility/durableFile.d.ts +36 -0
- package/dist/utility/durableFile.js +106 -0
- package/dist/utility/durableFile.js.map +1 -0
- package/dist/utility/errors/hdbError.d.ts +4 -0
- package/dist/utility/errors/hdbError.js +10 -1
- package/dist/utility/errors/hdbError.js.map +1 -1
- package/dist/utility/globalSchema.d.ts +9 -0
- package/dist/utility/hdbTerms.d.ts +1 -0
- package/dist/utility/hdbTerms.js +1 -0
- package/dist/utility/hdbTerms.js.map +1 -1
- package/dist/validation/configValidator.js +29 -0
- package/dist/validation/configValidator.js.map +1 -1
- package/index.ts +7 -0
- package/json/systemSchema.json +14 -0
- package/npm-shrinkwrap.json +22 -22
- package/package.json +2 -2
- package/resources/DESIGN.md +57 -272
- package/resources/DatabaseTransaction.ts +708 -87
- package/resources/FULL_TEXT.md +6 -2
- package/resources/IterableEventQueue.ts +14 -7
- package/resources/LMDBTransaction.ts +212 -84
- package/resources/RecordEncoder.ts +2 -3
- package/resources/Resource.ts +5 -6
- package/resources/ResourceInterface.ts +3 -1
- package/resources/RocksIndexStore.ts +5 -0
- package/resources/Table.ts +118 -15
- package/resources/analytics/write.ts +6 -2
- package/resources/audit-retention.md +203 -0
- package/resources/auditStore.ts +306 -84
- package/resources/branchDatabase.ts +2 -0
- package/resources/databases.ts +6 -2
- package/resources/indexes/DESIGN.md +120 -177
- package/resources/indexes/fullTextNativeBinding.ts +22 -3
- package/resources/indexes/fullTextQueryIndex.ts +274 -11
- package/resources/models/DESIGN.md +11 -3
- package/resources/models/Models.ts +127 -4
- package/resources/models/backendRegistry.ts +17 -0
- package/resources/models/bootstrap.ts +9 -1
- package/resources/models/calibration.ts +298 -0
- package/resources/models/calibrationStore.ts +1167 -0
- package/resources/models/decisionStore.ts +57 -0
- package/resources/models/generativeDecision.ts +54 -16
- package/resources/models/types.ts +6 -0
- package/resources/record-locks.md +155 -141
- package/resources/recordLock.ts +12 -6
- package/resources/replayLogs.ts +9 -2
- package/resources/scheduler/DESIGN.md +4 -3
- package/resources/scheduler/engine.ts +35 -2
- package/resources/search.ts +154 -16
- package/resources/transaction.ts +50 -5
- package/resources/transactionBroadcast.ts +69 -0
- package/security/DESIGN.md +9 -30
- package/security/keys.ts +175 -127
- package/server/DESIGN.md +70 -36
- package/server/http.ts +10 -0
- package/server/serverHelpers/Request.ts +10 -2
- package/server/serverHelpers/contentTypes.ts +5 -3
- package/server/serverHelpers/uwsServer.ts +34 -7
- package/server/threads/manageThreads.js +16 -14
- package/server/threads/threadServer.js +24 -6
- package/studio/web/assets/{Chat-CNEKMllJ.js → Chat-4LMxI0yB.js} +1 -1
- package/studio/web/assets/{FloatingChat-CEnWhHeR.js → FloatingChat-kWHApEtF.js} +3 -3
- package/studio/web/assets/{SearchDialog-CwhJdEdY.js → SearchDialog-B7ymna97.js} +1 -1
- package/studio/web/assets/{apiToken-DC5vsaaO.js → apiToken-CE3AAt9f.js} +1 -1
- package/studio/web/assets/{applications-DQZxDy0x.js → applications-DdN7CqsB.js} +1 -1
- package/studio/web/assets/{cssMode-Dz2DxF4e.js → cssMode-BpySZUSt.js} +1 -1
- package/studio/web/assets/{editor-B3mjd3tz.js → editor-myLCVv9C.js} +1 -1
- package/studio/web/assets/{html-BsRGM7tp.js → html-DGNYO55O.js} +1 -1
- package/studio/web/assets/{htmlMode-tUKJaA3o.js → htmlMode-BUgpQr41.js} +1 -1
- package/studio/web/assets/{index-BuqFjN5K.js → index-FN4KC9va.js} +39 -39
- package/studio/web/assets/index-QLZw6xrU.css +1 -0
- package/studio/web/assets/{index.lazy-CcpfQFY_.js → index.lazy-DZFuaumG.js} +1 -1
- package/studio/web/assets/{javascript-BZ_uRGHl.js → javascript-DiBzh4pj.js} +1 -1
- package/studio/web/assets/{jsonMode-DgB-aFMj.js → jsonMode-Bm312nmu.js} +1 -1
- package/studio/web/assets/{languageServices-BleuBw17.js → languageServices-Dz2K9rZN.js} +1 -1
- package/studio/web/assets/{lspLanguageFeatures-ByVYLSsQ.js → lspLanguageFeatures-CNu1L5xn.js} +1 -1
- package/studio/web/assets/{notifications-TuzN0IMi.js → notifications--nwFLGmM.js} +1 -1
- package/studio/web/assets/{notifications-D1Nf-Edn.js → notifications-BelKw5bR.js} +1 -1
- package/studio/web/assets/{profile-D7C7Lupz.js → profile-AU5FjOBx.js} +1 -1
- package/studio/web/assets/{regions-nGQCm-v8.js → regions-C0sTSEHH.js} +1 -1
- package/studio/web/assets/{register-C3x9Ze6P.js → register-DyNR2rGk.js} +2 -2
- package/studio/web/assets/{setComponentFile-DpjrRqeG.js → setComponentFile-BkWx8AN3.js} +1 -1
- package/studio/web/assets/{setup-CGiKLZ9e.js → setup-D6LZS78d.js} +2 -2
- package/studio/web/assets/{status-CGJwrNPH.js → status-BCWnn70o.js} +1 -1
- package/studio/web/assets/{toggleHighContrast-C-Ne6P7T.js → toggleHighContrast-pyeGJ6W1.js} +1 -1
- package/studio/web/assets/{tsMode-CSreYTys.js → tsMode-CGV3iaWC.js} +1 -1
- package/studio/web/assets/{typescript-MnxBk89W.js → typescript-Ci7Khvwo.js} +1 -1
- package/studio/web/assets/{useEntityRestURL-TNrh77-s.js → useEntityRestURL-DNiz3aZ0.js} +1 -1
- package/studio/web/assets/{workers-BfZlUNVI.js → workers-byzxjbqy.js} +1 -1
- package/studio/web/assets/{xml-BHd6qGH7.js → xml-DGnHQ3CX.js} +1 -1
- package/studio/web/assets/{yaml-C53R8OQq.js → yaml-BUoTnPWb.js} +1 -1
- package/studio/web/index.html +2 -2
- package/upgrade/directives/5-3-0.ts +10 -3
- package/utility/durableFile.ts +96 -0
- package/utility/errors/hdbError.ts +9 -0
- package/utility/hdbTerms.ts +1 -0
- package/validation/configValidator.ts +31 -0
- 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();
|
package/bin/deployRenderer.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
548
|
-
* between the git-reference reclone path above and the plain identifier
|
|
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<
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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();
|
package/components/DESIGN.md
CHANGED
|
@@ -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.
|
|
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
|
|
307
|
-
|
|
308
|
-
|
|
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
|
-
`
|
|
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.
|
|
458
|
-
|
|
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"
|
|
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
|
-
|
|
486
|
-
|
|
487
|
-
`
|
|
488
|
-
|
|
489
|
-
|
|
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.
|
|
525
|
-
ordering rather than `fsync`, so a host power loss can lose
|
|
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
|
+
}
|
package/components/mcp/DESIGN.md
CHANGED
|
@@ -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
|
|
59
|
-
|
|
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.**
|