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
5 changes: 5 additions & 0 deletions .changeset/fix-date-corruption.md
Original file line number Diff line number Diff line change
@@ -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.
47 changes: 23 additions & 24 deletions packages/offline-transactions/src/outbox/TransactionSerializer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Collection<any, any, any, any, any>>
private collectionIdToKey: Map<string, string>

// eslint-disable-next-line @typescript-eslint/no-explicit-any
constructor(
collections: Record<string, Collection<any, any, any, any, any>>,
) {
Expand All @@ -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),
),
Expand Down Expand Up @@ -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`) {
Expand Down
3 changes: 1 addition & 2 deletions packages/offline-transactions/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ export interface SerializedOfflineTransaction {
mutations: Array<SerializedMutation>
keys: Array<string>
idempotencyKey: string
createdAt: Date
createdAt: string
retryCount: number
nextAttemptAt: number
lastError?: SerializedError
Expand Down Expand Up @@ -88,7 +88,6 @@ export interface StorageDiagnostic {
}

export interface OfflineConfig {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
collections: Record<string, Collection<any, any, any, any, any>>
mutationFns: Record<string, OfflineMutationFn>
storage?: StorageAdapter
Expand Down
237 changes: 237 additions & 0 deletions packages/offline-transactions/tests/TransactionSerializer.test.ts
Original file line number Diff line number Diff line change
@@ -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(),
)
})
})
})
Loading