@ecosyste-ms/critical 1.1.20260822 → 1.3.20260824

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 CHANGED
@@ -12,14 +12,27 @@ npm install @ecosyste-ms/critical
12
12
 
13
13
  The package includes a pre-built `critical-packages.db` file:
14
14
 
15
- ```javascript
15
+ ```typescript
16
16
  import { databasePath } from '@ecosyste-ms/critical'
17
- import Database from 'better-sqlite3'
17
+ import { DatabaseSync } from 'node:sqlite'
18
18
 
19
- const db = new Database(databasePath)
19
+ const db = new DatabaseSync(databasePath)
20
20
  const pkg = db.prepare('SELECT * FROM packages WHERE name = ?').get('lodash')
21
21
  ```
22
22
 
23
+ Written in TypeScript and published with type declarations; the row shapes the build
24
+ side works with (`ApiPackage`, `ApiAdvisory`, `ApiRepoMetadata`, `BuildInfo`) are
25
+ exported as types.
26
+
27
+ Since 1.3.0 the package has one runtime dependency, [`@ecosyste-ms/ecosystems-ts`][sdk],
28
+ the typed client for the ecosyste.ms API. The build talks to the API through it rather
29
+ than through a hand-rolled fetch layer, so retries, rate limiting and registry routing
30
+ live in one place across the ecosyste.ms packages. The version is pinned exactly, since
31
+ the daily job publishes unattended. Reading the bundled database still touches nothing
32
+ but `node:sqlite`.
33
+
34
+ [sdk]: https://github.com/ecosyste-ms/ecosystems-ts
35
+
23
36
  See [ecosyste-ms/mcp](https://github.com/ecosyste-ms/mcp) for a full example.
24
37
 
25
38
  ## Download
@@ -52,6 +65,36 @@ npx @ecosyste-ms/critical -o my-db.db # custom output path
52
65
  npx @ecosyste-ms/critical --stats # show database statistics
53
66
  ```
54
67
 
68
+ Set `ECOSYSTEMS_MAILTO` to a contact address before a full build. The ecosyste.ms API
69
+ allows anonymous callers roughly 5,000 requests an hour and a full build makes around
70
+ 9,700; a build that identifies itself gets roughly 15,000. Without it most of the version
71
+ fetches are rate-limited away, and `build()` throws rather than publish a database that
72
+ lost more than 5% of its versions.
73
+
74
+ ```bash
75
+ ECOSYSTEMS_MAILTO=you@example.com npx @ecosyste-ms/critical
76
+ ```
77
+
78
+ ## Development
79
+
80
+ ```bash
81
+ npm ci
82
+ npm run build # tsc: lib/*.ts -> dist/
83
+ npm test # vitest, against lib/ sources -- no build needed
84
+ npm run test:packaged # packs the tarball and tests what it ships
85
+ npm run lint # biome check + tsc --noEmit
86
+ npm run format # biome check --write
87
+ npm run build:db # build critical-packages.db from the API (what the daily job runs)
88
+ ```
89
+
90
+ The default suite runs against `lib/` and needs no build step. `test:packaged` is
91
+ separate because it builds, runs `npm pack`, and exercises the extracted tarball's
92
+ entrypoint, export surface and `bin` — the checks that catch a broken `exports` map
93
+ rather than broken logic.
94
+
95
+ The published package is plain JavaScript, so consumers stay on the Node >= 22.5.0 floor
96
+ that `node:sqlite` sets.
97
+
55
98
  ## Schema
56
99
 
57
100
  ### packages
