11import { createReadStream } from 'node:fs'
22import * as path from 'node:path'
3+ import { createInterface } from 'node:readline'
34import { Transform } from 'node:stream'
45import { pipeline } from 'node:stream/promises'
56import { CopyStreamQuery , from as copyFrom } from 'pg-copy-streams'
@@ -14,55 +15,59 @@ const log = getServiceChildLogger('cargo-load')
1415
1516export const STAGING_SCHEMA = 'cargo_sync'
1617
17- // Column order matches each CSV header exactly so COPY FROM STDIN CSV HEADER maps by position.
18- const STAGING_DDL = `
19- DROP TABLE IF EXISTS ${ STAGING_SCHEMA } .crates CASCADE;
20- CREATE TABLE ${ STAGING_SCHEMA } .crates (
21- created_at text, description text, documentation text, homepage text,
22- id integer, max_features text, max_upload_size text, name text,
23- readme text, repository text, trustpub_only text, updated_at text
24- );
25- DROP TABLE IF EXISTS ${ STAGING_SCHEMA } .versions CASCADE;
26- CREATE TABLE ${ STAGING_SCHEMA } .versions (
27- bin_names text, categories text, checksum text, crate_id integer,
28- crate_size text, created_at text, description text, documentation text,
29- downloads text, edition text, features text, has_lib text, homepage text,
30- id integer, keywords text, license text, links text, num text,
31- num_no_build text, published_by text, repository text, rust_version text,
32- updated_at text, yanked text
33- );
34- DROP TABLE IF EXISTS ${ STAGING_SCHEMA } .default_versions CASCADE;
35- CREATE TABLE ${ STAGING_SCHEMA } .default_versions (
36- crate_id integer, num_versions integer, version_id integer
37- );
38- DROP TABLE IF EXISTS ${ STAGING_SCHEMA } .version_downloads CASCADE;
39- CREATE TABLE ${ STAGING_SCHEMA } .version_downloads (
40- date date, downloads integer, version_id integer
41- );
42- DROP TABLE IF EXISTS ${ STAGING_SCHEMA } .dependencies CASCADE;
43- CREATE TABLE ${ STAGING_SCHEMA } .dependencies (
44- crate_id integer, default_features text, explicit_name text, features text,
45- id integer, kind integer, optional text, req text, target text,
46- version_id integer
47- );
48- DROP TABLE IF EXISTS ${ STAGING_SCHEMA } .keywords CASCADE;
49- CREATE TABLE ${ STAGING_SCHEMA } .keywords (
50- crates_cnt integer, created_at text, id integer, keyword text
51- );
52- DROP TABLE IF EXISTS ${ STAGING_SCHEMA } .crates_keywords CASCADE;
53- CREATE TABLE ${ STAGING_SCHEMA } .crates_keywords (
54- crate_id integer, keyword_id integer
55- );
56- DROP TABLE IF EXISTS ${ STAGING_SCHEMA } .crate_owners CASCADE;
57- CREATE TABLE ${ STAGING_SCHEMA } .crate_owners (
58- crate_id integer, created_at text, created_by text, owner_id integer,
59- owner_kind integer
60- );
61- DROP TABLE IF EXISTS ${ STAGING_SCHEMA } .oauth_github CASCADE;
62- CREATE TABLE ${ STAGING_SCHEMA } .oauth_github (
63- account_id text, avatar text, login text, user_id integer
64- );
65- `
18+ const COLUMN_TYPES : Record < string , Record < string , string > > = {
19+ crates : { id : 'integer' } ,
20+ versions : { crate_id : 'integer' , id : 'integer' } ,
21+ default_versions : { crate_id : 'integer' , num_versions : 'integer' , version_id : 'integer' } ,
22+ version_downloads : { date : 'date' , downloads : 'integer' , version_id : 'integer' } ,
23+ dependencies : { crate_id : 'integer' , id : 'integer' , kind : 'integer' , version_id : 'integer' } ,
24+ keywords : { crates_cnt : 'integer' , id : 'integer' } ,
25+ crates_keywords : { crate_id : 'integer' , keyword_id : 'integer' } ,
26+ crate_owners : { crate_id : 'integer' , owner_id : 'integer' , owner_kind : 'integer' } ,
27+ oauth_github : { user_id : 'integer' } ,
28+ }
29+
30+ const REQUIRED_COLUMNS : Record < string , string [ ] > = {
31+ crates : [ 'id' , 'name' , 'description' , 'homepage' , 'repository' ] ,
32+ versions : [ 'id' , 'crate_id' , 'created_at' , 'num' , 'license' , 'yanked' ] ,
33+ default_versions : [ 'crate_id' , 'version_id' ] ,
34+ version_downloads : [ 'date' , 'downloads' , 'version_id' ] ,
35+ dependencies : [ 'crate_id' , 'version_id' , 'kind' ] ,
36+ keywords : [ 'id' , 'keyword' ] ,
37+ crates_keywords : [ 'crate_id' , 'keyword_id' ] ,
38+ crate_owners : [ 'crate_id' , 'owner_id' , 'owner_kind' ] ,
39+ oauth_github : [ 'user_id' , 'login' ] ,
40+ }
41+
42+ async function readCsvHeader ( csvPath : string ) : Promise < string [ ] > {
43+ const source = createReadStream ( csvPath )
44+ const rl = createInterface ( { input : source , crlfDelay : Infinity } )
45+ try {
46+ for await ( const line of rl ) {
47+ return line . split ( ',' ) . map ( ( c ) => c . trim ( ) )
48+ }
49+ } finally {
50+ rl . close ( )
51+ source . destroy ( )
52+ }
53+ throw new Error ( `CSV has no header line: ${ csvPath } ` )
54+ }
55+
56+ async function buildStagingTable ( qx : QueryExecutor , table : string , csvPath : string ) : Promise < void > {
57+ const cols = await readCsvHeader ( csvPath )
58+ const missing = ( REQUIRED_COLUMNS [ table ] ?? [ ] ) . filter ( ( c ) => ! cols . includes ( c ) )
59+ if ( missing . length ) {
60+ throw new Error (
61+ `cargo dump ${ table } .csv is missing required column(s) [${ missing . join ( ', ' ) } ] — ` +
62+ `crates.io schema changed; header was [${ cols . join ( ', ' ) } ]` ,
63+ )
64+ }
65+ const types = COLUMN_TYPES [ table ] ?? { }
66+ const ddl = cols . map ( ( c ) => `"${ c . replace ( / " / g, '""' ) } " ${ types [ c ] ?? 'text' } ` ) . join ( ', ' )
67+ await qx . result (
68+ `DROP TABLE IF EXISTS ${ STAGING_SCHEMA } .${ table } CASCADE; CREATE TABLE ${ STAGING_SCHEMA } .${ table } (${ ddl } )` ,
69+ )
70+ }
6671
6772// crates.csv needs NUL stripping — readme blobs contain 0x00 bytes that COPY rejects.
6873const CSV_FILES : Array < { table : string ; file : string ; stripNul ?: boolean } > = [
@@ -265,14 +270,13 @@ export async function loadDump(
265270 const dataDir = path . join ( dumpDir , 'data' )
266271
267272 log . info ( 'Creating staging schema...' )
268- await qx . result (
269- `CREATE SCHEMA IF NOT EXISTS ${ STAGING_SCHEMA } ;
270- ${ STAGING_DDL } ` ,
271- )
273+ await qx . result ( `CREATE SCHEMA IF NOT EXISTS ${ STAGING_SCHEMA } ` )
272274
273275 const counts : Record < string , number > = { }
274276 for ( const { table, file, stripNul : doStripNul } of CSV_FILES ) {
275- counts [ table ] = await copyCsv ( db , table , path . join ( dataDir , file ) , doStripNul ?? false )
277+ const csvPath = path . join ( dataDir , file )
278+ await buildStagingTable ( qx , table , csvPath )
279+ counts [ table ] = await copyCsv ( db , table , csvPath , doStripNul ?? false )
276280 log . info ( { table, rows : counts [ table ] } , 'Loaded staging table' )
277281 }
278282
0 commit comments