diff --git a/.changeset/quiet-temporal-dragons.md b/.changeset/quiet-temporal-dragons.md new file mode 100644 index 000000000..81ba46db4 --- /dev/null +++ b/.changeset/quiet-temporal-dragons.md @@ -0,0 +1,5 @@ +--- +"@tanstack/offline-transactions": patch +--- + +Preserve Temporal values when serializing and restoring offline transactions. diff --git a/packages/offline-transactions/package.json b/packages/offline-transactions/package.json index 872a47b92..3ebd94195 100644 --- a/packages/offline-transactions/package.json +++ b/packages/offline-transactions/package.json @@ -79,6 +79,7 @@ "@types/node": "^25.2.2", "eslint": "^9.39.2", "react-native": "0.79.6", + "temporal-polyfill": "^0.3.0", "typescript": "^5.9.2", "vitest": "^3.2.4" } diff --git a/packages/offline-transactions/src/OfflineExecutor.ts b/packages/offline-transactions/src/OfflineExecutor.ts index 8f443277c..1f8f207cf 100644 --- a/packages/offline-transactions/src/OfflineExecutor.ts +++ b/packages/offline-transactions/src/OfflineExecutor.ts @@ -7,6 +7,7 @@ import { LocalStorageAdapter } from './storage/LocalStorageAdapter' import { OutboxManager } from './outbox/OutboxManager' import { KeyScheduler } from './executor/KeyScheduler' import { TransactionExecutor } from './executor/TransactionExecutor' +import { MissingTemporalConstructorError } from './outbox/TransactionSerializer' // Coordination import { WebLocksLeader } from './coordination/WebLocksLeader' @@ -280,7 +281,11 @@ export class OfflineExecutor { } // Storage available - set up offline components - this.outbox = new OutboxManager(storage, this.config.collections) + this.outbox = new OutboxManager( + storage, + this.config.collections, + this.config.temporal, + ) this.executor = new TransactionExecutor( this.scheduler, this.outbox, @@ -333,6 +338,10 @@ export class OfflineExecutor { console.warn(`Failed to execute transactions:`, error) }) } catch (error) { + if (error instanceof MissingTemporalConstructorError) { + throw error + } + console.warn(`Failed to load and replay transactions:`, error) } } diff --git a/packages/offline-transactions/src/outbox/OutboxManager.ts b/packages/offline-transactions/src/outbox/OutboxManager.ts index b2f1faca9..778465b51 100644 --- a/packages/offline-transactions/src/outbox/OutboxManager.ts +++ b/packages/offline-transactions/src/outbox/OutboxManager.ts @@ -1,6 +1,13 @@ import { withSpan } from '../telemetry/tracer' -import { TransactionSerializer } from './TransactionSerializer' -import type { OfflineTransaction, StorageAdapter } from '../types' +import { + MissingTemporalConstructorError, + TransactionSerializer, +} from './TransactionSerializer' +import type { + OfflineTransaction, + StorageAdapter, + TemporalConstructors, +} from '../types' import type { Collection } from '@tanstack/db' export class OutboxManager { @@ -12,9 +19,10 @@ export class OutboxManager { storage: StorageAdapter, collections: Record>, + temporal?: TemporalConstructors, ) { this.storage = storage - this.serializer = new TransactionSerializer(collections) + this.serializer = new TransactionSerializer(collections, temporal) } private getStorageKey(id: string): string { @@ -52,6 +60,10 @@ export class OutboxManager { span.setAttribute(`result`, `found`) return transaction } catch (error) { + if (error instanceof MissingTemporalConstructorError) { + throw error + } + console.warn(`Failed to deserialize transaction ${id}:`, error) span.setAttribute(`result`, `deserialize_error`) return null @@ -77,6 +89,10 @@ export class OutboxManager { const transaction = this.serializer.deserialize(data) transactions.push(transaction) } catch (error) { + if (error instanceof MissingTemporalConstructorError) { + throw error + } + console.warn( `Failed to deserialize transaction from key ${key}:`, error, diff --git a/packages/offline-transactions/src/outbox/TransactionSerializer.ts b/packages/offline-transactions/src/outbox/TransactionSerializer.ts index cbbea8ece..c3df7e3f7 100644 --- a/packages/offline-transactions/src/outbox/TransactionSerializer.ts +++ b/packages/offline-transactions/src/outbox/TransactionSerializer.ts @@ -3,17 +3,86 @@ import type { SerializedError, SerializedMutation, SerializedOfflineTransaction, + TemporalConstructorName, + TemporalConstructors, } from '../types' import type { Collection, PendingMutation } from '@tanstack/db' +const temporalConstructors = { + 'Temporal.Duration': `Duration`, + 'Temporal.Instant': `Instant`, + 'Temporal.PlainDate': `PlainDate`, + 'Temporal.PlainDateTime': `PlainDateTime`, + 'Temporal.PlainMonthDay': `PlainMonthDay`, + 'Temporal.PlainTime': `PlainTime`, + 'Temporal.PlainYearMonth': `PlainYearMonth`, + 'Temporal.ZonedDateTime': `ZonedDateTime`, +} as const + +type TemporalType = keyof typeof temporalConstructors + +interface TemporalLike { + readonly [Symbol.toStringTag]: TemporalType + toString: () => string +} + +interface SerializedTemporalValue { + __type: `Temporal` + type: TemporalType + value: string +} + +function isTemporalType(type: string): type is TemporalType { + return Object.prototype.hasOwnProperty.call(temporalConstructors, type) +} + +function isTemporalValue(value: unknown): value is TemporalLike { + if (value === null || typeof value !== `object`) { + return false + } + + const tag = (value as Record)[Symbol.toStringTag] + return typeof tag === `string` && isTemporalType(tag) +} + +export class MissingTemporalConstructorError extends Error { + constructor(constructorName: TemporalConstructorName) { + super( + `Failed to deserialize Temporal marker: Temporal.${constructorName} is not available. Pass a Temporal namespace to startOfflineExecutor({ temporal }) or polyfill globalThis.Temporal.`, + ) + this.name = `MissingTemporalConstructorError` + } +} + +function getDefaultTemporalConstructors(): TemporalConstructors | undefined { + return (globalThis as { Temporal?: TemporalConstructors }).Temporal +} + +function getTemporalConstructor( + constructors: TemporalConstructors | undefined, + type: TemporalType, +) { + const constructorName = temporalConstructors[type] + const constructor = constructors?.[constructorName] + + if (!constructor) { + throw new MissingTemporalConstructorError(constructorName) + } + + return constructor +} + export class TransactionSerializer { private collections: Record> private collectionIdToKey: Map + private temporal: TemporalConstructors | undefined constructor( collections: Record>, + temporal?: TemporalConstructors, ) { this.collections = collections + this.temporal = temporal ?? getDefaultTemporalConstructors() // Create reverse lookup from collection.id to registry key this.collectionIdToKey = new Map() for (const [key, collection] of Object.entries(collections)) { @@ -25,6 +94,9 @@ export class TransactionSerializer { const serialized: SerializedOfflineTransaction = { ...transaction, createdAt: transaction.createdAt.toISOString(), + metadata: this.serializeValue(transaction.metadata) as + | Record + | undefined, mutations: transaction.mutations.map((mutation) => this.serializeMutation(mutation), ), @@ -47,6 +119,9 @@ export class TransactionSerializer { return { ...parsed, createdAt, + metadata: this.deserializeValue(parsed.metadata) as + | Record + | undefined, mutations: parsed.mutations.map((mutationData) => this.deserializeMutation(mutationData), ), @@ -112,6 +187,14 @@ export class TransactionSerializer { return { __type: `Date`, value: value.toISOString() } } + if (isTemporalValue(value)) { + return { + __type: `Temporal`, + type: value[Symbol.toStringTag], + value: value.toString(), + } satisfies SerializedTemporalValue + } + if (typeof value === `object`) { const result: any = Array.isArray(value) ? [] : {} for (const key in value) { @@ -143,6 +226,18 @@ export class TransactionSerializer { return date } + if (typeof value === `object` && value.__type === `Temporal`) { + if (typeof value.type !== `string` || !isTemporalType(value.type)) { + throw new Error(`Corrupted Temporal marker: invalid type field`) + } + + if (typeof value.value !== `string`) { + throw new Error(`Corrupted Temporal marker: missing value field`) + } + + return getTemporalConstructor(this.temporal, value.type).from(value.value) + } + if (typeof value === `object`) { const result: any = Array.isArray(value) ? [] : {} for (const key in value) { diff --git a/packages/offline-transactions/src/types.ts b/packages/offline-transactions/src/types.ts index e18a287cb..7537ad3e2 100644 --- a/packages/offline-transactions/src/types.ts +++ b/packages/offline-transactions/src/types.ts @@ -39,6 +39,24 @@ export interface SerializedSpanContext { traceState?: string } +export type TemporalConstructorName = + | `Duration` + | `Instant` + | `PlainDate` + | `PlainDateTime` + | `PlainMonthDay` + | `PlainTime` + | `PlainYearMonth` + | `ZonedDateTime` + +export type TemporalConstructor = { + from: (value: string) => unknown +} + +export type TemporalConstructors = Partial< + Record +> + // In-memory representation with full PendingMutation objects export interface OfflineTransaction { id: string @@ -108,6 +126,11 @@ export interface OfflineConfig { * The '@tanstack/offline-transactions/react-native' entry point uses ReactNativeOnlineDetector automatically. */ onlineDetector?: OnlineDetector + /** + * Temporal constructors used to restore Temporal values from persisted + * offline transactions. Defaults to globalThis.Temporal when available. + */ + temporal?: TemporalConstructors } export interface StorageAdapter { diff --git a/packages/offline-transactions/tests/OfflineExecutor.test.ts b/packages/offline-transactions/tests/OfflineExecutor.test.ts index 19e48eb6c..330d65078 100644 --- a/packages/offline-transactions/tests/OfflineExecutor.test.ts +++ b/packages/offline-transactions/tests/OfflineExecutor.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { LocalStorageAdapter, startOfflineExecutor } from '../src/index' +import { MissingTemporalConstructorError } from '../src/outbox/TransactionSerializer' import type { OfflineConfig } from '../src/types' describe(`OfflineExecutor`, () => { @@ -73,4 +74,67 @@ describe(`OfflineExecutor`, () => { expect(() => executor.dispose()).not.toThrow() }) + + it(`should fail initialization when replay requires unavailable Temporal constructors`, async () => { + const globalWithTemporal = globalThis as { Temporal?: unknown } + const originalTemporal = globalWithTemporal.Temporal + delete globalWithTemporal.Temporal + + const serializedTransaction = JSON.stringify({ + id: `tx-temporal`, + mutationFnName: `syncData`, + mutations: [ + { + globalKey: `key-1`, + type: `insert`, + modified: { + __type: `Temporal`, + type: `Temporal.PlainDate`, + value: `2024-01-15`, + }, + original: null, + changes: {}, + collectionId: `test-collection`, + }, + ], + keys: [`key-1`], + idempotencyKey: `idempotency-key-1`, + createdAt: `2024-01-01T00:00:00.000Z`, + retryCount: 0, + nextAttemptAt: 0, + version: 1, + }) + + let executor: ReturnType | undefined + + try { + executor = startOfflineExecutor({ + ...config, + storage: { + get: async (key) => + key === `tx:tx-temporal` ? serializedTransaction : null, + set: async () => {}, + delete: async () => {}, + keys: async () => [`tx:tx-temporal`], + clear: async () => {}, + }, + leaderElection: { + requestLeadership: async () => true, + releaseLeadership: () => {}, + isLeader: () => true, + onLeadershipChange: () => () => {}, + }, + }) + + await expect(executor.waitForInit()).rejects.toBeInstanceOf( + MissingTemporalConstructorError, + ) + } finally { + executor?.dispose() + + if (originalTemporal !== undefined) { + globalWithTemporal.Temporal = originalTemporal + } + } + }) }) diff --git a/packages/offline-transactions/tests/TransactionSerializer.test.ts b/packages/offline-transactions/tests/TransactionSerializer.test.ts index c1ce716fe..ee7890256 100644 --- a/packages/offline-transactions/tests/TransactionSerializer.test.ts +++ b/packages/offline-transactions/tests/TransactionSerializer.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import { Temporal } from 'temporal-polyfill' import { TransactionSerializer } from '../src/outbox/TransactionSerializer' import type { OfflineTransaction } from '../src/types' import type { PendingMutation } from '@tanstack/db' @@ -12,7 +13,48 @@ describe(`TransactionSerializer`, () => { const createSerializer = () => { return new TransactionSerializer({ 'test-collection': mockCollection as any, - }) + }, Temporal) + } + + const createTransaction = ({ + modified, + original = null, + changes = {}, + metadata, + }: { + modified: any + original?: any + changes?: any + metadata?: Record + }): OfflineTransaction => { + return { + id: `tx-1`, + createdAt: new Date(`2024-01-01T00:00:00.000Z`), + mutationFnName: `syncData`, + mutations: [ + { + globalKey: `key-1`, + type: `insert`, + modified, + original, + collection: mockCollection, + mutationId: `mut-1`, + key: modified.id, + changes, + metadata: undefined, + syncMetadata: {}, + optimistic: true, + createdAt: new Date(), + updatedAt: new Date(), + } as PendingMutation, + ], + keys: [`key-1`], + idempotencyKey: `idempotency-key-1`, + retryCount: 0, + nextAttemptAt: 0, + metadata, + version: 1, + } } describe(`date handling`, () => { @@ -235,4 +277,96 @@ describe(`TransactionSerializer`, () => { ) }) }) + + describe(`Temporal handling`, () => { + it(`should restore Temporal values in mutation data`, () => { + const serializer = createSerializer() + const temporalValues = { + duration: Temporal.Duration.from({ hours: 1, minutes: 30 }), + instant: Temporal.Instant.from(`2024-01-15T10:30:00Z`), + plainDate: Temporal.PlainDate.from(`2024-01-15`), + plainDateTime: Temporal.PlainDateTime.from(`2024-01-15T10:30:00`), + plainMonthDay: Temporal.PlainMonthDay.from(`01-15`), + plainTime: Temporal.PlainTime.from(`10:30:00`), + plainYearMonth: Temporal.PlainYearMonth.from(`2024-01`), + zonedDateTime: Temporal.ZonedDateTime.from( + `2024-01-15T10:30:00+00:00[UTC]`, + ), + } + + const transaction = createTransaction({ + modified: { + id: `1`, + temporalValues, + }, + original: { + id: `1`, + dueDate: temporalValues.plainDate, + }, + changes: { + temporalValues, + }, + }) + + const serialized = serializer.serialize(transaction) + const deserialized = serializer.deserialize(serialized) + + const restoredValues = deserialized.mutations[0]!.modified.temporalValues + for (const [key, originalValue] of Object.entries(temporalValues)) { + const restoredValue = restoredValues[key] + expect(restoredValue[Symbol.toStringTag]).toBe( + originalValue[Symbol.toStringTag], + ) + expect(restoredValue.toString()).toBe(originalValue.toString()) + } + + const restoredOriginal = deserialized.mutations[0]!.original.dueDate + expect(restoredOriginal[Symbol.toStringTag]).toBe(`Temporal.PlainDate`) + expect(restoredOriginal.toString()).toBe( + temporalValues.plainDate.toString(), + ) + + const restoredChanges = + deserialized.mutations[0]!.changes.temporalValues + for (const [key, originalValue] of Object.entries(temporalValues)) { + const restoredValue = restoredChanges[key] + expect(restoredValue[Symbol.toStringTag]).toBe( + originalValue[Symbol.toStringTag], + ) + expect(restoredValue.toString()).toBe(originalValue.toString()) + } + }) + + it(`should restore Temporal values in transaction metadata`, () => { + const serializer = createSerializer() + const submittedAt = Temporal.Instant.from(`2024-01-15T10:30:00Z`) + const scheduledFor = Temporal.PlainDate.from(`2024-02-01`) + + const transaction = createTransaction({ + modified: { id: `1`, name: `Test` }, + metadata: { + submittedAt, + nested: { + scheduledFor, + }, + }, + }) + + const serialized = serializer.serialize(transaction) + const deserialized = serializer.deserialize(serialized) + + expect(deserialized.metadata!.submittedAt[Symbol.toStringTag]).toBe( + `Temporal.Instant`, + ) + expect(deserialized.metadata!.submittedAt.toString()).toBe( + submittedAt.toString(), + ) + expect( + deserialized.metadata!.nested.scheduledFor[Symbol.toStringTag], + ).toBe(`Temporal.PlainDate`) + expect(deserialized.metadata!.nested.scheduledFor.toString()).toBe( + scheduledFor.toString(), + ) + }) + }) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ad174b46b..8374979f0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1312,6 +1312,9 @@ importers: react-native: specifier: 0.79.6 version: 0.79.6(@babel/core@7.29.0)(@types/react@19.2.13)(react@19.2.4) + temporal-polyfill: + specifier: ^0.3.0 + version: 0.3.0 typescript: specifier: ^5.9.2 version: 5.9.3