fix(core): sanitize NUL (u0000) characters in SQLite-to-PostgreSQL migration
Legacy SQLite databases can hold U+0000 in TEXT cells and inside stored JSON, which PostgreSQL rejects in text and jsonb columns and which aborted the first-boot auto-migration. Strip NUL from plain text cells, JSON string values and object keys, malformed-JSON scalars, and opaque legacy-preservation cells; content-checksum verification compares the sanitized source against the sanitized target so migrations still verify. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
7
.changeset/sqlite-migration-nul-sanitize.md
Normal file
7
.changeset/sqlite-migration-nul-sanitize.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Fix first-boot SQLite→PostgreSQL migration failing on legacy data containing NUL (\u0000) characters.
|
||||||
|
category: fix
|
||||||
|
dev: The migrator now strips U+0000 from plain text cells, JSON string values/keys, malformed-JSON scalars, and opaque legacy-preservation cells before insert; content-checksum verification compares sanitized source against sanitized target.
|
||||||
@@ -733,6 +733,101 @@ pgDescribe("SQLite-to-PostgreSQL migrator", () => {
|
|||||||
expect(rows[0].source_schema_sql).toContain("CREATE TABLE mission_feature_evidence_links");
|
expect(rows[0].source_schema_sql).toContain("CREATE TABLE mission_feature_evidence_links");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:PostgresMigrationNulSanitize 2026-07-17-10:05:
|
||||||
|
Legacy SQLite data can contain U+0000 in TEXT cells and inside stored JSON documents. PostgreSQL rejects NUL in text and jsonb columns ("unsupported Unicode escape sequence" / "\u0000 cannot be converted to text"), which aborted the first-boot auto-migration. The migrator must strip NUL from plain text cells, JSON string values and object keys, malformed-JSON scalars, and opaque legacy-preservation cells — and the content-checksum verification must still pass on the sanitized rows.
|
||||||
|
*/
|
||||||
|
it("strips U+0000 from legacy text and JSON values so the migration succeeds and verifies", async () => {
|
||||||
|
const sqlitePath = join(ctx!.fusionDir, "fusion.db");
|
||||||
|
const legacy = new DatabaseSync(sqlitePath);
|
||||||
|
try {
|
||||||
|
const insert = legacy.prepare(
|
||||||
|
`INSERT INTO tasks (id, title, description, "column", customFields, createdAt, updatedAt)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
);
|
||||||
|
insert.run(
|
||||||
|
"FN-NUL-1",
|
||||||
|
"nul\u0000title",
|
||||||
|
"desc\u0000ription",
|
||||||
|
"todo",
|
||||||
|
JSON.stringify({ ["key\u0000nul"]: "value\u0000nul", nested: ["a\u0000b"] }),
|
||||||
|
"2026-07-17T00:00:00Z",
|
||||||
|
"2026-07-17T00:00:00Z",
|
||||||
|
);
|
||||||
|
insert.run(
|
||||||
|
"FN-NUL-2",
|
||||||
|
"clean title",
|
||||||
|
"malformed json cell",
|
||||||
|
"todo",
|
||||||
|
"not-json\u0000tail",
|
||||||
|
"2026-07-17T00:00:00Z",
|
||||||
|
"2026-07-17T00:00:00Z",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
legacy.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
const report = await migrateTest(
|
||||||
|
ctx!.db,
|
||||||
|
[{ sqlitePath, pgSchema: "project" as const }],
|
||||||
|
);
|
||||||
|
const tasks = report.tables.find((table) => table.table === "tasks")!;
|
||||||
|
expect(tasks.verified).toBe(true);
|
||||||
|
|
||||||
|
const rows = await ctx!.db.execute(sql`
|
||||||
|
SELECT id, title, description, custom_fields
|
||||||
|
FROM project.tasks
|
||||||
|
WHERE id IN ('FN-NUL-1', 'FN-NUL-2')
|
||||||
|
ORDER BY id
|
||||||
|
`) as unknown as Array<{
|
||||||
|
id: string;
|
||||||
|
title: string | null;
|
||||||
|
description: string;
|
||||||
|
custom_fields: unknown;
|
||||||
|
}>;
|
||||||
|
expect(rows).toHaveLength(2);
|
||||||
|
expect(rows[0].title).toBe("nultitle");
|
||||||
|
expect(rows[0].description).toBe("description");
|
||||||
|
expect(rows[0].custom_fields).toEqual({ keynul: "valuenul", nested: ["ab"] });
|
||||||
|
// Malformed JSON is preserved as a jsonb string scalar, minus the NUL.
|
||||||
|
expect(rows[1].custom_fields).toBe("not-jsontail");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips U+0000 from opaque legacy-preservation text cells", async () => {
|
||||||
|
const sqlitePath = join(ctx!.fusionDir, "mission-nul.db");
|
||||||
|
const legacy = new DatabaseSync(sqlitePath);
|
||||||
|
try {
|
||||||
|
legacy.exec(`CREATE TABLE mission_feature_evidence_links (id TEXT, featureId TEXT)`);
|
||||||
|
legacy.prepare(`INSERT INTO mission_feature_evidence_links VALUES (?, ?)`)
|
||||||
|
.run("nul\u0000id", "feat\u0000ure");
|
||||||
|
} finally {
|
||||||
|
legacy.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
const report = await migrateTest(
|
||||||
|
ctx!.db,
|
||||||
|
[{ sqlitePath, pgSchema: "project" as const }],
|
||||||
|
{ projectId: "project-nul" },
|
||||||
|
);
|
||||||
|
expect(report.tables).toContainEqual(expect.objectContaining({
|
||||||
|
table: "mission_feature_evidence_links",
|
||||||
|
sourceRows: 1,
|
||||||
|
verified: true,
|
||||||
|
skipped: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const rows = await ctx!.db.execute(sql`
|
||||||
|
SELECT legacy_row FROM project.mission_feature_evidence_links
|
||||||
|
WHERE project_id = 'project-nul'
|
||||||
|
`) as unknown as Array<{ legacy_row: Record<string, unknown> }>;
|
||||||
|
expect(rows).toEqual([{
|
||||||
|
legacy_row: {
|
||||||
|
featureId: { type: "text", value: "feature" },
|
||||||
|
id: { type: "text", value: "nulid" },
|
||||||
|
},
|
||||||
|
}]);
|
||||||
|
});
|
||||||
|
|
||||||
it("requires a project identity before preserving opaque project rows", async () => {
|
it("requires a project identity before preserving opaque project rows", async () => {
|
||||||
const sqlitePath = join(ctx!.fusionDir, "mission-unbound.db");
|
const sqlitePath = join(ctx!.fusionDir, "mission-unbound.db");
|
||||||
const legacy = new DatabaseSync(sqlitePath);
|
const legacy = new DatabaseSync(sqlitePath);
|
||||||
|
|||||||
@@ -1212,6 +1212,35 @@ function classifyColumnType(pgCol: {
|
|||||||
return "plain";
|
return "plain";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:PostgresMigrationNulSanitize 2026-07-17-10:05:
|
||||||
|
PostgreSQL rejects U+0000 in text/varchar ("invalid byte sequence" / "\u0000 cannot be converted to text") and in json/jsonb ("unsupported Unicode escape sequence"), but SQLite TEXT stores it freely, so legacy databases can contain NUL bytes that abort the first-boot auto-migration. Strip U+0000 from every migrated string — plain text cells, string values and object keys inside JSON documents, and opaque legacy-preservation text cells — rather than failing the cutover. Sanitization happens inside convertValue/tagLegacyCell so the content-checksum verification (computeSourceCanonicalRows reuses convertValue) compares the sanitized source against the sanitized target and still passes.
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line no-control-regex -- matching the NUL control character is the point
|
||||||
|
const NUL_CHAR_RE = /\u0000/g;
|
||||||
|
|
||||||
|
function stripNulChars(text: string): string {
|
||||||
|
return text.includes("\u0000") ? text.replace(NUL_CHAR_RE, "") : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Recursively strip U+0000 from all string values and object keys in a parsed JSON document. */
|
||||||
|
function deepStripNulChars(value: unknown): unknown {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return stripNulChars(value);
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map(deepStripNulChars);
|
||||||
|
}
|
||||||
|
if (value !== null && typeof value === "object") {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(value as Record<string, unknown>).map(
|
||||||
|
([key, entry]) => [stripNulChars(key), deepStripNulChars(entry)],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert a SQLite value to its PostgreSQL representation based on the column
|
* Convert a SQLite value to its PostgreSQL representation based on the column
|
||||||
* type classification.
|
* type classification.
|
||||||
@@ -1255,14 +1284,14 @@ function convertValue(
|
|||||||
return nullJsonbFallback ?? (preserveEmptyJsonbString ? JSON.stringify(value) : null);
|
return nullJsonbFallback ?? (preserveEmptyJsonbString ? JSON.stringify(value) : null);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
return JSON.stringify(JSON.parse(trimmed));
|
return JSON.stringify(deepStripNulChars(JSON.parse(trimmed)));
|
||||||
} catch {
|
} catch {
|
||||||
// Malformed JSON — store as a JSON-encoded string scalar (valid jsonb).
|
// Malformed JSON — store as a JSON-encoded string scalar (valid jsonb).
|
||||||
return JSON.stringify(value);
|
return JSON.stringify(stripNulChars(value));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Already a JS value (object/array/number/boolean) — stringify it.
|
// Already a JS value (object/array/number/boolean) — stringify it.
|
||||||
return JSON.stringify(value);
|
return JSON.stringify(deepStripNulChars(value));
|
||||||
}
|
}
|
||||||
case "bytea": {
|
case "bytea": {
|
||||||
if (Buffer.isBuffer(value)) {
|
if (Buffer.isBuffer(value)) {
|
||||||
@@ -1280,7 +1309,8 @@ function convertValue(
|
|||||||
case "identity":
|
case "identity":
|
||||||
case "generated":
|
case "generated":
|
||||||
default:
|
default:
|
||||||
return value;
|
// PostgreSQL text/varchar rejects U+0000 outright; see NUL-sanitize note above.
|
||||||
|
return typeof value === "string" ? stripNulChars(value) : value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1330,7 +1360,9 @@ function tagLegacyCell(value: unknown): TaggedLegacyCell {
|
|||||||
if (typeof value === "number" || typeof value === "bigint") {
|
if (typeof value === "number" || typeof value === "bigint") {
|
||||||
return { type: "number", value: Object.is(value, -0) ? "-0" : String(value) };
|
return { type: "number", value: Object.is(value, -0) ? "-0" : String(value) };
|
||||||
}
|
}
|
||||||
return { type: "text", value: String(value) };
|
// Legacy-preservation rows are stored as jsonb; jsonb rejects \u0000, so
|
||||||
|
// opaque text cells get the same NUL sanitization as regular columns.
|
||||||
|
return { type: "text", value: stripNulChars(String(value)) };
|
||||||
}
|
}
|
||||||
|
|
||||||
function canonicalizeLegacyRows(
|
function canonicalizeLegacyRows(
|
||||||
|
|||||||
Reference in New Issue
Block a user