Binary file
package/dist/bin.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ export {};
3
+ //# sourceMappingURL=bin.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bin.d.ts","sourceRoot":"","sources":["../lib/bin.ts"],"names":[],"mappings":""}
package/dist/bin.js ADDED
@@ -0,0 +1,91 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync } from "node:fs";
3
+ import { DatabaseSync } from "node:sqlite";
4
+ import { build, databasePath } from "./index.js";
5
+ const args = process.argv.slice(2);
6
+ if (args.includes("--help") || args.includes("-h")) {
7
+ console.log(`Usage: critical [options]
8
+
9
+ Options:
10
+ --output, -o <path> Output database path (default: critical-packages.db)
11
+ --skip-versions Skip fetching version data (faster)
12
+ --stats Show database statistics
13
+ --help, -h Show this help message
14
+
15
+ Environment:
16
+ ECOSYSTEMS_MAILTO Contact address sent to the API. Without it the build runs on
17
+ the anonymous rate-limit tier (~5,000 requests/hour against
18
+ ~9,700 needed) and aborts once too many version fetches fail.
19
+
20
+ Examples:
21
+ npx @ecosyste-ms/critical
22
+ npx @ecosyste-ms/critical --skip-versions
23
+ npx @ecosyste-ms/critical -o my-database.db
24
+ npx @ecosyste-ms/critical --stats`);
25
+ process.exit(0);
26
+ }
27
+ function getArg(flags) {
28
+ for (const flag of flags) {
29
+ const idx = args.indexOf(flag);
30
+ if (idx !== -1 && args[idx + 1]) {
31
+ return args[idx + 1] ?? null;
32
+ }
33
+ }
34
+ return null;
35
+ }
36
+ const dbPath = getArg(["--output", "-o"]) || "critical-packages.db";
37
+ if (args.includes("--stats")) {
38
+ const path = existsSync(dbPath) ? dbPath : databasePath;
39
+ if (!existsSync(path)) {
40
+ console.error(`Database not found: ${path}`);
41
+ process.exit(1);
42
+ }
43
+ const db = new DatabaseSync(path, { readOnly: true });
44
+ const info = db.prepare("SELECT * FROM build_info WHERE id = 1").get();
45
+ const count = (table) => Number(db.prepare(`SELECT COUNT(*) as count FROM ${table}`).get()?.count ?? 0);
46
+ const packageCount = count("packages");
47
+ const versionCount = count("versions");
48
+ const advisoryCount = count("advisories");
49
+ const ecosystems = db
50
+ .prepare(`
51
+ SELECT ecosystem, COUNT(*) as count
52
+ FROM packages
53
+ GROUP BY ecosystem
54
+ ORDER BY count DESC
55
+ `)
56
+ .all();
57
+ const severities = db
58
+ .prepare(`
59
+ SELECT severity, COUNT(*) as count
60
+ FROM advisories
61
+ GROUP BY severity
62
+ ORDER BY count DESC
63
+ `)
64
+ .all();
65
+ console.log(`Database: ${path}`);
66
+ console.log(`Built: ${info?.built_at || "unknown"}`);
67
+ console.log(`\nPackages: ${packageCount}`);
68
+ console.log(`Versions: ${versionCount}`);
69
+ console.log(`Advisories: ${advisoryCount}`);
70
+ console.log(`\nBy ecosystem:`);
71
+ for (const e of ecosystems) {
72
+ console.log(` ${e.ecosystem}: ${e.count}`);
73
+ }
74
+ console.log(`\nBy severity:`);
75
+ for (const s of severities) {
76
+ console.log(` ${s.severity || "unknown"}: ${s.count}`);
77
+ }
78
+ db.close();
79
+ process.exit(0);
80
+ }
81
+ const skipVersions = args.includes("--skip-versions");
82
+ build({
83
+ dbPath,
84
+ fetchVersionsData: !skipVersions,
85
+ })
86
+ .then(() => process.exit(0))
87
+ .catch((err) => {
88
+ console.error(err);
89
+ process.exit(1);
90
+ });
91
+ //# sourceMappingURL=bin.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bin.js","sourceRoot":"","sources":["../lib/bin.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,96 @@
1
+ import { DatabaseSync } from "node:sqlite";
2
+ import { type EcosystemsClient, type PackageWithRegistry } from "@ecosyste-ms/ecosystems-ts";
3
+ declare const databasePath: string;
4
+ /**
5
+ * Every field optional and nullable.
6
+ *
7
+ * The OpenAPI spec marks most package fields required and non-null; the API omits them
8
+ * and sends explicit nulls. Modelling what actually arrives is what keeps the runtime
9
+ * guards below honest instead of looking redundant.
10
+ */
11
+ type Loose<T> = {
12
+ [K in keyof T]?: T[K] | null;
13
+ };
14
+ /**
15
+ * Repository metadata as `GET /packages/critical` serves it -- every field optional.
16
+ *
17
+ * The API sends `full_name` ("owner/repo") but never `name`, and nests the host here
18
+ * rather than on the package. Both were previously read from the wrong place, which left
19
+ * `repo_metadata.repo_name` and `repo_metadata.host` NULL in every published build.
20
+ */
21
+ export interface ApiRepoMetadata {
22
+ owner?: string | null;
23
+ name?: string | null;
24
+ full_name?: string | null;
25
+ language?: string | null;
26
+ stargazers_count?: number | null;
27
+ forks_count?: number | null;
28
+ open_issues_count?: number | null;
29
+ archived?: boolean | null;
30
+ fork?: boolean | null;
31
+ host?: ApiHost | null;
32
+ }
33
+ /** The registry host, as nested inside `repo_metadata`. */
34
+ export interface ApiHost {
35
+ name?: string | null;
36
+ }
37
+ /**
38
+ * An advisory as the API nests it in a package.
39
+ *
40
+ * Derived from the package schema rather than the SDK's top-level `Advisory`: the two
41
+ * specs describe the same objects differently -- `advisories.ecosyste.ms` types the
42
+ * optional fields `string | undefined`, while the copy embedded in a package uses
43
+ * `string | null`, which is what the API actually sends.
44
+ */
45
+ type PackageAdvisory = NonNullable<PackageWithRegistry["advisories"]>[number];
46
+ export type ApiAdvisory = Loose<PackageAdvisory>;
47
+ /**
48
+ * A package from `GET /packages/critical`, as the SDK types it.
49
+ *
50
+ * `repo_metadata` is overridden: the OpenAPI spec types it `Record<string, never>` -- a
51
+ * known-opaque object -- while this package reads nine fields out of it. Everything else
52
+ * is the generated shape.
53
+ *
54
+ * The spec is stricter than the API. It declares `normalized_licenses`, `purl` and
55
+ * `keywords_array` required and non-null; they are not always sent. Every runtime guard
56
+ * below stays regardless of what these types claim.
57
+ */
58
+ export type ApiPackage = Loose<Omit<PackageWithRegistry, "repo_metadata" | "advisories">> & Pick<PackageWithRegistry, "id" | "ecosystem" | "name"> & {
59
+ advisories?: (ApiAdvisory | null)[] | null;
60
+ repo_metadata?: ApiRepoMetadata | null;
61
+ };
62
+ /** The single `build_info` row, written at the end of every build. */
63
+ export interface BuildInfo {
64
+ built_at: string;
65
+ package_count: number;
66
+ version_count: number;
67
+ advisory_count: number;
68
+ }
69
+ export type ProgressReporter = (message: string) => void;
70
+ export interface BuildOptions {
71
+ dbPath?: string;
72
+ fetchVersionsData?: boolean;
73
+ onProgress?: ProgressReporter;
74
+ /**
75
+ * The API client. Defaults to the shared one; a substitute lets the tests drive the
76
+ * fetch loop's failure accounting without a network.
77
+ */
78
+ client?: Pick<EcosystemsClient, "listCriticalPackages" | "getVersionNumbers">;
79
+ }
80
+ declare function extractDatabase(source: string, destination: string): Promise<void>;
81
+ declare function registryFor(pkg: {
82
+ ecosystem?: string | null;
83
+ purl?: string | null;
84
+ }): string | null;
85
+ declare function fetchAllCriticalPackages(onProgress?: ProgressReporter, api?: Pick<EcosystemsClient, "listCriticalPackages">): Promise<ApiPackage[]>;
86
+ /** Version numbers for a package, or `[]` when there is no registry to ask. Throws on
87
+ * transport failure -- the caller decides whether one package's loss is tolerable. */
88
+ declare function fetchVersionNumbers(ecosystem: string, name: string): Promise<string[]>;
89
+ declare function createDatabase(dbPath: string): DatabaseSync;
90
+ declare function insertPackage(db: DatabaseSync, pkg: ApiPackage): number;
91
+ declare function insertRepoMetadata(db: DatabaseSync, packageId: number, repoMetadata: ApiRepoMetadata | null | undefined, host?: ApiHost | null): void;
92
+ declare function insertAdvisories(db: DatabaseSync, packageId: number, advisories: (ApiAdvisory | null | undefined)[] | null | undefined): void;
93
+ declare function insertVersions(db: DatabaseSync, packageId: number, versionNumbers: string[] | null | undefined): void;
94
+ declare function build(options?: BuildOptions): Promise<BuildInfo>;
95
+ export { build, createDatabase, databasePath, extractDatabase, fetchAllCriticalPackages, fetchVersionNumbers, insertAdvisories, insertPackage, insertRepoMetadata, insertVersions, registryFor, };
96
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../lib/index.ts"],"names":[],"mappings":"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"}
package/dist/index.js ADDED
@@ -0,0 +1,386 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { createReadStream, createWriteStream, existsSync } from "node:fs";
3
+ import { mkdir, readFile, rename, unlink } from "node:fs/promises";
4
+ import { dirname, join } from "node:path";
5
+ import { DatabaseSync } from "node:sqlite";
6
+ import { pipeline } from "node:stream/promises";
7
+ import { fileURLToPath } from "node:url";
8
+ import { createGunzip } from "node:zlib";
9
+ import { createEcosystemsClient, PURL_TYPE_TO_REGISTRY, parsePurl, purlToRegistry, } from "@ecosyste-ms/ecosystems-ts";
10
+ const __dirname = dirname(fileURLToPath(import.meta.url));
11
+ const pkg = JSON.parse(await readFile(join(__dirname, "..", "package.json"), "utf8"));
12
+ const databasePath = join(__dirname, "..", "critical-packages.db");
13
+ const gzPath = join(__dirname, "..", "critical-packages.db.gz");
14
+ // Extract via a uniquely named temp file, then rename into place. rename(2) is atomic
15
+ // within a filesystem, so concurrent importers see either no database or a complete
16
+ // one. Streaming straight to databasePath let them interleave and produce a file
17
+ // SQLite rejects with "database disk image is malformed" -- permanently, since the
18
+ // corrupt file then satisfies the existsSync check on every subsequent run.
19
+ async function extractDatabase(source, destination) {
20
+ const tmpPath = `${destination}.${process.pid}-${randomBytes(6).toString("hex")}.tmp`;
21
+ try {
22
+ await pipeline(createReadStream(source), createGunzip(), createWriteStream(tmpPath));
23
+ await rename(tmpPath, destination);
24
+ }
25
+ catch (err) {
26
+ await unlink(tmpPath).catch(() => { });
27
+ // Another importer finished first and holds the destination open; on Windows that
28
+ // makes the rename fail. A complete database is there either way, so this is a win.
29
+ if (!existsSync(destination))
30
+ throw err;
31
+ }
32
+ }
33
+ if (!existsSync(databasePath) && existsSync(gzPath)) {
34
+ await extractDatabase(gzPath, databasePath);
35
+ }
36
+ const CONCURRENCY = 10;
37
+ /** Above this share of failed version fetches the build throws instead of publishing. */
38
+ const MAX_VERSION_FAILURE_RATE = 0.05;
39
+ /**
40
+ * The mailto that moves the build off the anonymous rate-limit tier.
41
+ *
42
+ * Anonymous is 5,000 requests/hour and a full build makes ~9,700, so an unidentified
43
+ * build gets throttled partway through. With a contact address the tier is "polite",
44
+ * 15,000.
45
+ */
46
+ const CONTACT_EMAIL = process.env.ECOSYSTEMS_MAILTO?.trim() || undefined;
47
+ let client;
48
+ /** The SDK client, created once. Owns retries, backoff, 429/Retry-After and pagination. */
49
+ function ecosystemsClient() {
50
+ if (!client) {
51
+ client = createEcosystemsClient({
52
+ userAgent: `${pkg.name}/${pkg.version}`,
53
+ from: CONTACT_EMAIL,
54
+ });
55
+ }
56
+ return client;
57
+ }
58
+ /**
59
+ * The ecosyste.ms registry for a package, or null when there is nothing to query.
60
+ *
61
+ * Routed through the PURL where there is one: the SDK's table is keyed by PURL type, and
62
+ * that is the vocabulary the API sends.
63
+ *
64
+ * `ecosystem` is the fallback for a package with no PURL, translated through
65
+ * `ECOSYSTEM_TO_PURL_TYPE` first -- the two vocabularies disagree for four of the
66
+ * ecosystems here (`rubygems` vs `gem`, `go` vs `golang`, `packagist` vs `composer`,
67
+ * `homebrew` vs `brew`), and an unresolved registry means zero versions fetched with the
68
+ * build still reporting success.
69
+ */
70
+ const ECOSYSTEM_TO_PURL_TYPE = {
71
+ rubygems: "gem",
72
+ go: "golang",
73
+ packagist: "composer",
74
+ homebrew: "brew",
75
+ };
76
+ function registryFor(pkg) {
77
+ if (pkg.purl) {
78
+ try {
79
+ return purlToRegistry(parsePurl(pkg.purl));
80
+ }
81
+ catch {
82
+ // Malformed PURL: fall through to the ecosystem string.
83
+ }
84
+ }
85
+ const ecosystem = pkg.ecosystem?.toLowerCase();
86
+ if (!ecosystem)
87
+ return null;
88
+ const key = ECOSYSTEM_TO_PURL_TYPE[ecosystem] ?? ecosystem;
89
+ // Object.hasOwn, not a bare lookup: "constructor" is a valid key and would otherwise
90
+ // return Object itself -- truthy, typed string, and interpolated into a URL path.
91
+ const registry = Object.hasOwn(PURL_TYPE_TO_REGISTRY, key)
92
+ ? PURL_TYPE_TO_REGISTRY[key]
93
+ : undefined;
94
+ return registry || null;
95
+ }
96
+ async function fetchAllCriticalPackages(onProgress, api = ecosystemsClient()) {
97
+ onProgress?.("Fetching critical packages...");
98
+ const packages = await api.listCriticalPackages();
99
+ return packages;
100
+ }
101
+ /** Version numbers for a package, or `[]` when there is no registry to ask. Throws on
102
+ * transport failure -- the caller decides whether one package's loss is tolerable. */
103
+ async function fetchVersionNumbers(ecosystem, name) {
104
+ const registry = registryFor({ ecosystem });
105
+ if (!registry)
106
+ return [];
107
+ return await ecosystemsClient().getVersionNumbers(registry, name);
108
+ }
109
+ function createDatabase(dbPath) {
110
+ const db = new DatabaseSync(dbPath);
111
+ db.exec("PRAGMA journal_mode = WAL");
112
+ db.exec(`
113
+ CREATE TABLE packages (
114
+ id INTEGER PRIMARY KEY,
115
+ ecosystem TEXT NOT NULL,
116
+ name TEXT NOT NULL,
117
+ purl TEXT,
118
+ namespace TEXT,
119
+ description TEXT,
120
+ homepage TEXT,
121
+ repository_url TEXT,
122
+ licenses TEXT,
123
+ normalized_licenses TEXT,
124
+ latest_version TEXT,
125
+ versions_count INTEGER,
126
+ downloads INTEGER,
127
+ downloads_period TEXT,
128
+ dependent_packages_count INTEGER,
129
+ dependent_repos_count INTEGER,
130
+ first_release_at TEXT,
131
+ latest_release_at TEXT,
132
+ last_synced_at TEXT,
133
+ keywords TEXT,
134
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP
135
+ );
136
+
137
+ CREATE UNIQUE INDEX idx_packages_ecosystem_name ON packages(ecosystem, name);
138
+ CREATE INDEX idx_packages_purl ON packages(purl);
139
+ CREATE INDEX idx_packages_licenses ON packages(licenses);
140
+ CREATE INDEX idx_packages_ecosystem ON packages(ecosystem);
141
+
142
+ CREATE TABLE versions (
143
+ package_id INTEGER NOT NULL,
144
+ number TEXT NOT NULL,
145
+ PRIMARY KEY (package_id, number),
146
+ FOREIGN KEY (package_id) REFERENCES packages(id)
147
+ );
148
+
149
+ CREATE TABLE advisories (
150
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
151
+ package_id INTEGER NOT NULL,
152
+ uuid TEXT NOT NULL,
153
+ url TEXT,
154
+ title TEXT,
155
+ description TEXT,
156
+ severity TEXT,
157
+ published_at TEXT,
158
+ cvss_score REAL,
159
+ FOREIGN KEY (package_id) REFERENCES packages(id)
160
+ );
161
+
162
+ CREATE INDEX idx_advisories_package_id ON advisories(package_id);
163
+ CREATE INDEX idx_advisories_uuid ON advisories(uuid);
164
+ CREATE INDEX idx_advisories_severity ON advisories(severity);
165
+ CREATE UNIQUE INDEX idx_advisories_package_uuid ON advisories(package_id, uuid);
166
+
167
+ CREATE TABLE repo_metadata (
168
+ package_id INTEGER PRIMARY KEY,
169
+ owner TEXT,
170
+ repo_name TEXT,
171
+ full_name TEXT,
172
+ host TEXT,
173
+ language TEXT,
174
+ stargazers_count INTEGER,
175
+ forks_count INTEGER,
176
+ open_issues_count INTEGER,
177
+ archived INTEGER,
178
+ fork INTEGER,
179
+ FOREIGN KEY (package_id) REFERENCES packages(id)
180
+ );
181
+
182
+ CREATE INDEX idx_repo_full_name ON repo_metadata(full_name);
183
+ CREATE INDEX idx_repo_owner ON repo_metadata(owner);
184
+
185
+ CREATE VIRTUAL TABLE packages_fts USING fts5(
186
+ ecosystem,
187
+ name,
188
+ description,
189
+ keywords,
190
+ content=packages,
191
+ content_rowid=id
192
+ );
193
+
194
+ CREATE TRIGGER packages_ai AFTER INSERT ON packages BEGIN
195
+ INSERT INTO packages_fts(rowid, ecosystem, name, description, keywords)
196
+ VALUES (new.id, new.ecosystem, new.name, new.description, new.keywords);
197
+ END;
198
+
199
+ CREATE TRIGGER packages_ad AFTER DELETE ON packages BEGIN
200
+ INSERT INTO packages_fts(packages_fts, rowid, ecosystem, name, description, keywords)
201
+ VALUES ('delete', old.id, old.ecosystem, old.name, old.description, old.keywords);
202
+ END;
203
+
204
+ CREATE TRIGGER packages_au AFTER UPDATE ON packages BEGIN
205
+ INSERT INTO packages_fts(packages_fts, rowid, ecosystem, name, description, keywords)
206
+ VALUES ('delete', old.id, old.ecosystem, old.name, old.description, old.keywords);
207
+ INSERT INTO packages_fts(rowid, ecosystem, name, description, keywords)
208
+ VALUES (new.id, new.ecosystem, new.name, new.description, new.keywords);
209
+ END;
210
+
211
+ CREATE TABLE build_info (
212
+ id INTEGER PRIMARY KEY CHECK (id = 1),
213
+ built_at TEXT NOT NULL,
214
+ package_count INTEGER,
215
+ version_count INTEGER,
216
+ advisory_count INTEGER
217
+ );
218
+ `);
219
+ return db;
220
+ }
221
+ function insertPackage(db, pkg) {
222
+ const stmt = db.prepare(`
223
+ INSERT OR REPLACE INTO packages (
224
+ id, ecosystem, name, purl, namespace, description, homepage,
225
+ repository_url, licenses, normalized_licenses, latest_version,
226
+ versions_count, downloads, downloads_period, dependent_packages_count,
227
+ dependent_repos_count, first_release_at, latest_release_at,
228
+ last_synced_at, keywords
229
+ ) VALUES (
230
+ ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
231
+ )
232
+ `);
233
+ const keywords = Array.isArray(pkg.keywords_array) ? pkg.keywords_array.join(" ") : null;
234
+ // Not JSON.stringify alone: it returns undefined for undefined, which node:sqlite
235
+ // refuses to bind, and the literal string "null" for null.
236
+ const normalizedLicenses = Array.isArray(pkg.normalized_licenses)
237
+ ? JSON.stringify(pkg.normalized_licenses)
238
+ : null;
239
+ stmt.run(pkg.id, pkg.ecosystem, pkg.name, pkg.purl ?? null, pkg.namespace ?? null, pkg.description ?? null, pkg.homepage ?? null, pkg.repository_url ?? null, pkg.licenses ?? null, normalizedLicenses, pkg.latest_release_number ?? null, pkg.versions_count ?? null, pkg.downloads ?? null, pkg.downloads_period ?? null, pkg.dependent_packages_count ?? null, pkg.dependent_repos_count ?? null, pkg.first_release_published_at ?? null, pkg.latest_release_published_at ?? null, pkg.last_synced_at ?? null, keywords);
240
+ return pkg.id;
241
+ }
242
+ function insertRepoMetadata(db, packageId, repoMetadata, host) {
243
+ if (!repoMetadata)
244
+ return;
245
+ // The explicit argument stays for callers that have a host from somewhere else.
246
+ const hostName = host?.name ?? repoMetadata.host?.name ?? null;
247
+ const repoName = repoMetadata.name ?? repoMetadata.full_name?.split("/").pop() ?? null;
248
+ const stmt = db.prepare(`
249
+ INSERT OR REPLACE INTO repo_metadata (
250
+ package_id, owner, repo_name, full_name, host, language,
251
+ stargazers_count, forks_count, open_issues_count, archived, fork
252
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
253
+ `);
254
+ stmt.run(packageId, repoMetadata.owner ?? null, repoName, repoMetadata.full_name ?? null, hostName, repoMetadata.language ?? null, repoMetadata.stargazers_count ?? null, repoMetadata.forks_count ?? null, repoMetadata.open_issues_count ?? null, repoMetadata.archived ? 1 : 0, repoMetadata.fork ? 1 : 0);
255
+ }
256
+ function insertAdvisories(db, packageId, advisories) {
257
+ if (!advisories || advisories.length === 0)
258
+ return;
259
+ const stmt = db.prepare(`
260
+ INSERT OR REPLACE INTO advisories (
261
+ package_id, uuid, url, title, description, severity, published_at, cvss_score
262
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
263
+ `);
264
+ for (const advisory of advisories) {
265
+ if (!advisory?.uuid)
266
+ continue;
267
+ stmt.run(packageId, advisory.uuid, advisory.url ?? null, advisory.title ?? null, advisory.description ?? null, advisory.severity ?? null, advisory.published_at ?? null, advisory.cvss_score ?? null);
268
+ }
269
+ }
270
+ function insertVersions(db, packageId, versionNumbers) {
271
+ if (!versionNumbers || versionNumbers.length === 0)
272
+ return;
273
+ const stmt = db.prepare(`
274
+ INSERT OR REPLACE INTO versions (package_id, number) VALUES (?, ?)
275
+ `);
276
+ for (const number of versionNumbers) {
277
+ stmt.run(packageId, number);
278
+ }
279
+ }
280
+ /** `SELECT COUNT(*)`, narrowed: node:sqlite hands back `Record<string, SQLOutputValue>`. */
281
+ function countRows(db, table) {
282
+ const row = db.prepare(`SELECT COUNT(*) as count FROM ${table}`).get();
283
+ return Number(row?.count ?? 0);
284
+ }
285
+ function updateBuildInfo(db) {
286
+ db.prepare(`
287
+ INSERT OR REPLACE INTO build_info (id, built_at, package_count, version_count, advisory_count)
288
+ VALUES (1, ?, ?, ?, ?)
289
+ `).run(new Date().toISOString(), countRows(db, "packages"), countRows(db, "versions"), countRows(db, "advisories"));
290
+ }
291
+ async function build(options = {}) {
292
+ const { dbPath = "critical-packages.db", fetchVersionsData = true, onProgress = console.log, client: api = ecosystemsClient(), } = options;
293
+ await mkdir(dirname(dbPath) || ".", { recursive: true }).catch(() => { });
294
+ await unlink(dbPath).catch(() => { });
295
+ await unlink(`${dbPath}-wal`).catch(() => { });
296
+ await unlink(`${dbPath}-shm`).catch(() => { });
297
+ onProgress("Creating database...");
298
+ const db = createDatabase(dbPath);
299
+ // Otherwise every throw below leaks the handle, which on Windows locks the file the
300
+ // caller is about to clean up. The version-failure threshold makes that a reachable
301
+ // path, not just a corrupt-data one.
302
+ try {
303
+ onProgress("Fetching critical packages...");
304
+ const packages = await fetchAllCriticalPackages(onProgress, api);
305
+ onProgress(`Found ${packages.length} critical packages`);
306
+ onProgress("Inserting packages...");
307
+ db.exec("BEGIN");
308
+ try {
309
+ for (const pkg of packages) {
310
+ insertPackage(db, pkg);
311
+ insertRepoMetadata(db, pkg.id, pkg.repo_metadata);
312
+ insertAdvisories(db, pkg.id, pkg.advisories);
313
+ }
314
+ db.exec("COMMIT");
315
+ }
316
+ catch (err) {
317
+ db.exec("ROLLBACK");
318
+ throw err;
319
+ }
320
+ if (fetchVersionsData) {
321
+ onProgress("Fetching versions...");
322
+ let completed = 0;
323
+ const total = packages.length;
324
+ // One package failing is tolerable; a pattern of failures means the run was
325
+ // throttled. Count them and report at the end rather than swallowing each one.
326
+ let failed = 0;
327
+ let firstError;
328
+ const processPackage = async (pkg) => {
329
+ const registry = registryFor(pkg);
330
+ if (!registry)
331
+ return { pkg, versions: [] };
332
+ try {
333
+ const versions = await api.getVersionNumbers(registry, pkg.name);
334
+ return { pkg, versions };
335
+ }
336
+ catch (err) {
337
+ failed++;
338
+ firstError ??= err;
339
+ return { pkg, versions: [] };
340
+ }
341
+ };
342
+ // Process in batches with concurrency limit
343
+ for (let i = 0; i < packages.length; i += CONCURRENCY) {
344
+ const batch = packages.slice(i, i + CONCURRENCY);
345
+ const results = await Promise.all(batch.map(processPackage));
346
+ db.exec("BEGIN");
347
+ try {
348
+ for (const { pkg, versions } of results) {
349
+ if (versions.length > 0) {
350
+ insertVersions(db, pkg.id, versions);
351
+ }
352
+ }
353
+ db.exec("COMMIT");
354
+ }
355
+ catch (err) {
356
+ db.exec("ROLLBACK");
357
+ throw err;
358
+ }
359
+ completed += batch.length;
360
+ onProgress(`Fetched versions for ${completed}/${total} packages`);
361
+ }
362
+ if (failed > 0) {
363
+ const share = ((failed / total) * 100).toFixed(1);
364
+ const reason = firstError instanceof Error ? firstError.message : String(firstError);
365
+ onProgress(`WARNING: version fetch failed for ${failed}/${total} packages (${share}%)`);
366
+ onProgress(` first failure: ${reason}`);
367
+ // A run that loses this much is not a build worth publishing.
368
+ if (failed / total > MAX_VERSION_FAILURE_RATE) {
369
+ throw new Error(`version fetch failed for ${share}% of packages (limit ${(MAX_VERSION_FAILURE_RATE * 100).toFixed(0)}%); first failure: ${reason}`);
370
+ }
371
+ }
372
+ }
373
+ updateBuildInfo(db);
374
+ const info = db
375
+ .prepare("SELECT * FROM build_info WHERE id = 1")
376
+ .get();
377
+ onProgress(`Build complete: ${info.package_count} packages, ${info.version_count} versions, ${info.advisory_count} advisories`);
378
+ return info;
379
+ }
380
+ finally {
381
+ // The success path returns through here too, and close() is not idempotent.
382
+ db.close();
383
+ }
384
+ }
385
+ export { build, createDatabase, databasePath, extractDatabase, fetchAllCriticalPackages, fetchVersionNumbers, insertAdvisories, insertPackage, insertRepoMetadata, insertVersions, registryFor, };
386
+ //# sourceMappingURL=index.js.map