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/quiet-temporal-dragons.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@tanstack/offline-transactions": patch
---

Preserve Temporal values when serializing and restoring offline transactions.
1 change: 1 addition & 0 deletions packages/offline-transactions/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
11 changes: 10 additions & 1 deletion packages/offline-transactions/src/OfflineExecutor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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,
)
Comment thread
cdemetriou-prod marked this conversation as resolved.
this.executor = new TransactionExecutor(
this.scheduler,
this.outbox,
Expand Down Expand Up @@ -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)
}
}
Expand Down
22 changes: 19 additions & 3 deletions packages/offline-transactions/src/outbox/OutboxManager.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -12,9 +19,10 @@ export class OutboxManager {
storage: StorageAdapter,

collections: Record<string, Collection<any, any, any, any, any>>,
temporal?: TemporalConstructors,
) {
this.storage = storage
this.serializer = new TransactionSerializer(collections)
this.serializer = new TransactionSerializer(collections, temporal)
}

private getStorageKey(id: string): string {
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down
95 changes: 95 additions & 0 deletions packages/offline-transactions/src/outbox/TransactionSerializer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, unknown>)[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<string, Collection<any, any, any, any, any>>
private collectionIdToKey: Map<string, string>
private temporal: TemporalConstructors | undefined

constructor(
collections: Record<string, Collection<any, any, any, any, any>>,
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)) {
Expand All @@ -25,6 +94,9 @@ export class TransactionSerializer {
const serialized: SerializedOfflineTransaction = {
...transaction,
createdAt: transaction.createdAt.toISOString(),
metadata: this.serializeValue(transaction.metadata) as
| Record<string, any>
| undefined,
mutations: transaction.mutations.map((mutation) =>
this.serializeMutation(mutation),
),
Expand All @@ -47,6 +119,9 @@ export class TransactionSerializer {
return {
...parsed,
createdAt,
metadata: this.deserializeValue(parsed.metadata) as
| Record<string, any>
| undefined,
mutations: parsed.mutations.map((mutationData) =>
this.deserializeMutation(mutationData),
),
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
23 changes: 23 additions & 0 deletions packages/offline-transactions/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TemporalConstructorName, TemporalConstructor>
>

// In-memory representation with full PendingMutation objects
export interface OfflineTransaction {
id: string
Expand Down Expand Up @@ -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 {
Expand Down
64 changes: 64 additions & 0 deletions packages/offline-transactions/tests/OfflineExecutor.test.ts
Original file line number Diff line number Diff line change
@@ -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`, () => {
Expand Down Expand Up @@ -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<typeof startOfflineExecutor> | 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
}
}
Comment thread
cdemetriou-prod marked this conversation as resolved.
})
})
Loading