Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 13 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ npx cdb-converter --version
| ------------------- | -------------------------------------------------------------------------------------------------- |
| `-n`, `--normalize` | (CDB → SQLite only) reconstruct PK/FK constraints from PCM naming conventions. See [Normalized schema](#normalized-schema). |
| `--index-fk` | Implies `--normalize`; also indexes every FK column for faster JOINs (roughly doubles output size). |
| `--precise-types` | (CDB → SQLite only) preserve the exact CDB type (BOOLEAN, INTEGER_BYTE, INTEGER_SHORT) instead of collapsing it to plain INTEGER. See [Compatibility](#compatibility). |

## Library usage

Expand Down Expand Up @@ -203,6 +204,7 @@ Convert CDB binary data into a SQLite database instance.
- **`SQL`** — `SqlJsStatic`, the module returned by `initSqlJs()`.
- **`options.normalize`** — `boolean` (default `false`). Reconstruct PK/FK constraints from PCM naming conventions. See [Normalized schema](#normalized-schema).
- **`options.indexForeignKeys`** — `boolean` (default `false`). When normalizing, also index every FK column for faster JOINs (roughly doubles the output size).
- **`options.preciseTypes`** — `boolean` (default `false`). Preserve the exact CDB type (BOOLEAN, INTEGER_BYTE, INTEGER_SHORT) and each table's flags in the `.sqlite` file instead of the official-tool-compatible defaults. See [How metadata is preserved](#how-metadata-is-preserved).
- **returns** — a `sql.js` `Database` with the CDB tables loaded.

### `sqlToCdb(db): ArrayBuffer`
Expand Down Expand Up @@ -242,18 +244,20 @@ Every CDB data type is preserved during conversion:
The library uses a special `DB_STRUCTURE` table to round-trip CDB metadata that has no native SQLite equivalent:

```sql
CREATE TABLE DB_STRUCTURE (
TableName TEXT '274',
ID INTEGER,
Flags INTEGER
)
-- default (compatible with the official PCM SQLiteExporter tool)
CREATE TABLE DB_STRUCTURE (TableName '274', ID '0')

-- with { preciseTypes: true }
CREATE TABLE DB_STRUCTURE (TableName TEXT '274', ID INTEGER, Flags INTEGER)
```

Each table's flags (their exact meaning is unknown but must be preserved) are stored in the `Flags` column, so they are written into the `.sqlite` file itself and survive an `export()`/reopen cycle. Column indices and data types are encoded into each column's declared type annotation. Together this makes `cdb → sqlite → cdb` lossless even when the SQLite database is saved to disk and reopened in a separate process.
Column indices and data types are encoded into each column's declared type annotation, so `cdb → sqlite → cdb` preserves every row value even when the SQLite database is saved to disk and reopened in a separate process. How much of the *schema* survives depends on the mode: `preciseTypes: true` round-trips the CDB types and table flags exactly, while the default trades some of that fidelity for interop. By default, CDB's narrower integer types (`BOOLEAN`, `INTEGER_BYTE`, `INTEGER_SHORT`) are encoded as plain `INTEGER`, and each table's flags (their exact meaning is unknown but must be preserved) are **not** written to the `.sqlite` file — `sqlToCdb` falls back to a static table of flags extracted from official PCM saves (`TABLE_FLAGS_BY_ID`) instead. Pass `{ preciseTypes: true }` (`--precise-types` on the CLI) to encode the exact CDB type and store each table's real flags in the `Flags` column instead of relying on that fallback.

This default exists specifically for interop: the official PCM `SQLiteExporter` tool only recognizes `FLOAT`, `STRING` and the two list types in this metadata and has no `Flags` column — a `.sqlite` written with `preciseTypes: true` crashes it on import. Leave `preciseTypes` off if you need the output to be re-importable by that tool; turn it on if `cdb-converter` (via `sqlToCdb`) is the only tool that will ever read the file back and you want the extra fidelity.

## Compatibility

The CDB parser is **format-driven, not version-specific**, so it is not tied to a single Pro Cycling Manager release. Lossless round-trip conversion (`cdb → sqlite → cdb`) is tested against the official databases of:
The CDB parser is **format-driven, not version-specific**, so it is not tied to a single Pro Cycling Manager release. Round-trip conversion (`cdb → sqlite → cdb`) is tested against the official databases of — losslessly, including types and flags, with `preciseTypes: true`, and preserving all row data in the default mode:

| Version | Status |
| ------------------------ | --------- |
Expand All @@ -263,6 +267,8 @@ The CDB parser is **format-driven, not version-specific**, so it is not tied to
| Pro Cycling Manager 2021 | ✅ tested |
| Pro Cycling Manager 2025 | ✅ tested |

The default (non-`preciseTypes`) `.sqlite` output is also verified importable by the official PCM `SQLiteExporter` tool (`-import`) on Pro Cycling Manager 2025 saves, round-tripping back through `cdb-converter` with identical data. `SQLiteExporter` itself cannot export the 2014 fixture (it crashes on that file directly, independent of anything produced by this library), so that combination isn't claimed.

## Performance & size

A full `cdb → sqlite → cdb` round-trip on a real ~60k-row database stays well under half a second, and the library's own code adds only **~28 kB** — the SQLite WASM runtime is the real weight, and you would pay for it with any SQLite-in-JS approach.
Expand Down
50 changes: 40 additions & 10 deletions src/cdbToSql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,10 +132,18 @@ export function cdbToSql(
// DB_STRUCTURE mirrors the PCM convention used by sqlToCdb: TableName keeps the
// literal type annotation '274' so the schema matches the metadata table shape
// expected by round-trip consumers, while only the table rows are read back.
// Flags persists each table's TABLE_FLAGS into the SQLite file so it survives an
// export()/reopen round-trip (its meaning is unknown but must be preserved).
//
// The official SQLiteExporter tool declares this table with no SQL type
// keyword at all (`TableName '274',ID '0'`) and no Flags column; matching that
// exactly by default keeps our output importable there (a mismatch here isn't
// just cosmetic — SQLiteExporter crashes on the extra/typed columns). Flags
// persists each table's TABLE_FLAGS so it survives an export()/reopen
// round-trip; it's only added under preciseTypes since sqlToCdb already falls
// back to TABLE_FLAGS_BY_ID when the column is absent.
db.run(
`CREATE TABLE DB_STRUCTURE (TableName TEXT '274', ID INTEGER, Flags INTEGER)`,
options?.preciseTypes
? `CREATE TABLE DB_STRUCTURE (TableName TEXT '274', ID INTEGER, Flags INTEGER)`
: `CREATE TABLE DB_STRUCTURE (TableName '274',ID '0')`,
);

const keyMap = options?.normalize ? inferKeys(tables) : null;
Expand All @@ -159,37 +167,59 @@ export function cdbToSql(
db.run("BEGIN TRANSACTION");

tables.forEach((table) => {
db.run(`INSERT INTO DB_STRUCTURE VALUES (?, ?, ?)`, [
table.name,
table.tableId,
table.tableFlags,
]);
if (options?.preciseTypes) {
db.run(`INSERT INTO DB_STRUCTURE VALUES (?, ?, ?)`, [
table.name,
table.tableId,
table.tableFlags,
]);
} else {
db.run(`INSERT INTO DB_STRUCTURE VALUES (?, ?)`, [
table.name,
table.tableId,
]);
}
const escapedTableName = escapeSqlIdentifier(table.name);

// Keep columns in original file order (do NOT sort)
const columnDefs = table.columns
.map((col) => {
const escapedColumnName = escapeSqlIdentifier(col.name);
let baseType: string;
let encodedType: number;
switch (col.type) {
case DATA_TYPE.FLOAT:
baseType = "REAL";
encodedType = col.type;
break;
case DATA_TYPE.STRING:
case DATA_TYPE.INTEGER_LIST:
case DATA_TYPE.FLOAT_LIST:
baseType = "TEXT";
encodedType = col.type;
break;
case DATA_TYPE.BOOLEAN:
baseType = "NUMERIC";
// `SQLiteExporter` (the official PCM tool) has no case for BOOLEAN,
// INTEGER_BYTE or INTEGER_SHORT: they all fall into its default
// branch and get encoded as plain INTEGER. Match that by default so
// our output stays importable there; preciseTypes opts back into
// preserving the exact CDB type for our own round-trip.
baseType = options?.preciseTypes ? "NUMERIC" : "INTEGER";
encodedType = options?.preciseTypes ? col.type : DATA_TYPE.INTEGER;
break;
case DATA_TYPE.INTEGER_BYTE:
case DATA_TYPE.INTEGER_SHORT:
baseType = "INTEGER";
encodedType = options?.preciseTypes ? col.type : DATA_TYPE.INTEGER;
break;
default:
baseType = "INTEGER";
encodedType = DATA_TYPE.INTEGER;
break;
}

const encodedValue =
(table.tableId * 256 + col.columnIndex) * 16 + (col.type & 0xf);
(table.tableId * 256 + col.columnIndex) * 16 + (encodedType & 0xf);
return `"${escapedColumnName}" '${baseType} ${encodedValue}'`;
})
.join(", ");
Expand Down
25 changes: 24 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export interface ParsedArgs {
output?: string;
normalize?: boolean;
indexForeignKeys?: boolean;
preciseTypes?: boolean;
}

const HELP_TEXT = `cdb-converter — convert Pro Cycling Manager CDB files to/from SQLite
Expand All @@ -45,19 +46,27 @@ Options:
schema. Ignored when converting sqlite -> cdb.
--index-fk (implies --normalize) also index every foreign-key column
for faster JOINs. Roughly doubles the output size.
--precise-types
(cdb -> sqlite only) preserve the exact CDB type (BOOLEAN,
INTEGER_BYTE, INTEGER_SHORT) in the SQLite schema instead
of collapsing them to plain INTEGER. Off by default so the
output stays importable by the official PCM SQLiteExporter
tool, which does not recognize those types.

Examples:
cdb-converter save.cdb
cdb-converter save.cdb save.sqlite
cdb-converter save.cdb save.sqlite --normalize
cdb-converter save.cdb save.sqlite --normalize --index-fk
cdb-converter save.cdb save.sqlite --precise-types
cdb-converter -- --data.cdb (use -- to treat a leading-dash path as a positional argument)
cdb-converter save.sqlite save.cdb`;

export function parseArgs(argv: string[]): ParsedArgs {
const positionals: string[] = [];
let normalize = false;
let indexForeignKeys = false;
let preciseTypes = false;

let optionsEnded = false;

Expand Down Expand Up @@ -86,6 +95,10 @@ export function parseArgs(argv: string[]): ParsedArgs {
indexForeignKeys = true;
continue;
}
if (arg === "--precise-types") {
preciseTypes = true;
continue;
}
if (arg.startsWith("-") && arg !== "-") {
throw new Error(
`Unknown option "${arg}". Run "cdb-converter --help" for usage.`,
Expand All @@ -100,6 +113,7 @@ export function parseArgs(argv: string[]): ParsedArgs {
output: positionals[1],
normalize,
indexForeignKeys,
preciseTypes,
};
}

Expand Down Expand Up @@ -147,6 +161,7 @@ async function convert(
output: string | undefined,
normalize: boolean,
indexForeignKeys: boolean,
preciseTypes: boolean,
): Promise<void> {
const direction = detectDirection(input);
const inputPath = resolve(process.cwd(), input);
Expand All @@ -162,7 +177,11 @@ async function convert(
let summary: string[] = [];

if (direction === "cdb-to-sql") {
const db = cdbToSql(inputBytes, SQL, { normalize, indexForeignKeys });
const db = cdbToSql(inputBytes, SQL, {
normalize,
indexForeignKeys,
preciseTypes,
});

try {
const tables = db.exec(
Expand All @@ -176,6 +195,9 @@ async function convert(
if (normalize && indexForeignKeys) {
summary.push("FK indexes : yes");
}
if (preciseTypes) {
summary.push("Precise types : yes");
}

outputBytes = db.export();
} finally {
Expand Down Expand Up @@ -234,6 +256,7 @@ export async function run(argv: string[]): Promise<void> {
parsed.output,
parsed.normalize ?? false,
parsed.indexForeignKeys ?? false,
parsed.preciseTypes ?? false,
);
} catch (error) {
console.error(`Error: ${error instanceof Error ? error.message : error}`);
Expand Down
15 changes: 15 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,4 +96,19 @@ export interface CdbToSqlOptions {
* intend to run frequent filtered JOINs on the output.
*/
indexForeignKeys?: boolean;

/**
* Encode each column's exact CDB data type (BOOLEAN, INTEGER_BYTE,
* INTEGER_SHORT) into the SQLite schema instead of collapsing them to plain
* INTEGER. Off by default.
*
* The official PCM `SQLiteExporter` tool only recognizes FLOAT, STRING and
* the two list types in this metadata; anything else (including BOOLEAN,
* INTEGER_BYTE and INTEGER_SHORT) is written as plain INTEGER. A `.sqlite`
* produced with `preciseTypes: true` preserves the exact CDB type through
* `sqlToCdb` round-trips, but its schema is not understood by that
* third-party tool and re-importing it there will crash. Leave this off if
* you need the output to be interchangeable with `SQLiteExporter`.
*/
preciseTypes?: boolean;
}
84 changes: 84 additions & 0 deletions test/cdbToSql.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,4 +151,88 @@ describe("cdb/sql conversion surface", () => {

mockReadChunk.mockReset();
});

it("collapses BOOLEAN/INTEGER_BYTE/INTEGER_SHORT to plain INTEGER by default", () => {
const sql = createMockSqlJs();

// DataType: INTEGER=0, FLOAT=1, BOOLEAN=3, INTEGER_BYTE=4, INTEGER_SHORT=5
const tableColumns = [
{ name: "id", columnIndex: 0, type: 0, data: [] },
{ name: "flag", columnIndex: 1, type: 3, data: [] },
{ name: "small", columnIndex: 2, type: 4, data: [] },
{ name: "medium", columnIndex: 3, type: 5, data: [] },
{ name: "ratio", columnIndex: 4, type: 1, data: [] },
];

mockReadChunk.mockReturnValueOnce({
children: {
1: [
{
name: "Narrow",
tableId: 2,
tableFlags: 0,
rowCount: 0,
columns: tableColumns,
},
],
},
});

cdbToSql(new Uint8Array([1, 2, 3]), sql);
const [db] = sql.createdDatabases;

const createStatement = db.sqlOperations.find((op) =>
op.sql.startsWith('CREATE TABLE "Narrow"'),
);

// tableId=2 -> base 8192 (2*4096); +columnIndex*16; nibble collapsed to 0
// for id/flag/small/medium, kept as 1 (FLOAT) for ratio.
expect(createStatement?.sql).toBe(
'CREATE TABLE "Narrow" ("id" \'INTEGER 8192\', "flag" \'INTEGER 8208\', ' +
"\"small\" 'INTEGER 8224', \"medium\" 'INTEGER 8240', \"ratio\" 'REAL 8257')",
);

mockReadChunk.mockReset();
});

it("preserves the exact CDB type with preciseTypes: true", () => {
const sql = createMockSqlJs();

const tableColumns = [
{ name: "id", columnIndex: 0, type: 0, data: [] },
{ name: "flag", columnIndex: 1, type: 3, data: [] },
{ name: "small", columnIndex: 2, type: 4, data: [] },
{ name: "medium", columnIndex: 3, type: 5, data: [] },
{ name: "ratio", columnIndex: 4, type: 1, data: [] },
];

mockReadChunk.mockReturnValueOnce({
children: {
1: [
{
name: "Narrow",
tableId: 2,
tableFlags: 0,
rowCount: 0,
columns: tableColumns,
},
],
},
});

cdbToSql(new Uint8Array([1, 2, 3]), sql, { preciseTypes: true });
const [db] = sql.createdDatabases;

const createStatement = db.sqlOperations.find((op) =>
op.sql.startsWith('CREATE TABLE "Narrow"'),
);

// Same base offsets, but the true nibble (3/4/5) is kept instead of 0.
expect(createStatement?.sql).toBe(
'CREATE TABLE "Narrow" ("id" \'INTEGER 8192\', "flag" \'NUMERIC 8211\', ' +
"\"small\" 'INTEGER 8228', \"medium\" 'INTEGER 8245', \"ratio\" 'REAL 8257')",
);

mockReadChunk.mockReset();
});
});
Loading