UNPKG

@tanstack/offline-transactions

Version:

Offline-first transaction capabilities for TanStack DB

404 lines (367 loc) • 11.9 kB
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 } }