From e232ab36fbe8d15e32064dae2b4e1e7b6f622dc3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Jan 2026 16:34:30 +0000 Subject: [PATCH 1/7] fix(offline-transactions): prevent date corruption from eager ISO string conversion The JSON.parse reviver in deserialize() was converting ALL ISO date strings to Date objects, including plain string values that happened to match the ISO format. This corrupted user data after app restarts. The serializer already has an explicit date marker system: - serializeValue() wraps Date objects as { __type: 'Date', value: '...' } - deserializeValue() restores dates from those markers The fix removes the eager reviver and only converts the top-level createdAt field, letting the marker system handle dates in mutation data. Adds TransactionSerializer.test.ts with tests covering: - Plain ISO strings preserved as strings - Actual Date objects correctly restored via marker system - Mixed Date objects and ISO strings handled correctly - Nested ISO string values not corrupted - Top-level createdAt correctly restored as Date --- .../src/outbox/TransactionSerializer.ts | 22 +- .../tests/TransactionSerializer.test.ts | 237 ++++++++++++++++++ 2 files changed, 244 insertions(+), 15 deletions(-) create mode 100644 packages/offline-transactions/tests/TransactionSerializer.test.ts diff --git a/packages/offline-transactions/src/outbox/TransactionSerializer.ts b/packages/offline-transactions/src/outbox/TransactionSerializer.ts index ff196f7f8..706ada31b 100644 --- a/packages/offline-transactions/src/outbox/TransactionSerializer.ts +++ b/packages/offline-transactions/src/outbox/TransactionSerializer.ts @@ -7,11 +7,11 @@ import type { import type { Collection, PendingMutation } from '@tanstack/db' export class TransactionSerializer { - // eslint-disable-next-line @typescript-eslint/no-explicit-any + private collections: Record> private collectionIdToKey: Map - // eslint-disable-next-line @typescript-eslint/no-explicit-any + constructor( collections: Record>, ) { @@ -41,22 +41,14 @@ export class TransactionSerializer { } deserialize(data: string): OfflineTransaction { - const parsed: SerializedOfflineTransaction = JSON.parse( - data, - (key, value) => { - // Parse ISO date strings back to Date objects - if ( - typeof value === `string` && - /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(value) - ) { - return new Date(value) - } - return value - }, - ) + // Parse without a reviver - let deserializeValue handle dates in mutation data + // using the { __type: 'Date' } marker system + const parsed: SerializedOfflineTransaction = JSON.parse(data) return { ...parsed, + // Only convert the top-level createdAt field back to a Date + createdAt: new Date(parsed.createdAt as unknown as string), mutations: parsed.mutations.map((mutationData) => this.deserializeMutation(mutationData), ), diff --git a/packages/offline-transactions/tests/TransactionSerializer.test.ts b/packages/offline-transactions/tests/TransactionSerializer.test.ts new file mode 100644 index 000000000..0ef83581a --- /dev/null +++ b/packages/offline-transactions/tests/TransactionSerializer.test.ts @@ -0,0 +1,237 @@ +import { describe, expect, it } from 'vitest' +import { TransactionSerializer } from '../src/outbox/TransactionSerializer' +import type { OfflineTransaction } from '../src/types' +import type { PendingMutation } from '@tanstack/db' + +describe(`TransactionSerializer`, () => { + const mockCollection = { + id: `test-collection`, + } + + const createSerializer = () => { + return new TransactionSerializer({ + 'test-collection': mockCollection as any, + }) + } + + describe(`date handling`, () => { + it(`should preserve plain ISO date strings without converting to Date objects`, () => { + const serializer = createSerializer() + + // This is the bug: a plain string that looks like an ISO date + // should NOT be converted to a Date object after round-trip + const isoDateString = `2024-01-15T10:30:00.000Z` + + const transaction: OfflineTransaction = { + id: `tx-1`, + createdAt: new Date(`2024-01-01T00:00:00.000Z`), + status: `pending`, + mutationFnName: `syncData`, + mutations: [ + { + globalKey: `key-1`, + type: `insert`, + // This field intentionally stores an ISO date as a STRING + // (e.g., a DB value, or a user-provided string) + modified: { + id: `1`, + eventId: isoDateString, // Should remain a string! + description: `Some event`, + }, + original: null, + collection: mockCollection, + mutationId: `mut-1`, + key: `1`, + changes: {}, + metadata: undefined, + syncMetadata: {}, + optimistic: true, + createdAt: new Date(), + updatedAt: new Date(), + } as PendingMutation, + ], + } + + // Serialize and deserialize (simulating app restart) + const serialized = serializer.serialize(transaction) + const deserialized = serializer.deserialize(serialized) + + // The eventId should still be a string, not a Date object + const eventId = deserialized.mutations[0]!.modified.eventId + expect(typeof eventId).toBe(`string`) + expect(eventId).toBe(isoDateString) + }) + + it(`should correctly restore actual Date objects using the marker system`, () => { + const serializer = createSerializer() + + const actualDate = new Date(`2024-01-15T10:30:00.000Z`) + + const transaction: OfflineTransaction = { + id: `tx-1`, + createdAt: new Date(`2024-01-01T00:00:00.000Z`), + status: `pending`, + mutationFnName: `syncData`, + mutations: [ + { + globalKey: `key-1`, + type: `insert`, + modified: { + id: `1`, + createdAt: actualDate, // This is an actual Date object + name: `Test`, + }, + original: null, + collection: mockCollection, + mutationId: `mut-1`, + key: `1`, + changes: {}, + metadata: undefined, + syncMetadata: {}, + optimistic: true, + createdAt: new Date(), + updatedAt: new Date(), + } as PendingMutation, + ], + } + + const serialized = serializer.serialize(transaction) + const deserialized = serializer.deserialize(serialized) + + // The createdAt should be restored as a Date object + const restoredDate = deserialized.mutations[0]!.modified.createdAt + expect(restoredDate).toBeInstanceOf(Date) + expect(restoredDate.toISOString()).toBe(actualDate.toISOString()) + }) + + it(`should handle mixed Date objects and ISO string values correctly`, () => { + const serializer = createSerializer() + + const actualDate = new Date(`2024-06-15T14:00:00.000Z`) + const isoStringValue = `2024-01-15T10:30:00.000Z` // Plain string, not a Date + + const transaction: OfflineTransaction = { + id: `tx-1`, + createdAt: new Date(`2024-01-01T00:00:00.000Z`), + status: `pending`, + mutationFnName: `syncData`, + mutations: [ + { + globalKey: `key-1`, + type: `insert`, + modified: { + id: `1`, + timestamp: actualDate, // Actual Date object + scheduledFor: isoStringValue, // Plain string that looks like ISO date + notes: `Meeting scheduled`, + }, + original: null, + collection: mockCollection, + mutationId: `mut-1`, + key: `1`, + changes: {}, + metadata: undefined, + syncMetadata: {}, + optimistic: true, + createdAt: new Date(), + updatedAt: new Date(), + } as PendingMutation, + ], + } + + const serialized = serializer.serialize(transaction) + const deserialized = serializer.deserialize(serialized) + + const modified = deserialized.mutations[0]!.modified + + // The actual Date should be restored as Date + expect(modified.timestamp).toBeInstanceOf(Date) + expect(modified.timestamp.toISOString()).toBe(actualDate.toISOString()) + + // The string should remain a string + expect(typeof modified.scheduledFor).toBe(`string`) + expect(modified.scheduledFor).toBe(isoStringValue) + }) + + it(`should not corrupt nested ISO string values`, () => { + const serializer = createSerializer() + + const transaction: OfflineTransaction = { + id: `tx-1`, + createdAt: new Date(`2024-01-01T00:00:00.000Z`), + status: `pending`, + mutationFnName: `syncData`, + mutations: [ + { + globalKey: `key-1`, + type: `insert`, + modified: { + id: `1`, + metadata: { + // Nested ISO strings should also be preserved + lastSync: `2024-03-20T08:00:00.000Z`, + importedFrom: `external-system`, + }, + }, + original: null, + collection: mockCollection, + mutationId: `mut-1`, + key: `1`, + changes: {}, + metadata: undefined, + syncMetadata: {}, + optimistic: true, + createdAt: new Date(), + updatedAt: new Date(), + } as PendingMutation, + ], + } + + const serialized = serializer.serialize(transaction) + const deserialized = serializer.deserialize(serialized) + + const lastSync = deserialized.mutations[0]!.modified.metadata.lastSync + expect(typeof lastSync).toBe(`string`) + expect(lastSync).toBe(`2024-03-20T08:00:00.000Z`) + }) + + it(`should correctly restore top-level createdAt as Date`, () => { + const serializer = createSerializer() + + const transactionDate = new Date(`2024-05-15T12:30:00.000Z`) + + const transaction: OfflineTransaction = { + id: `tx-1`, + createdAt: transactionDate, + status: `pending`, + mutationFnName: `syncData`, + mutations: [ + { + globalKey: `key-1`, + type: `insert`, + modified: { id: `1` }, + original: null, + collection: mockCollection, + mutationId: `mut-1`, + key: `1`, + changes: {}, + metadata: undefined, + syncMetadata: {}, + optimistic: true, + createdAt: new Date(), + updatedAt: new Date(), + } as PendingMutation, + ], + } + + const serialized = serializer.serialize(transaction) + const deserialized = serializer.deserialize(serialized) + + // Top-level createdAt should be a Date object + expect(deserialized.createdAt).toBeInstanceOf(Date) + expect(deserialized.createdAt.toISOString()).toBe( + transactionDate.toISOString(), + ) + }) + }) +}) From b475edd0ef6fe09cec693ae420f2830da5f8c8e6 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 13 Jan 2026 16:38:21 +0000 Subject: [PATCH 2/7] ci: apply automated fixes --- .../offline-transactions/src/outbox/TransactionSerializer.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/offline-transactions/src/outbox/TransactionSerializer.ts b/packages/offline-transactions/src/outbox/TransactionSerializer.ts index 706ada31b..8f55b5c97 100644 --- a/packages/offline-transactions/src/outbox/TransactionSerializer.ts +++ b/packages/offline-transactions/src/outbox/TransactionSerializer.ts @@ -7,11 +7,9 @@ import type { import type { Collection, PendingMutation } from '@tanstack/db' export class TransactionSerializer { - private collections: Record> private collectionIdToKey: Map - constructor( collections: Record>, ) { From 62c19f74e15eee2d9c8cca988683d88873c0b5f1 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Tue, 13 Jan 2026 09:50:02 -0700 Subject: [PATCH 3/7] fixes --- .../offline-transactions/src/outbox/TransactionSerializer.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/offline-transactions/src/outbox/TransactionSerializer.ts b/packages/offline-transactions/src/outbox/TransactionSerializer.ts index 8f55b5c97..56c7f6317 100644 --- a/packages/offline-transactions/src/outbox/TransactionSerializer.ts +++ b/packages/offline-transactions/src/outbox/TransactionSerializer.ts @@ -24,13 +24,12 @@ export class TransactionSerializer { serialize(transaction: OfflineTransaction): string { const serialized: SerializedOfflineTransaction = { ...transaction, - createdAt: transaction.createdAt, mutations: transaction.mutations.map((mutation) => this.serializeMutation(mutation), ), } // Convert the whole object to JSON, handling dates - return JSON.stringify(serialized, (key, value) => { + return JSON.stringify(serialized, (_key, value) => { if (value instanceof Date) { return value.toISOString() } From 40e798697e9ca9a04ddc6c07b09deba319261334 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Tue, 13 Jan 2026 11:38:45 -0700 Subject: [PATCH 4/7] refactor(offline-transactions): simplify date serialization types - Fix SerializedOfflineTransaction.createdAt type to string (matches JSON-parsed reality) - Remove JSON.stringify replacer in favor of explicit createdAt conversion - Remove unnecessary double cast in deserialize Co-Authored-By: Claude Opus 4.5 --- .../src/outbox/TransactionSerializer.ts | 12 +++--------- packages/offline-transactions/src/types.ts | 4 ++-- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/packages/offline-transactions/src/outbox/TransactionSerializer.ts b/packages/offline-transactions/src/outbox/TransactionSerializer.ts index 56c7f6317..3bd4623f3 100644 --- a/packages/offline-transactions/src/outbox/TransactionSerializer.ts +++ b/packages/offline-transactions/src/outbox/TransactionSerializer.ts @@ -24,17 +24,12 @@ export class TransactionSerializer { serialize(transaction: OfflineTransaction): string { const serialized: SerializedOfflineTransaction = { ...transaction, + createdAt: transaction.createdAt.toISOString(), mutations: transaction.mutations.map((mutation) => this.serializeMutation(mutation), ), } - // Convert the whole object to JSON, handling dates - return JSON.stringify(serialized, (_key, value) => { - if (value instanceof Date) { - return value.toISOString() - } - return value - }) + return JSON.stringify(serialized) } deserialize(data: string): OfflineTransaction { @@ -44,8 +39,7 @@ export class TransactionSerializer { return { ...parsed, - // Only convert the top-level createdAt field back to a Date - createdAt: new Date(parsed.createdAt as unknown as string), + createdAt: new Date(parsed.createdAt), mutations: parsed.mutations.map((mutationData) => this.deserializeMutation(mutationData), ), diff --git a/packages/offline-transactions/src/types.ts b/packages/offline-transactions/src/types.ts index 8cf18cc88..d061f8ade 100644 --- a/packages/offline-transactions/src/types.ts +++ b/packages/offline-transactions/src/types.ts @@ -60,7 +60,7 @@ export interface SerializedOfflineTransaction { mutations: Array keys: Array idempotencyKey: string - createdAt: Date + createdAt: string retryCount: number nextAttemptAt: number lastError?: SerializedError @@ -88,7 +88,7 @@ export interface StorageDiagnostic { } export interface OfflineConfig { - // eslint-disable-next-line @typescript-eslint/no-explicit-any + collections: Record> mutationFns: Record storage?: StorageAdapter From 6fbe2ec65879f92684e49fab9940618236088e2b Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 13 Jan 2026 18:39:42 +0000 Subject: [PATCH 5/7] ci: apply automated fixes --- packages/offline-transactions/src/types.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/offline-transactions/src/types.ts b/packages/offline-transactions/src/types.ts index d061f8ade..16da282c5 100644 --- a/packages/offline-transactions/src/types.ts +++ b/packages/offline-transactions/src/types.ts @@ -88,7 +88,6 @@ export interface StorageDiagnostic { } export interface OfflineConfig { - collections: Record> mutationFns: Record storage?: StorageAdapter From d0c21ebd6a57b5556af1b42370dfab0b049cab3e Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Tue, 13 Jan 2026 11:45:33 -0700 Subject: [PATCH 6/7] fix(offline-transactions): add date validation to prevent silent failures - Validate createdAt in deserialize() throws on Invalid Date - Validate Date markers have value field before deserializing - Validate Date marker values produce valid dates - Remove stray whitespace in types.ts Co-Authored-By: Claude Opus 4.5 --- .../src/outbox/TransactionSerializer.ts | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/packages/offline-transactions/src/outbox/TransactionSerializer.ts b/packages/offline-transactions/src/outbox/TransactionSerializer.ts index 3bd4623f3..92142010f 100644 --- a/packages/offline-transactions/src/outbox/TransactionSerializer.ts +++ b/packages/offline-transactions/src/outbox/TransactionSerializer.ts @@ -37,9 +37,16 @@ export class TransactionSerializer { // using the { __type: 'Date' } marker system const parsed: SerializedOfflineTransaction = JSON.parse(data) + const createdAt = new Date(parsed.createdAt) + if (isNaN(createdAt.getTime())) { + throw new Error( + `Failed to deserialize transaction: invalid createdAt value "${parsed.createdAt}"`, + ) + } + return { ...parsed, - createdAt: new Date(parsed.createdAt), + createdAt, mutations: parsed.mutations.map((mutationData) => this.deserializeMutation(mutationData), ), @@ -117,7 +124,16 @@ export class TransactionSerializer { } if (typeof value === `object` && value.__type === `Date`) { - return new Date(value.value) + if (value.value === undefined || value.value === null) { + throw new Error(`Corrupted Date marker: missing value field`) + } + const date = new Date(value.value) + if (isNaN(date.getTime())) { + throw new Error( + `Failed to deserialize Date marker: invalid date value "${value.value}"`, + ) + } + return date } if (typeof value === `object`) { From 8fdbb2e851b8fb33eda18fc9d82c74c4c019d656 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Tue, 13 Jan 2026 11:50:28 -0700 Subject: [PATCH 7/7] chore: add changeset for date corruption fix Co-Authored-By: Claude Opus 4.5 --- .changeset/fix-date-corruption.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fix-date-corruption.md diff --git a/.changeset/fix-date-corruption.md b/.changeset/fix-date-corruption.md new file mode 100644 index 000000000..6b3418388 --- /dev/null +++ b/.changeset/fix-date-corruption.md @@ -0,0 +1,5 @@ +--- +'@tanstack/offline-transactions': patch +--- + +Fix date field corruption after app restart. String values matching ISO date format were incorrectly converted to Date objects during deserialization, corrupting user data. Now only explicit Date markers are converted, preserving string values intact.