@tanstack/offline-transactions
Version:
Offline-first transaction capabilities for TanStack DB
404 lines (367 loc) • 11.9 kB
text/typescript
import type {
OfflineTransaction,
SerializedError,
SerializedMutation,
SerializedOfflineTransaction,
} from '../types'
import type { Collection, PendingMutation } from '@tanstack/db'
const temporalConstructorNames = [
`Duration`,
`Instant`,
`PlainDate`,
`PlainDateTime`,
`PlainMonthDay`,
`PlainTime`,
`PlainYearMonth`,
`ZonedDateTime`,
] as const
type TemporalConstructorName = (typeof temporalConstructorNames)[number]
type TemporalConstructor = {
from: (value: string) => unknown
prototype?: { toString?: () => string }
}
function getTemporalConstructorName(
type: unknown,
): TemporalConstructorName | undefined {
if (typeof type !== `string` || !type.startsWith(`Temporal.`)) return
const constructorName = type.slice(
`Temporal.`.length,
) as TemporalConstructorName
return temporalConstructorNames.includes(constructorName)
? constructorName
: undefined
}
function requireTemporalConstructor(
name: TemporalConstructorName,
): TemporalConstructor {
const constructor = (
globalThis as {
Temporal?: Partial<Record<TemporalConstructorName, TemporalConstructor>>
}
).Temporal?.[name]
if (typeof constructor?.from !== `function`)
throw new MissingTemporalConstructorError(
`Missing global Temporal.${name} constructor`,
)
return constructor
}
function serializeTemporalValue(
value: object,
name: TemporalConstructorName,
): string | undefined {
const constructor = requireTemporalConstructor(name)
const toString = constructor.prototype?.toString
if (typeof toString !== `function` || toString === Object.prototype.toString)
return
try {
const serialized = toString.call(value)
return typeof serialized === `string` ? serialized : undefined
} catch {
// Temporal prototype methods brand-check their receiver. A matching
// Symbol.toStringTag without the corresponding internal slots is user data.
return
}
}
function mayBeNativeScalar(value: object): boolean {
const prototype = Object.getPrototypeOf(value)
return (
prototype !== null &&
prototype !== Object.prototype &&
!Array.isArray(value)
)
}
export class MissingTemporalConstructorError extends Error {}
function setDataProperty(
object: Record<string, unknown>,
key: string,
value: unknown,
): void {
if (key !== `__proto__`) object[key] = value
else
Object.defineProperty(object, key, {
value,
enumerable: true,
configurable: true,
writable: true,
})
}
function assertOutboxPhase(
phase: unknown,
): asserts phase is `deletion-pending` | `rejection-pending` | undefined {
if (
phase !== undefined &&
phase !== `deletion-pending` &&
phase !== `rejection-pending`
)
throw new Error(`Unsupported transaction outbox phase`)
}
export class TransactionSerializer {
private collections: Record<string, Collection<any, any, any, any, any>>
private collectionIdToKey: Map<string, string>
constructor(
collections: Record<string, Collection<any, any, any, any, any>>,
) {
this.collections = collections
// Create reverse lookup from collection.id to registry key
this.collectionIdToKey = new Map()
for (const [key, collection] of Object.entries(collections)) {
this.collectionIdToKey.set(collection.id, key)
}
}
serialize(transaction: OfflineTransaction): string {
assertOutboxPhase(transaction.outboxPhase)
const serialized: SerializedOfflineTransaction = {
...transaction,
valueEncoding: 3,
createdAt: transaction.createdAt.toISOString(),
metadata: this.serializeValue(transaction.metadata, `metadata`),
mutations: transaction.mutations.map((mutation) =>
this.serializeMutation(mutation),
),
}
return JSON.stringify(serialized)
}
deserialize(data: string): OfflineTransaction {
// Old records retain their Date-marker meaning. New records also escape
// marker-shaped user objects; the encoding tag is not application metadata.
const {
valueEncoding,
outboxPhase,
...parsed
}: Omit<SerializedOfflineTransaction, `valueEncoding` | `outboxPhase`> & {
valueEncoding?: unknown
outboxPhase?: unknown
} = JSON.parse(data)
if (
valueEncoding !== undefined &&
valueEncoding !== 2 &&
valueEncoding !== 3
) {
throw new Error(
`Unsupported transaction value encoding: ${valueEncoding}`,
)
}
assertOutboxPhase(outboxPhase)
const createdAt = new Date(parsed.createdAt)
if (isNaN(createdAt.getTime())) {
throw new Error(
`Failed to deserialize transaction: invalid createdAt value "${parsed.createdAt}"`,
)
}
return {
...parsed,
...(outboxPhase === undefined ? {} : { outboxPhase }),
createdAt,
metadata:
valueEncoding === 3
? this.deserializeValue(parsed.metadata, valueEncoding)
: parsed.metadata,
mutations: parsed.mutations.map((mutationData) =>
this.deserializeMutation(mutationData, valueEncoding),
),
}
}
private serializeMutation(mutation: PendingMutation): SerializedMutation {
const registryKey = this.collectionIdToKey.get(mutation.collection.id)
if (!registryKey) {
throw new Error(
`Collection with id ${mutation.collection.id} not found in registry`,
)
}
return {
globalKey: mutation.globalKey,
type: mutation.type,
modified: this.serializeValue(mutation.modified),
original: this.serializeValue(mutation.original),
changes: this.serializeValue(mutation.changes),
collectionId: registryKey, // Store registry key instead of collection.id
}
}
private deserializeMutation(
data: SerializedMutation,
valueEncoding: 2 | 3 | undefined,
): PendingMutation {
const collection = this.collections[data.collectionId]
if (!collection) {
throw new Error(`Collection with id ${data.collectionId} not found`)
}
const modified = this.deserializeValue(data.modified, valueEncoding)
// Extract the key from the modified data using the collection's getKey function
// This is needed for optimistic state restoration to work correctly
const key = modified ? collection.getKeyFromItem(modified) : null
// Create a partial PendingMutation - we can't fully reconstruct it but
// we provide what we can. The executor will need to handle the rest.
return {
globalKey: data.globalKey,
type: data.type as any,
modified,
original: this.deserializeValue(data.original, valueEncoding),
changes: this.deserializeValue(data.changes, valueEncoding) ?? {},
collection,
// These fields would need to be reconstructed by the executor
mutationId: ``, // Will be regenerated
key,
metadata: undefined,
syncMetadata: {},
optimistic: true,
createdAt: new Date(),
updatedAt: new Date(),
} as PendingMutation
}
private serializeValue(
value: any,
jsonKey?: string | false,
ancestors = new WeakSet<object>(),
): any {
if (value === null || typeof value !== `object`) return value
if (jsonKey !== false && value instanceof Date) {
return { __type: `Date`, value: value.toISOString() }
}
const temporalConstructorName =
jsonKey !== false && mayBeNativeScalar(value)
? getTemporalConstructorName(value[Symbol.toStringTag])
: undefined
if (temporalConstructorName) {
const temporalValue = serializeTemporalValue(
value,
temporalConstructorName,
)
if (temporalValue !== undefined)
return {
__type: `Temporal`,
type: `Temporal.${temporalConstructorName}`,
value: temporalValue,
}
}
if (ancestors.has(value))
throw new TypeError(`Converting circular structure to JSON`)
const toJSON = typeof jsonKey === `string` && value.toJSON
if (typeof toJSON === `function`) {
const replacement = toJSON.call(value, jsonKey)
if (replacement === value) {
return this.serializeValue(replacement, false, ancestors)
}
ancestors.add(value)
try {
return this.serializeValue(replacement, false, ancestors)
} finally {
ancestors.delete(value)
}
}
if (
jsonKey !== undefined &&
(value instanceof Boolean ||
value instanceof BigInt ||
value instanceof Number ||
value instanceof String)
) {
return value.valueOf()
}
ancestors.add(value)
const isArray = Array.isArray(value)
const result: any = isArray ? [] : {}
try {
if (isArray) {
const length = value.length
for (let index = 0; index < length; index++) {
const key = String(index)
setDataProperty(
result,
key,
this.serializeValue(
value[index],
jsonKey === undefined ? undefined : key,
ancestors,
),
)
}
} else {
for (const key of Object.keys(value)) {
setDataProperty(
result,
key,
this.serializeValue(
value[key],
jsonKey === undefined ? undefined : key,
ancestors,
),
)
}
}
} finally {
ancestors.delete(value)
}
if (jsonKey === false && typeof result.toJSON === `function`)
delete result.toJSON
return !isArray && Object.prototype.hasOwnProperty.call(value, `__type`)
? { __type: `Object`, value: result }
: result
}
private deserializeValue(value: any, valueEncoding: 2 | 3 | undefined): any {
if (value === null || value === undefined) {
return value
}
if (typeof value === `object` && value.__type === `Date`) {
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 (
valueEncoding === 3 &&
typeof value === `object` &&
value.__type === `Temporal`
) {
const constructorName = getTemporalConstructorName(value.type)
if (!constructorName)
throw new Error(`Corrupted Temporal marker: invalid type field`)
if (typeof value.value !== `string`)
throw new Error(`Corrupted Temporal marker: missing value field`)
return requireTemporalConstructor(constructorName).from(value.value)
}
if (typeof value === `object`) {
// Unwrap once, then decode only the fields: the object's own __type is data.
if (valueEncoding !== undefined && value.__type === `Object`) {
if (
value.value === null ||
typeof value.value !== `object` ||
Array.isArray(value.value)
) {
throw new Error(`Corrupted Object marker: expected an object value`)
}
value = value.value
}
const result: any = Array.isArray(value) ? [] : {}
for (const key in value) {
if (Object.prototype.hasOwnProperty.call(value, key)) {
setDataProperty(
result,
key,
this.deserializeValue(value[key], valueEncoding),
)
}
}
return result
}
return value
}
serializeError(error: Error): SerializedError {
return {
name: error.name,
message: error.message,
stack: error.stack,
}
}
deserializeError(data: SerializedError): Error {
const error = new Error(data.message)
error.name = data.name
error.stack = data.stack
return error
}
}