ghcr-manager 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/LICENSE +21 -0
  3. package/README.md +97 -0
  4. package/dist/cli/_args.d.ts +6 -0
  5. package/dist/cli/_args.js +38 -0
  6. package/dist/cli/_logger.d.ts +9 -0
  7. package/dist/cli/_logger.js +24 -0
  8. package/dist/cli/_scan-command.d.ts +1 -0
  9. package/dist/cli/_scan-command.js +32 -0
  10. package/dist/cli/index.d.ts +2 -0
  11. package/dist/cli/index.js +30 -0
  12. package/dist/core/_types.d.ts +49 -0
  13. package/dist/core/_types.js +1 -0
  14. package/dist/core/index.d.ts +1 -0
  15. package/dist/core/index.js +1 -0
  16. package/dist/db/_manifest-reachability.d.ts +2 -0
  17. package/dist/db/_manifest-reachability.js +94 -0
  18. package/dist/db/_scan-writer.d.ts +18 -0
  19. package/dist/db/_scan-writer.js +176 -0
  20. package/dist/db/_schema.d.ts +2 -0
  21. package/dist/db/_schema.js +19 -0
  22. package/dist/db/_snapshot-repository.d.ts +24 -0
  23. package/dist/db/_snapshot-repository.js +98 -0
  24. package/dist/db/index.d.ts +4 -0
  25. package/dist/db/index.js +9 -0
  26. package/dist/ingest/github/_manifest-client.d.ts +8 -0
  27. package/dist/ingest/github/_manifest-client.js +100 -0
  28. package/dist/ingest/github/_manifest-ingest.d.ts +3 -0
  29. package/dist/ingest/github/_manifest-ingest.js +104 -0
  30. package/dist/ingest/github/_package-version-page-load.d.ts +13 -0
  31. package/dist/ingest/github/_package-version-page-load.js +52 -0
  32. package/dist/ingest/github/_packages-client.d.ts +10 -0
  33. package/dist/ingest/github/_packages-client.js +59 -0
  34. package/dist/ingest/github/_paginated-ingest.d.ts +11 -0
  35. package/dist/ingest/github/_paginated-ingest.js +28 -0
  36. package/dist/ingest/github/_parallel-paginated-ingest.d.ts +11 -0
  37. package/dist/ingest/github/_parallel-paginated-ingest.js +49 -0
  38. package/dist/ingest/github/_registry-token-client.d.ts +6 -0
  39. package/dist/ingest/github/_registry-token-client.js +67 -0
  40. package/dist/ingest/github/_shared.d.ts +28 -0
  41. package/dist/ingest/github/_shared.js +102 -0
  42. package/dist/ingest/github/index.d.ts +7 -0
  43. package/dist/ingest/github/index.js +26 -0
  44. package/dist/tuning/index.d.ts +6 -0
  45. package/dist/tuning/index.js +6 -0
  46. package/package.json +59 -0
  47. package/resources/sql/schema/001_schema.sql +109 -0
  48. package/resources/sql/views/001_v_latest_scan_per_package.sql +27 -0
  49. package/resources/sql/views/002_v_missing_digests.sql +32 -0
  50. package/resources/sql/views/003_v_missing_digests_related_manifests.sql +78 -0
  51. package/resources/sql/views/004_v_manifests_related_manifests.sql +142 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,44 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to
