@prisma-next/migration-tools 0.4.0-dev.9 → 0.5.0-dev.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/README.md +1 -1
- package/dist/{attestation-DnebS4XZ.mjs → attestation-DtF8tEOM.mjs} +24 -23
- package/dist/attestation-DtF8tEOM.mjs.map +1 -0
- package/dist/{errors-C_XuSbX7.mjs → errors-BKbRGCJM.mjs} +9 -2
- package/dist/errors-BKbRGCJM.mjs.map +1 -0
- package/dist/exports/attestation.d.mts +20 -6
- package/dist/exports/attestation.d.mts.map +1 -1
- package/dist/exports/attestation.mjs +3 -3
- package/dist/exports/dag.d.mts +8 -6
- package/dist/exports/dag.d.mts.map +1 -1
- package/dist/exports/dag.mjs +181 -107
- package/dist/exports/dag.mjs.map +1 -1
- package/dist/exports/io.d.mts +16 -13
- package/dist/exports/io.d.mts.map +1 -1
- package/dist/exports/io.mjs +2 -2
- package/dist/exports/migration-ts.d.mts +15 -21
- package/dist/exports/migration-ts.d.mts.map +1 -1
- package/dist/exports/migration-ts.mjs +28 -36
- package/dist/exports/migration-ts.mjs.map +1 -1
- package/dist/exports/migration.d.mts +48 -18
- package/dist/exports/migration.d.mts.map +1 -1
- package/dist/exports/migration.mjs +75 -85
- package/dist/exports/migration.mjs.map +1 -1
- package/dist/exports/refs.mjs +1 -1
- package/dist/exports/types.d.mts +2 -2
- package/dist/exports/types.mjs +2 -16
- package/dist/{io-Cun81AIZ.mjs → io-CCnYsUHU.mjs} +18 -22
- package/dist/io-CCnYsUHU.mjs.map +1 -0
- package/dist/types-DyGXcWWp.d.mts +71 -0
- package/dist/types-DyGXcWWp.d.mts.map +1 -0
- package/package.json +5 -4
- package/src/attestation.ts +34 -26
- package/src/dag.ts +140 -154
- package/src/errors.ts +8 -0
- package/src/exports/attestation.ts +2 -1
- package/src/exports/io.ts +1 -1
- package/src/exports/migration-ts.ts +1 -1
- package/src/exports/migration.ts +8 -1
- package/src/exports/types.ts +2 -8
- package/src/graph-ops.ts +65 -0
- package/src/io.ts +23 -24
- package/src/migration-base.ts +99 -101
- package/src/migration-ts.ts +28 -50
- package/src/queue.ts +37 -0
- package/src/types.ts +15 -55
- package/dist/attestation-DnebS4XZ.mjs.map +0 -1
- package/dist/errors-C_XuSbX7.mjs.map +0 -1
- package/dist/exports/types.mjs.map +0 -1
- package/dist/io-Cun81AIZ.mjs.map +0 -1
- package/dist/types-D2uX4ql7.d.mts +0 -100
- package/dist/types-D2uX4ql7.d.mts.map +0 -1
package/README.md
CHANGED
|
@@ -68,7 +68,7 @@ graph TD
|
|
|
68
68
|
|---|---|
|
|
69
69
|
| `./types` | `MigrationManifest`, `MigrationOps`, `MigrationPackage`, `MigrationGraph`, `MigrationChainEntry`, `MigrationHints` |
|
|
70
70
|
| `./io` | `writeMigrationPackage`, `readMigrationPackage`, `readMigrationsDir`, `formatMigrationDirName` |
|
|
71
|
-
| `./attestation` | `computeMigrationId`, `
|
|
71
|
+
| `./attestation` | `computeMigrationId`, `verifyMigration`, `verifyMigrationBundle` |
|
|
72
72
|
| `./dag` | `reconstructGraph`, `findLeaf`, `findPath`, `detectCycles`, `detectOrphans` |
|
|
73
73
|
| `./constants` | `EMPTY_CONTRACT_HASH` |
|
|
74
74
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { r as readMigrationPackage } from "./io-CCnYsUHU.mjs";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
3
|
|
|
4
4
|
//#region src/canonicalize-json.ts
|
|
@@ -24,41 +24,42 @@ function sha256Hex(input) {
|
|
|
24
24
|
* for the rationale: contracts are anchored separately by the
|
|
25
25
|
* storage-hash bookends inside the envelope; planner hints are advisory
|
|
26
26
|
* and must not affect identity.
|
|
27
|
+
*
|
|
28
|
+
* The `migrationId` field on the manifest is stripped before hashing so
|
|
29
|
+
* the function can be used both at write time (when no id exists yet)
|
|
30
|
+
* and at verify time (rehashing an already-attested manifest).
|
|
27
31
|
*/
|
|
28
32
|
function computeMigrationId(manifest, ops) {
|
|
29
33
|
const { migrationId: _migrationId, signature: _signature, fromContract: _fromContract, toContract: _toContract, hints: _hints, ...strippedMeta } = manifest;
|
|
30
34
|
return `sha256:${sha256Hex(canonicalizeJson([canonicalizeJson(strippedMeta), canonicalizeJson(ops)].map(sha256Hex)))}`;
|
|
31
35
|
}
|
|
32
|
-
/**
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
const pkg = await readMigrationPackage(dir);
|
|
44
|
-
if (pkg.manifest.migrationId === null) return {
|
|
45
|
-
ok: false,
|
|
46
|
-
reason: "draft"
|
|
47
|
-
};
|
|
48
|
-
const computed = computeMigrationId(pkg.manifest, pkg.ops);
|
|
49
|
-
if (pkg.manifest.migrationId === computed) return {
|
|
36
|
+
/**
|
|
37
|
+
* Re-hash an on-disk migration bundle and compare against the stored
|
|
38
|
+
* `migrationId`. Returns `{ ok: true }` when the package is internally
|
|
39
|
+
* consistent (manifest + ops still produce the recorded id), or
|
|
40
|
+
* `{ ok: false, reason: 'mismatch', stored, computed }` when they do
|
|
41
|
+
* not — typically a sign of FS corruption, partial writes, or a
|
|
42
|
+
* post-emit hand edit.
|
|
43
|
+
*/
|
|
44
|
+
function verifyMigrationBundle(bundle) {
|
|
45
|
+
const computed = computeMigrationId(bundle.manifest, bundle.ops);
|
|
46
|
+
if (bundle.manifest.migrationId === computed) return {
|
|
50
47
|
ok: true,
|
|
51
|
-
storedMigrationId:
|
|
48
|
+
storedMigrationId: bundle.manifest.migrationId,
|
|
52
49
|
computedMigrationId: computed
|
|
53
50
|
};
|
|
54
51
|
return {
|
|
55
52
|
ok: false,
|
|
56
53
|
reason: "mismatch",
|
|
57
|
-
storedMigrationId:
|
|
54
|
+
storedMigrationId: bundle.manifest.migrationId,
|
|
58
55
|
computedMigrationId: computed
|
|
59
56
|
};
|
|
60
57
|
}
|
|
58
|
+
/** Convenience wrapper: read the package from disk then verify it. */
|
|
59
|
+
async function verifyMigration(dir) {
|
|
60
|
+
return verifyMigrationBundle(await readMigrationPackage(dir));
|
|
61
|
+
}
|
|
61
62
|
|
|
62
63
|
//#endregion
|
|
63
|
-
export {
|
|
64
|
-
//# sourceMappingURL=attestation-
|
|
64
|
+
export { verifyMigration as n, verifyMigrationBundle as r, computeMigrationId as t };
|
|
65
|
+
//# sourceMappingURL=attestation-DtF8tEOM.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"attestation-DtF8tEOM.mjs","names":["sorted: Record<string, unknown>"],"sources":["../src/canonicalize-json.ts","../src/attestation.ts"],"sourcesContent":["function sortKeys(value: unknown): unknown {\n if (value === null || typeof value !== 'object') {\n return value;\n }\n if (Array.isArray(value)) {\n return value.map(sortKeys);\n }\n const sorted: Record<string, unknown> = {};\n for (const key of Object.keys(value).sort()) {\n sorted[key] = sortKeys((value as Record<string, unknown>)[key]);\n }\n return sorted;\n}\n\nexport function canonicalizeJson(value: unknown): string {\n return JSON.stringify(sortKeys(value));\n}\n","import { createHash } from 'node:crypto';\nimport { canonicalizeJson } from './canonicalize-json';\nimport { readMigrationPackage } from './io';\nimport type { MigrationBundle, MigrationManifest, MigrationOps } from './types';\n\nexport interface VerifyResult {\n readonly ok: boolean;\n readonly reason?: 'mismatch';\n readonly storedMigrationId?: string;\n readonly computedMigrationId?: string;\n}\n\nfunction sha256Hex(input: string): string {\n return createHash('sha256').update(input).digest('hex');\n}\n\n/**\n * Content-addressed migration identity over (manifest envelope sans\n * contracts/hints, ops). See ADR 199 \"Storage-only migration identity\"\n * for the rationale: contracts are anchored separately by the\n * storage-hash bookends inside the envelope; planner hints are advisory\n * and must not affect identity.\n *\n * The `migrationId` field on the manifest is stripped before hashing so\n * the function can be used both at write time (when no id exists yet)\n * and at verify time (rehashing an already-attested manifest).\n */\nexport function computeMigrationId(\n manifest: Omit<MigrationManifest, 'migrationId'> & { readonly migrationId?: string },\n ops: MigrationOps,\n): string {\n const {\n migrationId: _migrationId,\n signature: _signature,\n fromContract: _fromContract,\n toContract: _toContract,\n hints: _hints,\n ...strippedMeta\n } = manifest;\n\n const canonicalManifest = canonicalizeJson(strippedMeta);\n const canonicalOps = canonicalizeJson(ops);\n\n const partHashes = [canonicalManifest, canonicalOps].map(sha256Hex);\n const hash = sha256Hex(canonicalizeJson(partHashes));\n\n return `sha256:${hash}`;\n}\n\n/**\n * Re-hash an on-disk migration bundle and compare against the stored\n * `migrationId`. Returns `{ ok: true }` when the package is internally\n * consistent (manifest + ops still produce the recorded id), or\n * `{ ok: false, reason: 'mismatch', stored, computed }` when they do\n * not — typically a sign of FS corruption, partial writes, or a\n * post-emit hand edit.\n */\nexport function verifyMigrationBundle(bundle: MigrationBundle): VerifyResult {\n const computed = computeMigrationId(bundle.manifest, bundle.ops);\n\n if (bundle.manifest.migrationId === computed) {\n return {\n ok: true,\n storedMigrationId: bundle.manifest.migrationId,\n computedMigrationId: computed,\n };\n }\n\n return {\n ok: false,\n reason: 'mismatch',\n storedMigrationId: bundle.manifest.migrationId,\n computedMigrationId: computed,\n };\n}\n\n/** Convenience wrapper: read the package from disk then verify it. */\nexport async function verifyMigration(dir: string): Promise<VerifyResult> {\n const pkg = await readMigrationPackage(dir);\n return verifyMigrationBundle(pkg);\n}\n"],"mappings":";;;;AAAA,SAAS,SAAS,OAAyB;AACzC,KAAI,UAAU,QAAQ,OAAO,UAAU,SACrC,QAAO;AAET,KAAI,MAAM,QAAQ,MAAM,CACtB,QAAO,MAAM,IAAI,SAAS;CAE5B,MAAMA,SAAkC,EAAE;AAC1C,MAAK,MAAM,OAAO,OAAO,KAAK,MAAM,CAAC,MAAM,CACzC,QAAO,OAAO,SAAU,MAAkC,KAAK;AAEjE,QAAO;;AAGT,SAAgB,iBAAiB,OAAwB;AACvD,QAAO,KAAK,UAAU,SAAS,MAAM,CAAC;;;;;ACHxC,SAAS,UAAU,OAAuB;AACxC,QAAO,WAAW,SAAS,CAAC,OAAO,MAAM,CAAC,OAAO,MAAM;;;;;;;;;;;;;AAczD,SAAgB,mBACd,UACA,KACQ;CACR,MAAM,EACJ,aAAa,cACb,WAAW,YACX,cAAc,eACd,YAAY,aACZ,OAAO,QACP,GAAG,iBACD;AAQJ,QAAO,UAFM,UAAU,iBADJ,CAHO,iBAAiB,aAAa,EACnC,iBAAiB,IAAI,CAEU,CAAC,IAAI,UAAU,CAChB,CAAC;;;;;;;;;;AAatD,SAAgB,sBAAsB,QAAuC;CAC3E,MAAM,WAAW,mBAAmB,OAAO,UAAU,OAAO,IAAI;AAEhE,KAAI,OAAO,SAAS,gBAAgB,SAClC,QAAO;EACL,IAAI;EACJ,mBAAmB,OAAO,SAAS;EACnC,qBAAqB;EACtB;AAGH,QAAO;EACL,IAAI;EACJ,QAAQ;EACR,mBAAmB,OAAO,SAAS;EACnC,qBAAqB;EACtB;;;AAIH,eAAsB,gBAAgB,KAAoC;AAExE,QAAO,sBADK,MAAM,qBAAqB,IAAI,CACV"}
|
|
@@ -78,6 +78,13 @@ function errorInvalidSlug(slug) {
|
|
|
78
78
|
details: { slug }
|
|
79
79
|
});
|
|
80
80
|
}
|
|
81
|
+
function errorInvalidDestName(destName) {
|
|
82
|
+
return new MigrationToolsError("MIGRATION.INVALID_DEST_NAME", "Invalid copy destination name", {
|
|
83
|
+
why: `The destination name "${destName}" must be a single path segment (no ".." or directory separators).`,
|
|
84
|
+
fix: "Use a simple file name such as \"contract.json\" for each destination in the copy list.",
|
|
85
|
+
details: { destName }
|
|
86
|
+
});
|
|
87
|
+
}
|
|
81
88
|
function errorSameSourceAndTarget(dirName, hash) {
|
|
82
89
|
return new MigrationToolsError("MIGRATION.SAME_SOURCE_AND_TARGET", "Migration has same source and target", {
|
|
83
90
|
why: `Migration "${dirName}" has from === to === "${hash}". A migration must transition between two different contract states.`,
|
|
@@ -149,5 +156,5 @@ function errorDuplicateMigrationId(migrationId) {
|
|
|
149
156
|
}
|
|
150
157
|
|
|
151
158
|
//#endregion
|
|
152
|
-
export {
|
|
153
|
-
//# sourceMappingURL=errors-
|
|
159
|
+
export { errorInvalidDestName as a, errorInvalidRefName as c, errorInvalidSlug as d, errorMissingFile as f, errorSameSourceAndTarget as h, errorDuplicateMigrationId as i, errorInvalidRefValue as l, errorNoTarget as m, errorAmbiguousTarget as n, errorInvalidJson as o, errorNoInitialMigration as p, errorDirectoryExists as r, errorInvalidManifest as s, MigrationToolsError as t, errorInvalidRefs as u };
|
|
160
|
+
//# sourceMappingURL=errors-BKbRGCJM.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"errors-BKbRGCJM.mjs","names":[],"sources":["../src/errors.ts"],"sourcesContent":["/**\n * Structured error for migration tooling operations.\n *\n * Follows the NAMESPACE.SUBCODE convention from ADR 027. All codes live under\n * the MIGRATION namespace. These are tooling-time errors (file I/O, attestation,\n * migration history reconstruction), distinct from the runtime MIGRATION.* codes for apply-time\n * failures (PRECHECK_FAILED, POSTCHECK_FAILED, etc.).\n *\n * Fields:\n * - code: Stable machine-readable code (MIGRATION.SUBCODE)\n * - category: Always 'MIGRATION'\n * - why: Explains the cause in plain language\n * - fix: Actionable remediation step\n * - details: Machine-readable structured data for agents\n */\nexport class MigrationToolsError extends Error {\n readonly code: string;\n readonly category = 'MIGRATION' as const;\n readonly why: string;\n readonly fix: string;\n readonly details: Record<string, unknown> | undefined;\n\n constructor(\n code: string,\n summary: string,\n options: {\n readonly why: string;\n readonly fix: string;\n readonly details?: Record<string, unknown>;\n },\n ) {\n super(summary);\n this.name = 'MigrationToolsError';\n this.code = code;\n this.why = options.why;\n this.fix = options.fix;\n this.details = options.details;\n }\n\n static is(error: unknown): error is MigrationToolsError {\n if (!(error instanceof Error)) return false;\n const candidate = error as MigrationToolsError;\n return candidate.name === 'MigrationToolsError' && typeof candidate.code === 'string';\n }\n}\n\nexport function errorDirectoryExists(dir: string): MigrationToolsError {\n return new MigrationToolsError('MIGRATION.DIR_EXISTS', 'Migration directory already exists', {\n why: `The directory \"${dir}\" already exists. Each migration must have a unique directory.`,\n fix: 'Use --name to pick a different name, or delete the existing directory and re-run.',\n details: { dir },\n });\n}\n\nexport function errorMissingFile(file: string, dir: string): MigrationToolsError {\n return new MigrationToolsError('MIGRATION.FILE_MISSING', `Missing ${file}`, {\n why: `Expected \"${file}\" in \"${dir}\" but the file does not exist.`,\n fix: 'Ensure the migration directory contains both migration.json and ops.json. If the directory is corrupt, delete it and re-run migration plan.',\n details: { file, dir },\n });\n}\n\nexport function errorInvalidJson(filePath: string, parseError: string): MigrationToolsError {\n return new MigrationToolsError('MIGRATION.INVALID_JSON', 'Invalid JSON in migration file', {\n why: `Failed to parse \"${filePath}\": ${parseError}`,\n fix: 'Fix the JSON syntax error, or delete the migration directory and re-run migration plan.',\n details: { filePath, parseError },\n });\n}\n\nexport function errorInvalidManifest(filePath: string, reason: string): MigrationToolsError {\n return new MigrationToolsError('MIGRATION.INVALID_MANIFEST', 'Invalid migration manifest', {\n why: `Manifest at \"${filePath}\" is invalid: ${reason}`,\n fix: 'Ensure the manifest has all required fields (from, to, kind, toContract). If corrupt, delete and re-plan.',\n details: { filePath, reason },\n });\n}\n\nexport function errorInvalidSlug(slug: string): MigrationToolsError {\n return new MigrationToolsError('MIGRATION.INVALID_NAME', 'Invalid migration name', {\n why: `The slug \"${slug}\" contains no valid characters after sanitization (only a-z, 0-9 are kept).`,\n fix: 'Provide a name with at least one alphanumeric character, e.g. --name add_users.',\n details: { slug },\n });\n}\n\nexport function errorInvalidDestName(destName: string): MigrationToolsError {\n return new MigrationToolsError('MIGRATION.INVALID_DEST_NAME', 'Invalid copy destination name', {\n why: `The destination name \"${destName}\" must be a single path segment (no \"..\" or directory separators).`,\n fix: 'Use a simple file name such as \"contract.json\" for each destination in the copy list.',\n details: { destName },\n });\n}\n\nexport function errorSameSourceAndTarget(dirName: string, hash: string): MigrationToolsError {\n return new MigrationToolsError(\n 'MIGRATION.SAME_SOURCE_AND_TARGET',\n 'Migration has same source and target',\n {\n why: `Migration \"${dirName}\" has from === to === \"${hash}\". A migration must transition between two different contract states.`,\n fix: 'Delete the invalid migration directory and re-run migration plan.',\n details: { dirName, hash },\n },\n );\n}\n\nexport function errorAmbiguousTarget(\n branchTips: readonly string[],\n context?: {\n divergencePoint: string;\n branches: readonly {\n tip: string;\n edges: readonly { dirName: string; from: string; to: string }[];\n }[];\n },\n): MigrationToolsError {\n const divergenceInfo = context\n ? `\\nDivergence point: ${context.divergencePoint}\\nBranches:\\n${context.branches.map((b) => ` → ${b.tip} (${b.edges.length} edge(s): ${b.edges.map((e) => e.dirName).join(' → ') || 'direct'})`).join('\\n')}`\n : '';\n return new MigrationToolsError('MIGRATION.AMBIGUOUS_TARGET', 'Ambiguous migration target', {\n why: `The migration history has diverged into multiple branches: ${branchTips.join(', ')}. This typically happens when two developers plan migrations from the same starting point.${divergenceInfo}`,\n fix: 'Use `migration ref set <name> <hash>` to target a specific branch, delete one of the conflicting migration directories and re-run `migration plan`, or use --from <hash> to explicitly select a starting point.',\n details: {\n branchTips,\n ...(context ? { divergencePoint: context.divergencePoint, branches: context.branches } : {}),\n },\n });\n}\n\nexport function errorNoInitialMigration(nodes: readonly string[]): MigrationToolsError {\n return new MigrationToolsError('MIGRATION.NO_INITIAL_MIGRATION', 'No initial migration found', {\n why: `No migration starts from the empty contract state (known hashes: ${nodes.join(', ')}). At least one migration must originate from the empty state.`,\n fix: 'Inspect the migrations directory for corrupted migration.json files. At least one migration must start from the empty contract hash.',\n details: { nodes },\n });\n}\n\nexport function errorInvalidRefs(refsPath: string, reason: string): MigrationToolsError {\n return new MigrationToolsError('MIGRATION.INVALID_REFS', 'Invalid refs.json', {\n why: `refs.json at \"${refsPath}\" is invalid: ${reason}`,\n fix: 'Ensure refs.json is a flat object mapping valid ref names to contract hash strings.',\n details: { path: refsPath, reason },\n });\n}\n\nexport function errorInvalidRefFile(filePath: string, reason: string): MigrationToolsError {\n return new MigrationToolsError('MIGRATION.INVALID_REF_FILE', 'Invalid ref file', {\n why: `Ref file at \"${filePath}\" is invalid: ${reason}`,\n fix: 'Ensure the ref file contains valid JSON with { \"hash\": \"sha256:<64 hex chars>\", \"invariants\": [\"...\"] }.',\n details: { path: filePath, reason },\n });\n}\n\nexport function errorInvalidRefName(refName: string): MigrationToolsError {\n return new MigrationToolsError('MIGRATION.INVALID_REF_NAME', 'Invalid ref name', {\n why: `Ref name \"${refName}\" is invalid. Names must be lowercase alphanumeric with hyphens or forward slashes (no \".\" or \"..\" segments).`,\n fix: `Use a valid ref name (e.g., \"staging\", \"envs/production\").`,\n details: { refName },\n });\n}\n\nexport function errorNoTarget(reachableHashes: readonly string[]): MigrationToolsError {\n return new MigrationToolsError('MIGRATION.NO_TARGET', 'No migration target could be resolved', {\n why: `The migration history contains cycles and no target can be resolved automatically (reachable hashes: ${reachableHashes.join(', ')}). This typically happens after rollback migrations (e.g., C1→C2→C1).`,\n fix: 'Use --from <hash> to specify the planning origin explicitly.',\n details: { reachableHashes },\n });\n}\n\nexport function errorInvalidRefValue(value: string): MigrationToolsError {\n return new MigrationToolsError('MIGRATION.INVALID_REF_VALUE', 'Invalid ref value', {\n why: `Ref value \"${value}\" is not a valid contract hash. Values must be in the format \"sha256:<64 hex chars>\" or \"sha256:empty\".`,\n fix: 'Use a valid storage hash from `prisma-next contract emit` output or an existing migration.',\n details: { value },\n });\n}\n\nexport function errorDuplicateMigrationId(migrationId: string): MigrationToolsError {\n return new MigrationToolsError(\n 'MIGRATION.DUPLICATE_MIGRATION_ID',\n 'Duplicate migrationId in migration graph',\n {\n why: `Multiple migrations share migrationId \"${migrationId}\". Each migration must have a unique content-addressed identity.`,\n fix: 'Regenerate one of the conflicting migrations so each migrationId is unique, then re-run migration commands.',\n details: { migrationId },\n },\n );\n}\n"],"mappings":"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"}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { a as MigrationManifest, o as MigrationOps, t as MigrationBundle } from "../types-DyGXcWWp.mjs";
|
|
2
2
|
|
|
3
3
|
//#region src/attestation.d.ts
|
|
4
4
|
interface VerifyResult {
|
|
5
5
|
readonly ok: boolean;
|
|
6
|
-
readonly reason?: '
|
|
6
|
+
readonly reason?: 'mismatch';
|
|
7
7
|
readonly storedMigrationId?: string;
|
|
8
8
|
readonly computedMigrationId?: string;
|
|
9
9
|
}
|
|
@@ -13,11 +13,25 @@ interface VerifyResult {
|
|
|
13
13
|
* for the rationale: contracts are anchored separately by the
|
|
14
14
|
* storage-hash bookends inside the envelope; planner hints are advisory
|
|
15
15
|
* and must not affect identity.
|
|
16
|
+
*
|
|
17
|
+
* The `migrationId` field on the manifest is stripped before hashing so
|
|
18
|
+
* the function can be used both at write time (when no id exists yet)
|
|
19
|
+
* and at verify time (rehashing an already-attested manifest).
|
|
16
20
|
*/
|
|
17
|
-
declare function computeMigrationId(manifest: MigrationManifest,
|
|
18
|
-
|
|
19
|
-
|
|
21
|
+
declare function computeMigrationId(manifest: Omit<MigrationManifest, 'migrationId'> & {
|
|
22
|
+
readonly migrationId?: string;
|
|
23
|
+
}, ops: MigrationOps): string;
|
|
24
|
+
/**
|
|
25
|
+
* Re-hash an on-disk migration bundle and compare against the stored
|
|
26
|
+
* `migrationId`. Returns `{ ok: true }` when the package is internally
|
|
27
|
+
* consistent (manifest + ops still produce the recorded id), or
|
|
28
|
+
* `{ ok: false, reason: 'mismatch', stored, computed }` when they do
|
|
29
|
+
* not — typically a sign of FS corruption, partial writes, or a
|
|
30
|
+
* post-emit hand edit.
|
|
31
|
+
*/
|
|
32
|
+
declare function verifyMigrationBundle(bundle: MigrationBundle): VerifyResult;
|
|
33
|
+
/** Convenience wrapper: read the package from disk then verify it. */
|
|
20
34
|
declare function verifyMigration(dir: string): Promise<VerifyResult>;
|
|
21
35
|
//#endregion
|
|
22
|
-
export {
|
|
36
|
+
export { type VerifyResult, computeMigrationId, verifyMigration, verifyMigrationBundle };
|
|
23
37
|
//# sourceMappingURL=attestation.d.mts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"attestation.d.mts","names":[],"sources":["../../src/attestation.ts"],"sourcesContent":[],"mappings":";;;UAKiB,YAAA;;EAAA,SAAA,MAAY,CAAA,EAAA,
|
|
1
|
+
{"version":3,"file":"attestation.d.mts","names":[],"sources":["../../src/attestation.ts"],"sourcesContent":[],"mappings":";;;UAKiB,YAAA;;EAAA,SAAA,MAAY,CAAA,EAAA,UAAA;EAsBb,SAAA,iBAAkB,CAAA,EAAA,MAAA;EACjB,SAAA,mBAAA,CAAA,EAAA,MAAA;;;;AA6BjB;AAoBA;;;;;;;;iBAlDgB,kBAAA,WACJ,KAAK;;QACV;;;;;;;;;iBA4BS,qBAAA,SAA8B,kBAAkB;;iBAoB1C,eAAA,eAA8B,QAAQ"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import "../io-
|
|
2
|
-
import { n as
|
|
1
|
+
import "../io-CCnYsUHU.mjs";
|
|
2
|
+
import { n as verifyMigration, r as verifyMigrationBundle, t as computeMigrationId } from "../attestation-DtF8tEOM.mjs";
|
|
3
3
|
|
|
4
|
-
export {
|
|
4
|
+
export { computeMigrationId, verifyMigration, verifyMigrationBundle };
|
package/dist/exports/dag.d.mts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { n as MigrationChainEntry, r as MigrationGraph, t as MigrationBundle } from "../types-DyGXcWWp.mjs";
|
|
2
2
|
|
|
3
3
|
//#region src/dag.d.ts
|
|
4
|
-
declare function reconstructGraph(packages: readonly
|
|
4
|
+
declare function reconstructGraph(packages: readonly MigrationBundle[]): MigrationGraph;
|
|
5
5
|
/**
|
|
6
6
|
* Find the shortest path from `fromHash` to `toHash` using BFS over the
|
|
7
7
|
* contract-hash graph. Returns the ordered list of edges, or null if no path
|
|
@@ -31,11 +31,13 @@ declare function findPathWithDecision(graph: MigrationGraph, fromHash: string, t
|
|
|
31
31
|
declare function findReachableLeaves(graph: MigrationGraph, fromHash: string): readonly string[];
|
|
32
32
|
/**
|
|
33
33
|
* Find the target contract hash of the migration graph reachable from
|
|
34
|
-
* EMPTY_CONTRACT_HASH.
|
|
35
|
-
*
|
|
36
|
-
* Throws
|
|
34
|
+
* EMPTY_CONTRACT_HASH. Returns `null` for a graph that has no target
|
|
35
|
+
* state (either empty, or containing only the root with no outgoing
|
|
36
|
+
* edges). Throws NO_INITIAL_MIGRATION if the graph has nodes but none
|
|
37
|
+
* originate from the empty hash, and AMBIGUOUS_TARGET if multiple
|
|
38
|
+
* branch tips exist.
|
|
37
39
|
*/
|
|
38
|
-
declare function findLeaf(graph: MigrationGraph): string;
|
|
40
|
+
declare function findLeaf(graph: MigrationGraph): string | null;
|
|
39
41
|
/**
|
|
40
42
|
* Find the latest migration entry by traversing from EMPTY_CONTRACT_HASH
|
|
41
43
|
* to the single target. Returns null for an empty graph.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dag.d.mts","names":[],"sources":["../../src/dag.ts"],"sourcesContent":[],"mappings":";;;
|
|
1
|
+
{"version":3,"file":"dag.d.mts","names":[],"sources":["../../src/dag.ts"],"sourcesContent":[],"mappings":";;;iBAgCgB,gBAAA,oBAAoC,oBAAoB;;AAAxE;AAmFA;AAyCA;AAaA;AA8FA;AAkBA;AAwCA;AAQgB,iBAtNA,QAAA,CAsNoB,KAAA,EArN3B,cAqNyC,EAAA,QAAA,EAAA,MAAA,EAAA,MAAA,EAAA,MAAA,CAAA,EAAA,SAlNtC,mBAkNsC,EAAA,GAAA,IAAA;AA8DlC,UA3OC,YAAA,CA2OY;kCA1OK;;;;;;;;;;;iBAYlB,oBAAA,QACP,qEAIN;;;;;iBAyFa,mBAAA,QAA2B;;;;;;;;;iBAkB3B,QAAA,QAAgB;;;;;;iBAwChB,mBAAA,QAA2B,iBAAiB;iBAQ5C,YAAA,QAAoB;iBA8DpB,aAAA,QAAqB,0BAA0B"}
|
package/dist/exports/dag.mjs
CHANGED
|
@@ -1,8 +1,107 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { h as errorSameSourceAndTarget, i as errorDuplicateMigrationId, m as errorNoTarget, n as errorAmbiguousTarget, p as errorNoInitialMigration } from "../errors-BKbRGCJM.mjs";
|
|
2
2
|
import { t as EMPTY_CONTRACT_HASH } from "../constants-BRi0X7B_.mjs";
|
|
3
3
|
import { ifDefined } from "@prisma-next/utils/defined";
|
|
4
4
|
|
|
5
|
+
//#region src/queue.ts
|
|
6
|
+
/**
|
|
7
|
+
* FIFO queue with amortised O(1) push and shift.
|
|
8
|
+
*
|
|
9
|
+
* Uses a head-index cursor over a backing array rather than
|
|
10
|
+
* `Array.prototype.shift()`, which is O(n) on V8. Intended for BFS-shaped
|
|
11
|
+
* traversals where the queue is drained in a single pass — it does not
|
|
12
|
+
* reclaim memory for already-shifted items, so it is not suitable for
|
|
13
|
+
* long-lived queues with many push/shift cycles.
|
|
14
|
+
*/
|
|
15
|
+
var Queue = class {
|
|
16
|
+
items;
|
|
17
|
+
head = 0;
|
|
18
|
+
constructor(initial = []) {
|
|
19
|
+
this.items = [...initial];
|
|
20
|
+
}
|
|
21
|
+
push(item) {
|
|
22
|
+
this.items.push(item);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Remove and return the next item. Caller must check `isEmpty` first —
|
|
26
|
+
* shifting an empty queue throws.
|
|
27
|
+
*/
|
|
28
|
+
shift() {
|
|
29
|
+
if (this.head >= this.items.length) throw new Error("Queue.shift called on empty queue");
|
|
30
|
+
return this.items[this.head++];
|
|
31
|
+
}
|
|
32
|
+
get isEmpty() {
|
|
33
|
+
return this.head >= this.items.length;
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
//#endregion
|
|
38
|
+
//#region src/graph-ops.ts
|
|
39
|
+
/**
|
|
40
|
+
* Generic breadth-first traversal.
|
|
41
|
+
*
|
|
42
|
+
* Direction (forward/reverse) is expressed by the caller's `neighbours`
|
|
43
|
+
* closure: return `{ next, edge }` pairs where `next` is the node to visit
|
|
44
|
+
* next and `edge` is the edge that connects them. Callers that don't need
|
|
45
|
+
* path reconstruction can ignore the `parent`/`incomingEdge` fields of each
|
|
46
|
+
* yielded step.
|
|
47
|
+
*
|
|
48
|
+
* Stops are intrinsic — callers `break` out of the `for..of` loop when
|
|
49
|
+
* they've found what they're looking for.
|
|
50
|
+
*
|
|
51
|
+
* `ordering`, if provided, controls the order in which neighbours of each
|
|
52
|
+
* node are enqueued. Only matters for path-finding: a deterministic ordering
|
|
53
|
+
* makes BFS return a deterministic shortest path when multiple exist.
|
|
54
|
+
*/
|
|
55
|
+
function* bfs(starts, neighbours, ordering) {
|
|
56
|
+
const visited = /* @__PURE__ */ new Set();
|
|
57
|
+
const parentMap = /* @__PURE__ */ new Map();
|
|
58
|
+
const queue = new Queue();
|
|
59
|
+
for (const start of starts) if (!visited.has(start)) {
|
|
60
|
+
visited.add(start);
|
|
61
|
+
queue.push(start);
|
|
62
|
+
}
|
|
63
|
+
while (!queue.isEmpty) {
|
|
64
|
+
const current = queue.shift();
|
|
65
|
+
const parentInfo = parentMap.get(current);
|
|
66
|
+
yield {
|
|
67
|
+
node: current,
|
|
68
|
+
parent: parentInfo?.parent ?? null,
|
|
69
|
+
incomingEdge: parentInfo?.edge ?? null
|
|
70
|
+
};
|
|
71
|
+
const items = neighbours(current);
|
|
72
|
+
const toVisit = ordering ? ordering([...items]) : items;
|
|
73
|
+
for (const { next, edge } of toVisit) if (!visited.has(next)) {
|
|
74
|
+
visited.add(next);
|
|
75
|
+
parentMap.set(next, {
|
|
76
|
+
parent: current,
|
|
77
|
+
edge
|
|
78
|
+
});
|
|
79
|
+
queue.push(next);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
//#endregion
|
|
5
85
|
//#region src/dag.ts
|
|
86
|
+
/** Forward-edge neighbours for BFS: edge `e` from `n` visits `e.to` next. */
|
|
87
|
+
function forwardNeighbours(graph, node) {
|
|
88
|
+
return (graph.forwardChain.get(node) ?? []).map((edge) => ({
|
|
89
|
+
next: edge.to,
|
|
90
|
+
edge
|
|
91
|
+
}));
|
|
92
|
+
}
|
|
93
|
+
/** Reverse-edge neighbours for BFS: edge `e` from `n` visits `e.from` next. */
|
|
94
|
+
function reverseNeighbours(graph, node) {
|
|
95
|
+
return (graph.reverseChain.get(node) ?? []).map((edge) => ({
|
|
96
|
+
next: edge.from,
|
|
97
|
+
edge
|
|
98
|
+
}));
|
|
99
|
+
}
|
|
100
|
+
function appendEdge(map, key, entry) {
|
|
101
|
+
const bucket = map.get(key);
|
|
102
|
+
if (bucket) bucket.push(entry);
|
|
103
|
+
else map.set(key, [entry]);
|
|
104
|
+
}
|
|
6
105
|
function reconstructGraph(packages) {
|
|
7
106
|
const nodes = /* @__PURE__ */ new Set();
|
|
8
107
|
const forwardChain = /* @__PURE__ */ new Map();
|
|
@@ -21,16 +120,10 @@ function reconstructGraph(packages) {
|
|
|
21
120
|
createdAt: pkg.manifest.createdAt,
|
|
22
121
|
labels: pkg.manifest.labels
|
|
23
122
|
};
|
|
24
|
-
if (migration.migrationId
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
const fwd = forwardChain.get(from);
|
|
29
|
-
if (fwd) fwd.push(migration);
|
|
30
|
-
else forwardChain.set(from, [migration]);
|
|
31
|
-
const rev = reverseChain.get(to);
|
|
32
|
-
if (rev) rev.push(migration);
|
|
33
|
-
else reverseChain.set(to, [migration]);
|
|
123
|
+
if (migrationById.has(migration.migrationId)) throw errorDuplicateMigrationId(migration.migrationId);
|
|
124
|
+
migrationById.set(migration.migrationId, migration);
|
|
125
|
+
appendEdge(forwardChain, from, migration);
|
|
126
|
+
appendEdge(reverseChain, to, migration);
|
|
34
127
|
}
|
|
35
128
|
return {
|
|
36
129
|
nodes,
|
|
@@ -52,16 +145,21 @@ function labelPriority(labels) {
|
|
|
52
145
|
}
|
|
53
146
|
return best;
|
|
54
147
|
}
|
|
148
|
+
function compareTieBreak(a, b) {
|
|
149
|
+
const lp = labelPriority(a.labels) - labelPriority(b.labels);
|
|
150
|
+
if (lp !== 0) return lp;
|
|
151
|
+
const ca = a.createdAt.localeCompare(b.createdAt);
|
|
152
|
+
if (ca !== 0) return ca;
|
|
153
|
+
const tc = a.to.localeCompare(b.to);
|
|
154
|
+
if (tc !== 0) return tc;
|
|
155
|
+
return a.migrationId.localeCompare(b.migrationId);
|
|
156
|
+
}
|
|
55
157
|
function sortedNeighbors(edges) {
|
|
56
|
-
return [...edges].sort(
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
const tc = a.to.localeCompare(b.to);
|
|
62
|
-
if (tc !== 0) return tc;
|
|
63
|
-
return (a.migrationId ?? "").localeCompare(b.migrationId ?? "");
|
|
64
|
-
});
|
|
158
|
+
return [...edges].sort(compareTieBreak);
|
|
159
|
+
}
|
|
160
|
+
/** Ordering adapter for `bfs` — sorts `{next, edge}` pairs by tie-break. */
|
|
161
|
+
function bfsOrdering(items) {
|
|
162
|
+
return items.slice().sort((a, b) => compareTieBreak(a.edge, b.edge));
|
|
65
163
|
}
|
|
66
164
|
/**
|
|
67
165
|
* Find the shortest path from `fromHash` to `toHash` using BFS over the
|
|
@@ -73,40 +171,37 @@ function sortedNeighbors(edges) {
|
|
|
73
171
|
*/
|
|
74
172
|
function findPath(graph, fromHash, toHash) {
|
|
75
173
|
if (fromHash === toHash) return [];
|
|
76
|
-
const
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
if (
|
|
83
|
-
if (current === toHash) {
|
|
174
|
+
const parents = /* @__PURE__ */ new Map();
|
|
175
|
+
for (const step of bfs([fromHash], (n) => forwardNeighbours(graph, n), bfsOrdering)) {
|
|
176
|
+
if (step.parent !== null && step.incomingEdge !== null) parents.set(step.node, {
|
|
177
|
+
parent: step.parent,
|
|
178
|
+
edge: step.incomingEdge
|
|
179
|
+
});
|
|
180
|
+
if (step.node === toHash) {
|
|
84
181
|
const path = [];
|
|
85
|
-
let
|
|
86
|
-
let
|
|
87
|
-
while (
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
entry = parent.get(node);
|
|
182
|
+
let cur = toHash;
|
|
183
|
+
let p = parents.get(cur);
|
|
184
|
+
while (p) {
|
|
185
|
+
path.push(p.edge);
|
|
186
|
+
cur = p.parent;
|
|
187
|
+
p = parents.get(cur);
|
|
92
188
|
}
|
|
93
189
|
path.reverse();
|
|
94
190
|
return path;
|
|
95
191
|
}
|
|
96
|
-
const outgoing = graph.forwardChain.get(current);
|
|
97
|
-
if (!outgoing) continue;
|
|
98
|
-
for (const edge of sortedNeighbors(outgoing)) if (!visited.has(edge.to)) {
|
|
99
|
-
visited.add(edge.to);
|
|
100
|
-
parent.set(edge.to, {
|
|
101
|
-
node: current,
|
|
102
|
-
edge
|
|
103
|
-
});
|
|
104
|
-
queue.push(edge.to);
|
|
105
|
-
}
|
|
106
192
|
}
|
|
107
193
|
return null;
|
|
108
194
|
}
|
|
109
195
|
/**
|
|
196
|
+
* Reverse-BFS from `toHash` over `reverseChain` to collect every node from
|
|
197
|
+
* which `toHash` is reachable (inclusive of `toHash` itself).
|
|
198
|
+
*/
|
|
199
|
+
function collectNodesReachingTarget(graph, toHash) {
|
|
200
|
+
const reached = /* @__PURE__ */ new Set();
|
|
201
|
+
for (const step of bfs([toHash], (n) => reverseNeighbours(graph, n))) reached.add(step.node);
|
|
202
|
+
return reached;
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
110
205
|
* Find the shortest path from `fromHash` to `toHash` and return structured
|
|
111
206
|
* path-decision metadata for machine-readable output.
|
|
112
207
|
*/
|
|
@@ -121,14 +216,13 @@ function findPathWithDecision(graph, fromHash, toHash, refName) {
|
|
|
121
216
|
};
|
|
122
217
|
const path = findPath(graph, fromHash, toHash);
|
|
123
218
|
if (!path) return null;
|
|
219
|
+
const reachesTarget = collectNodesReachingTarget(graph, toHash);
|
|
124
220
|
const tieBreakReasons = [];
|
|
125
221
|
let alternativeCount = 0;
|
|
126
222
|
for (const edge of path) {
|
|
127
223
|
const outgoing = graph.forwardChain.get(edge.from);
|
|
128
224
|
if (outgoing && outgoing.length > 1) {
|
|
129
|
-
const reachable = outgoing.filter((e) =>
|
|
130
|
-
return findPath(graph, e.to, toHash) !== null || e.to === toHash;
|
|
131
|
-
});
|
|
225
|
+
const reachable = outgoing.filter((e) => reachesTarget.has(e.to));
|
|
132
226
|
if (reachable.length > 1) {
|
|
133
227
|
alternativeCount += reachable.length - 1;
|
|
134
228
|
const sorted = sortedNeighbors(reachable);
|
|
@@ -154,14 +248,7 @@ function findPathWithDecision(graph, fromHash, toHash, refName) {
|
|
|
154
248
|
function findDivergencePoint(graph, fromHash, leaves) {
|
|
155
249
|
const ancestorSets = leaves.map((leaf) => {
|
|
156
250
|
const ancestors = /* @__PURE__ */ new Set();
|
|
157
|
-
const
|
|
158
|
-
while (queue.length > 0) {
|
|
159
|
-
const current = queue.shift();
|
|
160
|
-
if (ancestors.has(current)) continue;
|
|
161
|
-
ancestors.add(current);
|
|
162
|
-
const incoming = graph.reverseChain.get(current);
|
|
163
|
-
if (incoming) for (const edge of incoming) queue.push(edge.from);
|
|
164
|
-
}
|
|
251
|
+
for (const step of bfs([leaf], (n) => reverseNeighbours(graph, n))) ancestors.add(step.node);
|
|
165
252
|
return ancestors;
|
|
166
253
|
});
|
|
167
254
|
const commonAncestors = [...ancestorSets[0] ?? []].filter((node) => ancestorSets.every((s) => s.has(node)));
|
|
@@ -182,36 +269,26 @@ function findDivergencePoint(graph, fromHash, leaves) {
|
|
|
182
269
|
* `fromHash` via forward edges.
|
|
183
270
|
*/
|
|
184
271
|
function findReachableLeaves(graph, fromHash) {
|
|
185
|
-
const visited = /* @__PURE__ */ new Set();
|
|
186
|
-
const queue = [fromHash];
|
|
187
|
-
visited.add(fromHash);
|
|
188
272
|
const leaves = [];
|
|
189
|
-
|
|
190
|
-
const current = queue.shift();
|
|
191
|
-
if (current === void 0) break;
|
|
192
|
-
const outgoing = graph.forwardChain.get(current);
|
|
193
|
-
if (!outgoing || outgoing.length === 0) leaves.push(current);
|
|
194
|
-
else for (const edge of outgoing) if (!visited.has(edge.to)) {
|
|
195
|
-
visited.add(edge.to);
|
|
196
|
-
queue.push(edge.to);
|
|
197
|
-
}
|
|
198
|
-
}
|
|
273
|
+
for (const step of bfs([fromHash], (n) => forwardNeighbours(graph, n))) if (!graph.forwardChain.get(step.node)?.length) leaves.push(step.node);
|
|
199
274
|
return leaves;
|
|
200
275
|
}
|
|
201
276
|
/**
|
|
202
277
|
* Find the target contract hash of the migration graph reachable from
|
|
203
|
-
* EMPTY_CONTRACT_HASH.
|
|
204
|
-
*
|
|
205
|
-
* Throws
|
|
278
|
+
* EMPTY_CONTRACT_HASH. Returns `null` for a graph that has no target
|
|
279
|
+
* state (either empty, or containing only the root with no outgoing
|
|
280
|
+
* edges). Throws NO_INITIAL_MIGRATION if the graph has nodes but none
|
|
281
|
+
* originate from the empty hash, and AMBIGUOUS_TARGET if multiple
|
|
282
|
+
* branch tips exist.
|
|
206
283
|
*/
|
|
207
284
|
function findLeaf(graph) {
|
|
208
|
-
if (graph.nodes.size === 0) return
|
|
285
|
+
if (graph.nodes.size === 0) return null;
|
|
209
286
|
if (!graph.nodes.has(EMPTY_CONTRACT_HASH)) throw errorNoInitialMigration([...graph.nodes]);
|
|
210
287
|
const leaves = findReachableLeaves(graph, EMPTY_CONTRACT_HASH);
|
|
211
288
|
if (leaves.length === 0) {
|
|
212
289
|
const reachable = [...graph.nodes].filter((n) => n !== EMPTY_CONTRACT_HASH);
|
|
213
290
|
if (reachable.length > 0) throw errorNoTarget(reachable);
|
|
214
|
-
return
|
|
291
|
+
return null;
|
|
215
292
|
}
|
|
216
293
|
if (leaves.length > 1) {
|
|
217
294
|
const divergencePoint = findDivergencePoint(graph, EMPTY_CONTRACT_HASH, leaves);
|
|
@@ -229,8 +306,7 @@ function findLeaf(graph) {
|
|
|
229
306
|
})
|
|
230
307
|
});
|
|
231
308
|
}
|
|
232
|
-
|
|
233
|
-
return leaf !== void 0 ? leaf : EMPTY_CONTRACT_HASH;
|
|
309
|
+
return leaves[0];
|
|
234
310
|
}
|
|
235
311
|
/**
|
|
236
312
|
* Find the latest migration entry by traversing from EMPTY_CONTRACT_HASH
|
|
@@ -238,12 +314,9 @@ function findLeaf(graph) {
|
|
|
238
314
|
* Throws AMBIGUOUS_TARGET if the graph has multiple branch tips.
|
|
239
315
|
*/
|
|
240
316
|
function findLatestMigration(graph) {
|
|
241
|
-
if (graph.nodes.size === 0) return null;
|
|
242
317
|
const leafHash = findLeaf(graph);
|
|
243
|
-
if (leafHash ===
|
|
244
|
-
|
|
245
|
-
if (!path || path.length === 0) return null;
|
|
246
|
-
return path[path.length - 1] ?? null;
|
|
318
|
+
if (leafHash === null) return null;
|
|
319
|
+
return findPath(graph, EMPTY_CONTRACT_HASH, leafHash)?.at(-1) ?? null;
|
|
247
320
|
}
|
|
248
321
|
function detectCycles(graph) {
|
|
249
322
|
const WHITE = 0;
|
|
@@ -253,30 +326,42 @@ function detectCycles(graph) {
|
|
|
253
326
|
const parentMap = /* @__PURE__ */ new Map();
|
|
254
327
|
const cycles = [];
|
|
255
328
|
for (const node of graph.nodes) color.set(node, WHITE);
|
|
256
|
-
|
|
329
|
+
const stack = [];
|
|
330
|
+
function pushFrame(u) {
|
|
257
331
|
color.set(u, GRAY);
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
332
|
+
stack.push({
|
|
333
|
+
node: u,
|
|
334
|
+
outgoing: graph.forwardChain.get(u) ?? [],
|
|
335
|
+
index: 0
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
for (const root of graph.nodes) {
|
|
339
|
+
if (color.get(root) !== WHITE) continue;
|
|
340
|
+
parentMap.set(root, null);
|
|
341
|
+
pushFrame(root);
|
|
342
|
+
while (stack.length > 0) {
|
|
343
|
+
const frame = stack[stack.length - 1];
|
|
344
|
+
if (frame.index >= frame.outgoing.length) {
|
|
345
|
+
color.set(frame.node, BLACK);
|
|
346
|
+
stack.pop();
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
const v = frame.outgoing[frame.index++].to;
|
|
350
|
+
const vColor = color.get(v);
|
|
351
|
+
if (vColor === GRAY) {
|
|
262
352
|
const cycle = [v];
|
|
263
|
-
let cur =
|
|
353
|
+
let cur = frame.node;
|
|
264
354
|
while (cur !== v) {
|
|
265
355
|
cycle.push(cur);
|
|
266
356
|
cur = parentMap.get(cur) ?? v;
|
|
267
357
|
}
|
|
268
358
|
cycle.reverse();
|
|
269
359
|
cycles.push(cycle);
|
|
270
|
-
} else if (
|
|
271
|
-
parentMap.set(v,
|
|
272
|
-
|
|
360
|
+
} else if (vColor === WHITE) {
|
|
361
|
+
parentMap.set(v, frame.node);
|
|
362
|
+
pushFrame(v);
|
|
273
363
|
}
|
|
274
364
|
}
|
|
275
|
-
color.set(u, BLACK);
|
|
276
|
-
}
|
|
277
|
-
for (const node of graph.nodes) if (color.get(node) === WHITE) {
|
|
278
|
-
parentMap.set(node, null);
|
|
279
|
-
dfs(node);
|
|
280
365
|
}
|
|
281
366
|
return cycles;
|
|
282
367
|
}
|
|
@@ -290,18 +375,7 @@ function detectOrphans(graph) {
|
|
|
290
375
|
for (const edges of graph.forwardChain.values()) for (const edge of edges) allTargets.add(edge.to);
|
|
291
376
|
for (const node of graph.nodes) if (!allTargets.has(node)) startNodes.push(node);
|
|
292
377
|
}
|
|
293
|
-
const
|
|
294
|
-
for (const hash of queue) reachable.add(hash);
|
|
295
|
-
while (queue.length > 0) {
|
|
296
|
-
const node = queue.shift();
|
|
297
|
-
if (node === void 0) break;
|
|
298
|
-
const outgoing = graph.forwardChain.get(node);
|
|
299
|
-
if (!outgoing) continue;
|
|
300
|
-
for (const migration of outgoing) if (!reachable.has(migration.to)) {
|
|
301
|
-
reachable.add(migration.to);
|
|
302
|
-
queue.push(migration.to);
|
|
303
|
-
}
|
|
304
|
-
}
|
|
378
|
+
for (const step of bfs(startNodes, (n) => forwardNeighbours(graph, n))) reachable.add(step.node);
|
|
305
379
|
const orphans = [];
|
|
306
380
|
for (const [from, migrations] of graph.forwardChain) if (!reachable.has(from)) orphans.push(...migrations);
|
|
307
381
|
return orphans;
|