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. diff --git a/packages/offline-transactions/src/outbox/TransactionSerializer.ts b/packages/offline-transactions/src/outbox/TransactionSerializer.ts index ff196f7f8..92142010f 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 { - // 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>, ) { @@ -26,37 +24,29 @@ export class TransactionSerializer { serialize(transaction: OfflineTransaction): string { const serialized: SerializedOfflineTransaction = { ...transaction, - createdAt: transaction.createdAt, + 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 { - 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) + + 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, mutations: parsed.mutations.map((mutationData) => this.deserializeMutation(mutationData), ), @@ -134,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`) { diff --git a/packages/offline-transactions/src/types.ts b/packages/offline-transactions/src/types.ts index 8cf18cc88..16da282c5 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,6 @@ export interface StorageDiagnostic { } export interface OfflineConfig { - // eslint-disable-next-line @typescript-eslint/no-explicit-any collections: Record> mutationFns: Record storage?: StorageAdapter 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(), + ) + }) + }) +})