6
+ [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.0.4] - 2026-04-30
11
+
12
+ ### Changed
13
+
14
+ - Publish to npmjs
15
+
16
+ ## [0.0.3] - 2026-04-30
17
+
18
+ ### Changed
19
+
20
+ - Released on GitHub marketplace as public action
21
+
22
+ ## [0.0.1] - 2026-04-30
23
+
24
+ ### Added
25
+
26
+ - Initial public release of `ghcr-manager` as a GitHub Action plus companion CLI.
27
+ - GHCR scan flow that loads package versions, tags, manifests, descriptors, and manifest graph edges into SQLite.
28
+ - Manifest reachability precomputation (`manifest_reachability`) for fast graph-based analysis queries.
29
+ - Raw payload storage for GitHub package-version items and GHCR manifests (`package_version_payloads`,
30
+ `manifest_payloads`).
31
+ - Scan lifecycle tracking with scan history (`package_scans`) and status transitions (`running|completed|failed`).
32
+ - Immutable per-scan UUID (`package_scans.scan_uuid`) for robust duplicate detection across merged databases.
33
+ - Optional action artifact upload for scan DB export (`upload-db-artifact`, optional retention override).
34
+ - Manual workflow for interactive scan runs (`.github/workflows/manual-run.yml`).
35
+ - Missing-manifest investigation SQL recipes (`docs/missing-manifests-queries.md`) and schema/terminology docs.
36
+
37
+ ### Changed
38
+
39
+ - Enforced stricter repository conventions:
40
+ - source/test tree mirroring
41
+ - cross-folder imports via folder `index.ts`
42
+ - internal source naming (`_*.ts`)
43
+ - Hardened CI/workflows with explicit token permissions and immutable action reference checks.
44
+ - Refined action/runtime flow to focus on scan + DB export behavior for this release.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 gh-workflow
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,97 @@
1
+ # ghcr-manager
2
+
3
+ [![Release](https://img.shields.io/github/v/release/gh-workflow/ghcr-manager?style=flat-square)](https://github.com/gh-workflow/ghcr-manager/releases)
4
+ [![Immutable Releases](https://img.shields.io/badge/releases-immutable-blue?labelColor=333)](https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/immutable-releases)
5
+ [![GitHub Marketplace](https://img.shields.io/badge/marketplace-ghcr--manager-blue?logo=github&labelColor=333&style=flat-square)](https://github.com/marketplace/actions/ghcr-manager)
6
+ [![Tests](https://img.shields.io/github/actions/workflow/status/gh-workflow/ghcr-manager/change-validation.yml?branch=main&label=test&style=flat-square)](https://github.com/gh-workflow/ghcr-manager/actions/workflows/change-validation.yml)
7
+ [![Usage](https://img.shields.io/badge/image-GHCR-2496ED?logo=docker&logoColor=white&style=flat-square)](#usage)
8
+
9
+ Inspect, analyze, and manage GitHub Container Registry packages.
10
+
11
+ `ghcr-manager` is a public GitHub Action and companion CLI for safe GHCR cleanup and inspection, with a focus on large
12
+ packages and correct handling of multi-arch images, referrers, and attestations.
13
+
14
+ ## Scope
15
+
16
+ - :white_check_mark: Full package and manifest scan per run for correctness
17
+ - :white_check_mark: Export database of Container Registry from runs for local analysis
18
+ - :construction: Safe cleanup of GHCR image artifacts in GitHub packages
19
+
20
+ ## How
21
+
22
+ ### :white_check_mark: Data Loading
23
+
24
+ 1. Writes Container Registry metadata to a database
25
+ 2. Pre-processes data for optimized lookups
26
+ 3. Optional: Export of database from runs for local analysis
27
+
28
+ > :construction: Planned: Support for merging several such databases into one for local analysis
29
+
30
+ ### :construction: Consistency Check
31
+
32
+ 1. Run consistency check against the database
33
+ 2. Optional: Export report of missing manifests from runs
34
+
35
+ ### :construction: Safe cleanup of GHCR image artifacts
36
+
37
+ 1. Use filter input to query database for related artifacts (images and manifests)
38
+ 2. Optional `dry-run`: Export which image artifacts would be deleted
39
+ 3. Delete image artifacts (without `dry-run`)
40
+
41
+ ## Usage
42
+
43
+ ```yaml
44
+ concurrency:
45
+ group: ghcr-manager__${{ inputs.owner }}__${{ inputs.package }}
46
+ jobs:
47
+ scan:
48
+ runs-on: ubuntu-latest
49
+ permissions:
50
+ contents: read
51
+ packages: read
52
+ actions: write # only required when upload-db-artifact is true
53
+ concurrency:
54
+ group: ghcr-manager
55
+ steps:
56
+ - uses: actions/checkout@v6
57
+
58
+ - name: Run ghcr-manager action
59
+ uses: gh-workflow/ghcr-manager@0.0.4
60
+ with:
61
+ github-token: ${{ github.token }}
62
+ owner: OWNER
63
+ package: PACKAGE
64
+ upload-db-artifact: true
65
+ ```
66
+
67
+ > Copy the [Manual Run Workflow](.github/workflows/manual-run.yml) as a ready-to-run manual scan workflow.
68
+
69
+ ## Inputs
70
+
71
+ <!-- markdownlint-disable MD013 -->
72
+
73
+ | Input | Description | Required | Default |
74
+ | ---------------------------- | --------------------------------------------------------------- | -------- | ------------------------------ |
75
+ | `github-token` | GitHub token used for GitHub/GHCR API calls | Yes | `${{ github.token }}` |
76
+ | `owner` | GitHub owner of the container package (user or org) | Yes | |
77
+ | `package` | Container package name | Yes | |
78
+ | `upload-db-artifact` | Whether to upload the scan database as a workflow run artifact | No | `false` |
79
+ | `db-artifact-retention-days` | Optional retention days override for uploaded database artifact | No | `${{ github.retention_days }}` |
80
+
81
+ <!-- markdownlint-enable MD013 -->
82
+
83
+ ## Outputs
84
+
85
+ | Output | Description |
86
+ | --------- | ------------------------------------------------------- |
87
+ | `db-path` | Path to the SQLite database in the GitHub action runner |
88
+
89
+ ## Artifacts
90
+
91
+ <!-- markdownlint-disable MD013 -->
92
+
93
+ | Name | Filename | Description |
94
+ | ----------------------------- | --------------------------------- | -------------------------------------------------------------------- |
95
+ | `${OWNER}__${PACKAGE}.sqlite` | `${OWNER}__${PACKAGE}.sqlite.zip` | Zipped SQLite database containing GitHub Container Registry metadata |
96
+
97
+ <!-- markdownlint-enable MD013 -->
@@ -0,0 +1,6 @@
1
+ import { type LogLevel } from "./_logger.js";
2
+ export declare function requireOption(args: string[], name: string): string;
3
+ export declare function findOption(args: string[], name: string): string | undefined;
4
+ export declare function collectRepeatedOption(args: string[], name: string): string[];
5
+ export declare function resolveGitHubToken(args: string[]): string;
6
+ export declare function resolveLogLevel(args: string[]): LogLevel;
@@ -0,0 +1,38 @@
1
+ import { isLogLevel } from "./_logger.js";
2
+ export function requireOption(args, name) {
3
+ const value = findOption(args, name);
4
+ if (!value) {
5
+ throw new Error(`missing required option: ${name}`);
6
+ }
7
+ return value;
8
+ }
9
+ export function findOption(args, name) {
10
+ const index = args.indexOf(name);
11
+ if (index < 0) {
12
+ return undefined;
13
+ }
14
+ return args[index + 1];
15
+ }
16
+ export function collectRepeatedOption(args, name) {
17
+ const values = [];
18
+ for (let index = 0; index < args.length; index += 1) {
19
+ if (args[index] === name && args[index + 1]) {
20
+ values.push(args[index + 1]);
21
+ }
22
+ }
23
+ return values;
24
+ }
25
+ export function resolveGitHubToken(args) {
26
+ const cliToken = findOption(args, "--token");
27
+ if (cliToken) {
28
+ return cliToken;
29
+ }
30
+ throw new Error("missing required option: --token");
31
+ }
32
+ export function resolveLogLevel(args) {
33
+ const rawLevel = findOption(args, "--log-level") ?? "info";
34
+ if (!isLogLevel(rawLevel)) {
35
+ throw new Error(`invalid log level: ${rawLevel}`);
36
+ }
37
+ return rawLevel;
38
+ }
@@ -0,0 +1,9 @@
1
+ export type LogLevel = "debug" | "info" | "warn" | "error" | "silent";
2
+ export interface Logger {
3
+ debug(message: string): void;
4
+ info(message: string): void;
5
+ warn(message: string): void;
6
+ error(message: string): void;
7
+ }
8
+ export declare function isLogLevel(value: string): value is LogLevel;
9
+ export declare function createLogger(level: LogLevel, sink?: NodeJS.WritableStream): Logger;
@@ -0,0 +1,24 @@
1
+ const _logLevelPriority = {
2
+ debug: 10,
3
+ info: 20,
4
+ warn: 30,
5
+ error: 40,
6
+ silent: 50
7
+ };
8
+ export function isLogLevel(value) {
9
+ return value in _logLevelPriority;
10
+ }
11
+ export function createLogger(level, sink = process.stderr) {
12
+ return {
13
+ debug: _write.bind(null, "debug", level, sink),
14
+ info: _write.bind(null, "info", level, sink),
15
+ warn: _write.bind(null, "warn", level, sink),
16
+ error: _write.bind(null, "error", level, sink)
17
+ };
18
+ }
19
+ function _write(level, threshold, sink, message) {
20
+ if (_logLevelPriority[level] < _logLevelPriority[threshold]) {
21
+ return;
22
+ }
23
+ sink.write(`${new Date().toISOString()} ${level.toUpperCase()} ${message}\n`);
24
+ }
@@ -0,0 +1 @@
1
+ export declare function handleScan(args: string[]): Promise<number>;
@@ -0,0 +1,32 @@
1
+ import { ScanWriter, SnapshotRepository, openDatabase } from "../db/index.js";
2
+ import { importGitHubScan } from "../ingest/github/index.js";
3
+ import { requireOption, resolveGitHubToken, resolveLogLevel } from "./_args.js";
4
+ import { createLogger } from "./_logger.js";
5
+ export async function handleScan(args) {
6
+ const databasePath = requireOption(args, "--db");
7
+ const owner = requireOption(args, "--owner");
8
+ const packageName = requireOption(args, "--package");
9
+ const logger = createLogger(resolveLogLevel(args));
10
+ const database = openDatabase(databasePath);
11
+ const repository = new SnapshotRepository(database);
12
+ const writer = new ScanWriter(database);
13
+ await importGitHubScan({
14
+ owner,
15
+ packageName,
16
+ token: resolveGitHubToken(args),
17
+ logger
18
+ }, writer, repository);
19
+ const scanId = writer.getActiveScanId();
20
+ const metadata = repository.getPackageMetadata(scanId);
21
+ console.log(JSON.stringify({
22
+ owner: metadata.owner,
23
+ packageName: metadata.packageName,
24
+ scanCompletedAt: metadata.scanCompletedAt,
25
+ packageVersions: repository.countPackageVersions(scanId),
26
+ tags: repository.countTags(scanId),
27
+ manifests: repository.countManifests(scanId),
28
+ manifestEdges: repository.countManifestEdges(scanId)
29
+ }, null, 2));
30
+ database.close();
31
+ return 0;
32
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export declare function main(argv: string[]): Promise<number>;
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env node
2
+ import { realpathSync } from "node:fs";
3
+ import { fileURLToPath } from "node:url";
4
+ import { handleScan } from "./_scan-command.js";
5
+ export async function main(argv) {
6
+ const [command, ...rest] = argv;
7
+ if (!command) {
8
+ printUsage();
9
+ return 1;
10
+ }
11
+ switch (command) {
12
+ case "scan":
13
+ return handleScan(rest);
14
+ default:
15
+ throw new Error(`unknown command: ${command}`);
16
+ }
17
+ }
18
+ function printUsage() {
19
+ console.error(`Usage:
20
+ ghcr-manager scan --db <path> [--log-level <debug|info|warn|error|silent>] --owner <org> --package <name> --token <token>`);
21
+ }
22
+ const _entryPath = process.argv[1];
23
+ const _isDirectExecution = realpathSync(_entryPath) === realpathSync(fileURLToPath(import.meta.url));
24
+ if (_isDirectExecution) {
25
+ main(process.argv.slice(2)).catch((error) => {
26
+ const message = error instanceof Error ? error.message : String(error);
27
+ console.error(message);
28
+ process.exitCode = 1;
29
+ });
30
+ }
@@ -0,0 +1,49 @@
1
+ export interface PackageVersionRecord {
2
+ versionId: number;
3
+ digest: string;
4
+ createdAt: string;
5
+ updatedAt: string;
6
+ metadata?: Record<string, unknown>;
7
+ }
8
+ export interface TagRecord {
9
+ tag: string;
10
+ digest: string;
11
+ versionId: number;
12
+ }
13
+ export interface ManifestRecord {
14
+ digest: string;
15
+ mediaType: string;
16
+ artifactType?: string;
17
+ configMediaType?: string;
18
+ subjectDigest?: string;
19
+ annotations?: Record<string, unknown>;
20
+ platform?: {
21
+ architecture?: string;
22
+ os?: string;
23
+ variant?: string;
24
+ };
25
+ }
26
+ export interface ManifestDescriptorRecord {
27
+ parentDigest: string;
28
+ childDigest: string;
29
+ mediaType: string;
30
+ artifactType?: string;
31
+ platform?: {
32
+ architecture?: string;
33
+ os?: string;
34
+ variant?: string;
35
+ };
36
+ }
37
+ export interface ManifestEdgeRecord {
38
+ parentDigest: string;
39
+ childDigest: string;
40
+ edgeKind: "image-child" | "referrer";
41
+ }
42
+ export interface PackageSnapshot {
43
+ packageName: string;
44
+ scanCompletedAt: string;
45
+ packageVersions: PackageVersionRecord[];
46
+ tags: TagRecord[];
47
+ manifests: ManifestRecord[];
48
+ manifestEdges: ManifestEdgeRecord[];
49
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export type { ManifestEdgeRecord, ManifestDescriptorRecord, ManifestRecord, PackageSnapshot, PackageVersionRecord, TagRecord } from "./_types.js";
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,2 @@
1
+ import type Database from "better-sqlite3";
2
+ export declare function rebuildManifestReachability(database: Database.Database, scanId: number): void;
@@ -0,0 +1,94 @@
1
+ export function rebuildManifestReachability(database, scanId) {
2
+ const manifestDigests = _loadManifestDigests(database, scanId);
3
+ const childDigestsByParent = new Map();
4
+ const parentDigestsByChild = new Map();
5
+ for (const digest of manifestDigests) {
6
+ childDigestsByParent.set(digest, new Set());
7
+ parentDigestsByChild.set(digest, new Set());
8
+ }
9
+ for (const manifestEdge of _loadManifestEdges(database, scanId)) {
10
+ childDigestsByParent.get(manifestEdge.parent_digest)?.add(manifestEdge.child_digest);
11
+ parentDigestsByChild.get(manifestEdge.child_digest)?.add(manifestEdge.parent_digest);
12
+ }
13
+ const remainingChildrenCount = new Map();
14
+ const descendantDistancesByDigest = new Map();
15
+ const readyDigests = [];
16
+ for (const digest of manifestDigests) {
17
+ const childCount = childDigestsByParent.get(digest)?.size ?? 0;
18
+ remainingChildrenCount.set(digest, childCount);
19
+ if (childCount === 0) {
20
+ readyDigests.push(digest);
21
+ }
22
+ }
23
+ while (readyDigests.length > 0) {
24
+ const digest = readyDigests.shift();
25
+ if (!digest) {
26
+ continue;
27
+ }
28
+ const distances = new Map([[digest, 0]]);
29
+ for (const childDigest of childDigestsByParent.get(digest) ?? []) {
30
+ _setMinDistance(distances, childDigest, 1);
31
+ const childDistances = descendantDistancesByDigest.get(childDigest);
32
+ if (!childDistances) {
33
+ throw new Error(`manifest reachability build missing child results for ${childDigest}`);
34
+ }
35
+ for (const [descendantDigest, childDistance] of childDistances) {
36
+ if (descendantDigest === childDigest) {
37
+ continue;
38
+ }
39
+ _setMinDistance(distances, descendantDigest, childDistance + 1);
40
+ }
41
+ }
42
+ descendantDistancesByDigest.set(digest, distances);
43
+ for (const parentDigest of parentDigestsByChild.get(digest) ?? []) {
44
+ const nextCount = (remainingChildrenCount.get(parentDigest) ?? 0) - 1;
45
+ remainingChildrenCount.set(parentDigest, nextCount);
46
+ if (nextCount === 0) {
47
+ readyDigests.push(parentDigest);
48
+ }
49
+ }
50
+ }
51
+ if (descendantDistancesByDigest.size !== manifestDigests.length) {
52
+ throw new Error("manifest reachability build detected a cycle in manifest_edges");
53
+ }
54
+ const insertRow = database.prepare(`
55
+ INSERT OR REPLACE INTO manifest_reachability(
56
+ scan_id,
57
+ ancestor_digest,
58
+ descendant_digest,
59
+ min_distance
60
+ )
61
+ VALUES(?, ?, ?, ?)
62
+ `);
63
+ const rebuild = database.transaction(() => {
64
+ database.prepare("DELETE FROM manifest_reachability WHERE scan_id = ?").run(scanId);
65
+ for (const digest of manifestDigests) {
66
+ for (const [descendantDigest, distance] of descendantDistancesByDigest.get(digest) ?? []) {
67
+ insertRow.run(scanId, digest, descendantDigest, distance);
68
+ }
69
+ }
70
+ });
71
+ rebuild();
72
+ }
73
+ function _loadManifestDigests(database, scanId) {
74
+ const rows = database
75
+ .prepare("SELECT digest FROM manifests WHERE scan_id = ? ORDER BY digest")
76
+ .all(scanId);
77
+ return rows.map((row) => row.digest);
78
+ }
79
+ function _loadManifestEdges(database, scanId) {
80
+ return database
81
+ .prepare(`
82
+ SELECT DISTINCT parent_digest, child_digest
83
+ FROM manifest_edges
84
+ WHERE scan_id = ?
85
+ ORDER BY parent_digest, child_digest
86
+ `)
87
+ .all(scanId);
88
+ }
89
+ function _setMinDistance(distances, digest, distance) {
90
+ const currentDistance = distances.get(digest);
91
+ if (currentDistance === undefined || distance < currentDistance) {
92
+ distances.set(digest, distance);
93
+ }
94
+ }
@@ -0,0 +1,18 @@
1
+ import type Database from "better-sqlite3";
2
+ import type { ManifestDescriptorRecord, ManifestEdgeRecord, ManifestRecord, PackageVersionRecord, TagRecord } from "../core/index.js";
3
+ export declare class ScanWriter {
4
+ #private;
5
+ constructor(database: Database.Database);
6
+ resetScan(owner: string, packageName: string, scanStartedAt: string): void;
7
+ markScanCompleted(scanCompletedAt: string): void;
8
+ markScanFailed(scanCompletedAt: string): void;
9
+ insertPackageVersion(version: PackageVersionRecord): void;
10
+ insertPackageVersionPayload(versionId: number, rawJson: string): void;
11
+ insertTag(tag: TagRecord): void;
12
+ insertManifest(manifest: ManifestRecord): void;
13
+ insertManifestPayload(digest: string, rawJson: string): void;
14
+ insertManifestDescriptor(descriptor: ManifestDescriptorRecord): void;
15
+ insertManifestEdge(edge: ManifestEdgeRecord): void;
16
+ rebuildManifestReachability(): void;
17
+ getActiveScanId(): number;
18
+ }
@@ -0,0 +1,176 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { rebuildManifestReachability } from "./_manifest-reachability.js";
3
+ export class ScanWriter {
4
+ #database;
5
+ #activeScanId = null;
6
+ constructor(database) {
7
+ this.#database = database;
8
+ }
9
+ resetScan(owner, packageName, scanStartedAt) {
10
+ const result = this.#database
11
+ .prepare(`
12
+ INSERT INTO package_scans(scan_uuid, owner, package_name, scan_started_at, scan_completed_at, status)
13
+ VALUES(?, ?, ?, ?, NULL, 'running')
14
+ `)
15
+ .run(randomUUID(), owner, packageName, scanStartedAt);
16
+ this.#activeScanId = Number(result.lastInsertRowid);
17
+ }
18
+ markScanCompleted(scanCompletedAt) {
19
+ this.#database
20
+ .prepare(`
21
+ UPDATE package_scans
22
+ SET scan_completed_at = ?, status = 'completed'
23
+ WHERE scan_id = ?
24
+ `)
25
+ .run(scanCompletedAt, this.#requireScanId());
26
+ }
27
+ markScanFailed(scanCompletedAt) {
28
+ this.#database
29
+ .prepare(`
30
+ UPDATE package_scans
31
+ SET scan_completed_at = ?, status = 'failed'
32
+ WHERE scan_id = ?
33
+ `)
34
+ .run(scanCompletedAt, this.#requireScanId());
35
+ }
36
+ insertPackageVersion(version) {
37
+ this.#database
38
+ .prepare(`
39
+ INSERT OR REPLACE INTO package_versions(scan_id, version_id, digest, created_at, updated_at)
40
+ VALUES(@scanId, @versionId, @digest, @createdAt, @updatedAt)
41
+ `)
42
+ .run({
43
+ scanId: this.#requireScanId(),
44
+ versionId: version.versionId,
45
+ digest: version.digest,
46
+ createdAt: version.createdAt,
47
+ updatedAt: version.updatedAt
48
+ });
49
+ }
50
+ insertPackageVersionPayload(versionId, rawJson) {
51
+ this.#database
52
+ .prepare(`
53
+ INSERT OR REPLACE INTO package_version_payloads(scan_id, version_id, raw_json)
54
+ VALUES(?, ?, ?)
55
+ `)
56
+ .run(this.#requireScanId(), versionId, rawJson);
57
+ }
58
+ insertTag(tag) {
59
+ this.#database
60
+ .prepare(`
61
+ INSERT OR REPLACE INTO tags(scan_id, tag, digest, version_id)
62
+ VALUES(@scanId, @tag, @digest, @versionId)
63
+ `)
64
+ .run({
65
+ scanId: this.#requireScanId(),
66
+ ...tag
67
+ });
68
+ }
69
+ insertManifest(manifest) {
70
+ this.#database
71
+ .prepare(`
72
+ INSERT OR REPLACE INTO manifests(
73
+ scan_id,
74
+ digest,
75
+ media_type,
76
+ artifact_type,
77
+ config_media_type,
78
+ subject_digest,
79
+ annotations_json,
80
+ platform_os,
81
+ platform_architecture,
82
+ platform_variant
83
+ )
84
+ VALUES(
85
+ @scanId,
86
+ @digest,
87
+ @mediaType,
88
+ @artifactType,
89
+ @configMediaType,
90
+ @subjectDigest,
91
+ @annotationsJson,
92
+ @platformOs,
93
+ @platformArchitecture,
94
+ @platformVariant
95
+ )
96
+ `)
97
+ .run({
98
+ scanId: this.#requireScanId(),
99
+ digest: manifest.digest,
100
+ mediaType: manifest.mediaType,
101
+ artifactType: manifest.artifactType ?? null,
102
+ configMediaType: manifest.configMediaType ?? null,
103
+ subjectDigest: manifest.subjectDigest ?? null,
104
+ annotationsJson: manifest.annotations ? JSON.stringify(manifest.annotations) : null,
105
+ platformOs: manifest.platform?.os ?? null,
106
+ platformArchitecture: manifest.platform?.architecture ?? null,
107
+ platformVariant: manifest.platform?.variant ?? null
108
+ });
109
+ }
110
+ insertManifestPayload(digest, rawJson) {
111
+ this.#database
112
+ .prepare(`
113
+ INSERT OR REPLACE INTO manifest_payloads(scan_id, digest, raw_json)
114
+ VALUES(?, ?, ?)
115
+ `)
116
+ .run(this.#requireScanId(), digest, rawJson);
117
+ }
118
+ insertManifestDescriptor(descriptor) {
119
+ this.#database
120
+ .prepare(`
121
+ INSERT OR REPLACE INTO manifest_descriptors(
122
+ scan_id,
123
+ parent_digest,
124
+ child_digest,
125
+ media_type,
126
+ artifact_type,
127
+ platform_os,
128
+ platform_architecture,
129
+ platform_variant
130
+ )
131
+ VALUES(
132
+ @scanId,
133
+ @parentDigest,
134
+ @childDigest,
135
+ @mediaType,
136
+ @artifactType,
137
+ @platformOs,
138
+ @platformArchitecture,
139
+ @platformVariant
140
+ )
141
+ `)
142
+ .run({
143
+ scanId: this.#requireScanId(),
144
+ parentDigest: descriptor.parentDigest,
145
+ childDigest: descriptor.childDigest,
146
+ mediaType: descriptor.mediaType,
147
+ artifactType: descriptor.artifactType ?? null,
148
+ platformOs: descriptor.platform?.os ?? null,
149
+ platformArchitecture: descriptor.platform?.architecture ?? null,
150
+ platformVariant: descriptor.platform?.variant ?? null
151
+ });
152
+ }
153
+ insertManifestEdge(edge) {
154
+ this.#database
155
+ .prepare(`
156
+ INSERT OR IGNORE INTO manifest_edges(scan_id, parent_digest, child_digest, edge_kind)
157
+ VALUES(@scanId, @parentDigest, @childDigest, @edgeKind)
158
+ `)
159
+ .run({
160
+ scanId: this.#requireScanId(),
161
+ ...edge
162
+ });
163
+ }
164
+ rebuildManifestReachability() {
165
+ rebuildManifestReachability(this.#database, this.#requireScanId());
166
+ }
167
+ getActiveScanId() {
168
+ return this.#requireScanId();
169
+ }
170
+ #requireScanId() {
171
+ if (this.#activeScanId === null) {
172
+ throw new Error("package not initialized; call resetScan(owner, packageName, scanStartedAt) first");
173
+ }
174
+ return this.#activeScanId;
175
+ }
176
+ }
@@ -0,0 +1,2 @@
1
+ import type Database from "better-sqlite3";
2
+ export declare function initializeSchema(database: Database.Database): void;