From 899ce94827fa4e14f162c3e67c506223e1d9a48b Mon Sep 17 00:00:00 2001 From: Sahil Garg Date: Wed, 15 Jul 2026 22:18:36 +0530 Subject: [PATCH 1/4] fix: fixed the image array to JSON error. --- .../api/src/database/entities/Post.ts | 5 +- .../1784133148233-ReencodePostImages.ts | 87 +++++++++++++++++++ .../client/src/lib/fragments/Post/Post.svelte | 28 +----- .../lib/fragments/PostModal/PostModal.svelte | 28 +----- 4 files changed, 93 insertions(+), 55 deletions(-) create mode 100644 platforms/pictique/api/src/database/migrations/1784133148233-ReencodePostImages.ts diff --git a/platforms/pictique/api/src/database/entities/Post.ts b/platforms/pictique/api/src/database/entities/Post.ts index c9aecc735..aea380ec9 100644 --- a/platforms/pictique/api/src/database/entities/Post.ts +++ b/platforms/pictique/api/src/database/entities/Post.ts @@ -13,7 +13,10 @@ export class Post { @Column("text") text!: string; // was content - @Column("simple-array", { nullable: true }) + // simple-json (JSON.stringify/parse) is used instead of simple-array because + // base64 data URLs contain literal commas, which simple-array's naive + // comma-join/split encoding corrupts on read. + @Column("simple-json", { nullable: true }) images!: string[]; // was mediaUrls @OneToMany(() => Comment, (comment: Comment) => comment.post) diff --git a/platforms/pictique/api/src/database/migrations/1784133148233-ReencodePostImages.ts b/platforms/pictique/api/src/database/migrations/1784133148233-ReencodePostImages.ts new file mode 100644 index 000000000..64776dc33 --- /dev/null +++ b/platforms/pictique/api/src/database/migrations/1784133148233-ReencodePostImages.ts @@ -0,0 +1,87 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Re-encode the `posts.images` column from the legacy `simple-array` format + * (naive comma-join) to `simple-json` (JSON.stringify/parse). + * + * Both column types compile down to a plain `text` column, so there is no + * schema change — only the stored payloads need converting. Without this, rows + * written under the old encoding would throw a JSON.parse error the first time + * the entity is read back after the decorator switches to `simple-json`. + * + * Base64 data URLs (`data:;base64,`) always contain a comma in + * their MIME prefix, so the old comma-join is ambiguous. We recover the + * original array by splitting only on commas that immediately precede a new + * `data:` URL — the `data:` token cannot occur inside a base64 payload (whose + * alphabet excludes `:`), so this boundary is unambiguous for image posts. + * Values that are already valid JSON arrays are left untouched (idempotent). + */ +export class ReencodePostImages1784133148233 implements MigrationInterface { + + public async up(queryRunner: QueryRunner): Promise { + const rows: Array<{ id: string; images: string | null }> = + await queryRunner.query( + `SELECT "id", "images" FROM "posts" WHERE "images" IS NOT NULL AND "images" <> ''`, + ); + + for (const row of rows) { + const raw = row.images; + if (raw === null || raw === "") continue; + + // Already migrated (valid JSON array) — leave as-is. + if (raw.trimStart().startsWith("[")) { + try { + JSON.parse(raw); + continue; + } catch { + // Not actually valid JSON; fall through and re-encode. + } + } + + // Split the legacy comma-joined string back into individual URLs. + // Only break before a new `data:` URL so the comma inside each data + // URL's MIME prefix is preserved. + const parts = raw.includes("data:") + ? raw.split(/,(?=data:)/) + : raw.split(","); + + const images = parts + .map((p) => p.trim()) + .filter((p) => p.length > 0); + + await queryRunner.query( + `UPDATE "posts" SET "images" = $1 WHERE "id" = $2`, + [JSON.stringify(images), row.id], + ); + } + } + + public async down(queryRunner: QueryRunner): Promise { + // Reverse: re-encode JSON arrays back to the legacy comma-joined format. + const rows: Array<{ id: string; images: string | null }> = + await queryRunner.query( + `SELECT "id", "images" FROM "posts" WHERE "images" IS NOT NULL AND "images" <> ''`, + ); + + for (const row of rows) { + const raw = row.images; + if (raw === null || raw === "") continue; + + let images: string[]; + try { + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) continue; + images = parsed; + } catch { + // Not JSON — already in legacy format. + continue; + } + + await queryRunner.query( + `UPDATE "posts" SET "images" = $1 WHERE "id" = $2`, + [images.join(","), row.id], + ); + } + } + +} diff --git a/platforms/pictique/client/src/lib/fragments/Post/Post.svelte b/platforms/pictique/client/src/lib/fragments/Post/Post.svelte index 3d7f6618a..9c276d498 100644 --- a/platforms/pictique/client/src/lib/fragments/Post/Post.svelte +++ b/platforms/pictique/client/src/lib/fragments/Post/Post.svelte @@ -29,36 +29,11 @@ options?: Array<{ name: string; handler: () => void }>; } - function pairAndJoinChunks(chunks: string[]): string[] { - const result: string[] = []; - - console.log('chunks', chunks); - for (let i = 0; i < chunks.length; i += 2) { - const dataPart = chunks[i]; - const chunkPart = chunks[i + 1]; - - if (dataPart && chunkPart) { - if (dataPart.startsWith('data:')) { - result.push(`${dataPart},${chunkPart}`); - } else { - result.push(dataPart); - result.push(chunkPart); - } - } else { - if (!dataPart.startsWith('data:')) result.push(dataPart); - console.warn(`Skipping incomplete pair at index ${i}`); - } - } - console.log('result', result); - - return result; - } - const { avatar, userId, username, - imgUris: uris, + imgUris, text, count, callback, @@ -67,7 +42,6 @@ ...restProps }: IPostProps = $props(); - let imgUris = $derived(pairAndJoinChunks(uris)); let galleryRef: HTMLDivElement | undefined = $state(); let currentIndex = $state(0); diff --git a/platforms/pictique/client/src/lib/fragments/PostModal/PostModal.svelte b/platforms/pictique/client/src/lib/fragments/PostModal/PostModal.svelte index d61c91fcb..961adfea4 100644 --- a/platforms/pictique/client/src/lib/fragments/PostModal/PostModal.svelte +++ b/platforms/pictique/client/src/lib/fragments/PostModal/PostModal.svelte @@ -42,36 +42,11 @@ ownerProfile?: userProfile; } - function pairAndJoinChunks(chunks: string[]): string[] { - const result: string[] = []; - - console.log('chunks', chunks); - for (let i = 0; i < chunks.length; i += 2) { - const dataPart = chunks[i]; - const chunkPart = chunks[i + 1]; - - if (dataPart && chunkPart) { - if (dataPart.startsWith('data:')) { - result.push(`${dataPart},${chunkPart}`); - } else { - result.push(dataPart); - result.push(chunkPart); - } - } else { - if (!dataPart.startsWith('data:')) result.push(dataPart); - console.warn(`Skipping incomplete pair at index ${i}`); - } - } - console.log('result', result); - - return result; - } - const { avatar, userId, username, - imgUris: uris, + imgUris, text, count, callback, @@ -82,7 +57,6 @@ ...restProps }: IPostProps = $props(); - let imgUris = $derived(pairAndJoinChunks(uris)); let galleryRef: HTMLDivElement | undefined = $state(); let currentIndex = $state(0); let commentValue = $state(''); From cfd794204614733de26adea2252e6c268e5752ec Mon Sep 17 00:00:00 2001 From: Sahil Garg Date: Thu, 16 Jul 2026 21:52:01 +0530 Subject: [PATCH 2/4] fix: fixed the image array to JSON error. --- .../migrations/1784133148233-ReencodePostImages.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/platforms/pictique/api/src/database/migrations/1784133148233-ReencodePostImages.ts b/platforms/pictique/api/src/database/migrations/1784133148233-ReencodePostImages.ts index 64776dc33..dbf7f7218 100644 --- a/platforms/pictique/api/src/database/migrations/1784133148233-ReencodePostImages.ts +++ b/platforms/pictique/api/src/database/migrations/1784133148233-ReencodePostImages.ts @@ -21,12 +21,22 @@ export class ReencodePostImages1784133148233 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { const rows: Array<{ id: string; images: string | null }> = await queryRunner.query( - `SELECT "id", "images" FROM "posts" WHERE "images" IS NOT NULL AND "images" <> ''`, + `SELECT "id", "images" FROM "posts" WHERE "images" IS NOT NULL`, ); for (const row of rows) { const raw = row.images; - if (raw === null || raw === "") continue; + if (typeof raw !== "string") continue; + + // Empty string is how simple-array encoded an empty array. Left as + // "", simple-json would choke on JSON.parse("") — convert to "[]". + if (raw === "") { + await queryRunner.query( + `UPDATE "posts" SET "images" = $1 WHERE "id" = $2`, + ["[]", row.id], + ); + continue; + } // Already migrated (valid JSON array) — leave as-is. if (raw.trimStart().startsWith("[")) { From e9fbaf657b7be50b252fe668725d6a2f08ce0917 Mon Sep 17 00:00:00 2001 From: Sahil Garg Date: Thu, 16 Jul 2026 21:54:09 +0530 Subject: [PATCH 3/4] fix: made it more robust. --- .../1784133148233-ReencodePostImages.ts | 29 +++++++++++++------ 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/platforms/pictique/api/src/database/migrations/1784133148233-ReencodePostImages.ts b/platforms/pictique/api/src/database/migrations/1784133148233-ReencodePostImages.ts index dbf7f7218..369a33b22 100644 --- a/platforms/pictique/api/src/database/migrations/1784133148233-ReencodePostImages.ts +++ b/platforms/pictique/api/src/database/migrations/1784133148233-ReencodePostImages.ts @@ -48,16 +48,27 @@ export class ReencodePostImages1784133148233 implements MigrationInterface { } } - // Split the legacy comma-joined string back into individual URLs. - // Only break before a new `data:` URL so the comma inside each data - // URL's MIME prefix is preserved. - const parts = raw.includes("data:") - ? raw.split(/,(?=data:)/) - : raw.split(","); + // Reconstruct the array from the legacy comma-joined string. + // Walk comma-separated tokens: a base64 data URL got split across + // two tokens ("data:;base64" + "") by the comma in + // its own prefix, so rejoin that pair. Any other value (e.g. a + // Firebase/HTTP download URL) contains no internal comma and stands + // alone. This recovers pure-data, pure-URL, and mixed posts in any + // order. + const tokens = raw.split(","); + const images: string[] = []; + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i].trim(); + if (token === "") continue; - const images = parts - .map((p) => p.trim()) - .filter((p) => p.length > 0); + if (token.startsWith("data:")) { + const payload = (tokens[i + 1] ?? "").trim(); + images.push(payload ? `${token},${payload}` : token); + i++; // consume the payload token + } else { + images.push(token); + } + } await queryRunner.query( `UPDATE "posts" SET "images" = $1 WHERE "id" = $2`, From 210f87bfd4c8754c071f055e0a542222f20d907f Mon Sep 17 00:00:00 2001 From: Sahil Garg Date: Thu, 16 Jul 2026 22:01:47 +0530 Subject: [PATCH 4/4] fix: moved to batch updates instead of all at once which would had caused OOM during update. --- .../1784133148233-ReencodePostImages.ts | 127 +++++++++++------- 1 file changed, 82 insertions(+), 45 deletions(-) diff --git a/platforms/pictique/api/src/database/migrations/1784133148233-ReencodePostImages.ts b/platforms/pictique/api/src/database/migrations/1784133148233-ReencodePostImages.ts index 369a33b22..6a9fc5975 100644 --- a/platforms/pictique/api/src/database/migrations/1784133148233-ReencodePostImages.ts +++ b/platforms/pictique/api/src/database/migrations/1784133148233-ReencodePostImages.ts @@ -18,63 +18,100 @@ import { MigrationInterface, QueryRunner } from "typeorm"; */ export class ReencodePostImages1784133148233 implements MigrationInterface { + // Number of rows loaded per keyset-paginated batch. Kept modest because + // each image payload can be a multi-MB base64 data URL. + private static readonly BATCH_SIZE = 200; + public async up(queryRunner: QueryRunner): Promise { - const rows: Array<{ id: string; images: string | null }> = - await queryRunner.query( - `SELECT "id", "images" FROM "posts" WHERE "images" IS NOT NULL`, + const batchSize = ReencodePostImages1784133148233.BATCH_SIZE; + // Keyset cursor: the all-zero UUID is the lower bound, so `id > cursor` + // starts from the first row. Paging by id (never by the mutated + // `images` column) means re-encoded rows can't reappear in a later + // batch, so the walk always terminates. + let cursor = "00000000-0000-0000-0000-000000000000"; + let batch: Array<{ id: string; images: string | null }>; + + do { + batch = await queryRunner.query( + `SELECT "id", "images" FROM "posts" + WHERE "images" IS NOT NULL AND "id" > $1 + ORDER BY "id" ASC + LIMIT $2`, + [cursor, batchSize], ); + if (batch.length === 0) break; + cursor = batch[batch.length - 1].id; - for (const row of rows) { - const raw = row.images; - if (typeof raw !== "string") continue; + const updates: Array<{ id: string; images: string }> = []; + for (const row of batch) { + if (typeof row.images !== "string") continue; + const reencoded = this.reencode(row.images); + // `undefined` = already valid JSON, no write needed. + if (reencoded !== undefined) { + updates.push({ id: row.id, images: reencoded }); + } + } - // Empty string is how simple-array encoded an empty array. Left as - // "", simple-json would choke on JSON.parse("") — convert to "[]". - if (raw === "") { + if (updates.length > 0) { + // Single bulk UPDATE per batch via a VALUES join, instead of + // one round-trip per row. + const valuesSql = updates + .map((_, i) => `($${i * 2 + 1}::uuid, $${i * 2 + 2}::text)`) + .join(", "); + const params = updates.flatMap((u) => [u.id, u.images]); await queryRunner.query( - `UPDATE "posts" SET "images" = $1 WHERE "id" = $2`, - ["[]", row.id], + `UPDATE "posts" AS p + SET "images" = v.images + FROM (VALUES ${valuesSql}) AS v(id, images) + WHERE p."id" = v.id`, + params, ); - continue; } + } while (batch.length === batchSize); + } - // Already migrated (valid JSON array) — leave as-is. - if (raw.trimStart().startsWith("[")) { - try { - JSON.parse(raw); - continue; - } catch { - // Not actually valid JSON; fall through and re-encode. - } - } + /** + * Convert a single legacy `images` value to its `simple-json` encoding. + * Returns the new string to store, or `undefined` when the value is already + * a valid JSON array and should be left untouched. + */ + private reencode(raw: string): string | undefined { + // Empty string is how simple-array encoded an empty array. Left as "", + // simple-json would choke on JSON.parse("") — convert to "[]". + if (raw === "") return "[]"; - // Reconstruct the array from the legacy comma-joined string. - // Walk comma-separated tokens: a base64 data URL got split across - // two tokens ("data:;base64" + "") by the comma in - // its own prefix, so rejoin that pair. Any other value (e.g. a - // Firebase/HTTP download URL) contains no internal comma and stands - // alone. This recovers pure-data, pure-URL, and mixed posts in any - // order. - const tokens = raw.split(","); - const images: string[] = []; - for (let i = 0; i < tokens.length; i++) { - const token = tokens[i].trim(); - if (token === "") continue; - - if (token.startsWith("data:")) { - const payload = (tokens[i + 1] ?? "").trim(); - images.push(payload ? `${token},${payload}` : token); - i++; // consume the payload token - } else { - images.push(token); - } + // Already migrated (valid JSON array) — leave as-is. + if (raw.trimStart().startsWith("[")) { + try { + JSON.parse(raw); + return undefined; + } catch { + // Not actually valid JSON; fall through and re-encode. } + } - await queryRunner.query( - `UPDATE "posts" SET "images" = $1 WHERE "id" = $2`, - [JSON.stringify(images), row.id], - ); + // Reconstruct the array from the legacy comma-joined string. + // Walk comma-separated tokens: a base64 data URL got split across two + // tokens ("data:;base64" + "") by the comma in its own + // prefix, so rejoin that pair. Any other value (e.g. a Firebase/HTTP + // download URL) contains no internal comma and stands alone. This + // recovers pure-data, pure-URL, and mixed posts in any order. + const tokens = raw.split(","); + const images: string[] = []; + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i].trim(); + if (token === "") continue; + + if (token.startsWith("data:")) { + const payload = (tokens[i + 1] ?? "").trim(); + images.push(payload ? `${token},${payload}` : token); + i++; // consume the payload token + } else { + images.push(token); + } } + + return JSON.stringify(images); } public async down(queryRunner: QueryRunner): Promise {