···11+export * from './state.ts'
22+export * from './storage.ts'
+180
packages/store/state.ts
···11+/**
22+ * @module
33+ * Generic state and listener class used by all the simple-tools. This helps us:
44+ * 1. Get event listening for free in all of our tools for UI hookin
55+ * 2. Help define a structure for public state
66+ *
77+ * State is meant to be extended into Classes, and used with an Object state
88+ */
99+import type Storage from './storage.ts'
1010+1111+/** Options to modify how State works */
1212+export interface Options<T> {
1313+ /** Optional storage mechanism for saving state outside of memory */
1414+ storage?: Storage<T>
1515+}
1616+1717+/**
1818+ * Default options for State. Add these during state construction:
1919+ * `super(defaultState, options)`
2020+ */
2121+export const DefaultOptions: Options<unknown> = {}
2222+2323+/**
2424+ * State Class
2525+ * @example Basic Usage (See more in state.test.ts)
2626+ * ```ts
2727+ * import State from '@inro/simple-tools/state'
2828+ *
2929+ * class Counter extends State<{ count: number }> {
3030+ * constructor(count: number = 0) {
3131+ * super({ count }) // Super params are the initial value of `this.state`
3232+ * }
3333+ * increment() {
3434+ * this.state.count++
3535+ * this.notify() // Triggers all listeners
3636+ * }
3737+ * }
3838+ *
3939+ * const counter = new Counter(0)
4040+ * counter.addEventListener((state) => { console.log(state.count) })
4141+ * counter.increment()
4242+ * ```
4343+ */
4444+export default class State<InternalState extends object> {
4545+ #storage?: Storage<InternalState>
4646+ #isBatchingUpdates = false
4747+ #isPendingNotification = false
4848+ #options = DefaultOptions as Options<InternalState>
4949+ #state: InternalState
5050+ #watchers: Array<(state: InternalState) => void> = []
5151+5252+ /** Error returned from load/save */
5353+ error: Error | null = null
5454+ /** State has initial value successfully loaded */
5555+ initialized = false
5656+ /** State is currently loading data */
5757+ loading = false
5858+ /** State is currently saving data */
5959+ saving = false
6060+6161+ /**
6262+ * Define public state on initialization
6363+ * @param state The initial state for the app
6464+ */
6565+ constructor(state: InternalState, options?: Partial<Options<InternalState>>) {
6666+ this.#options = { ...this.#options, ...options }
6767+ this.#state = state
6868+ if (!options?.storage) {
6969+ this.initialized = true
7070+ } else {
7171+ this.#storage = options.storage
7272+ this.load(async () => {
7373+ const intialState = (await this.#storage?.get()) ?? state
7474+ this.initialized = true
7575+ return intialState
7676+ })
7777+ }
7878+ }
7979+8080+ /**
8181+ * Returns a reference to the state.
8282+ * This is used for easy editing state as well.
8383+ */
8484+ get state(): InternalState {
8585+ return this.#state
8686+ }
8787+8888+ /** Adds an event listener. Triggered by `this.notify` */
8989+ addEventListener(func: (state: InternalState) => void) {
9090+ this.#watchers.push(func)
9191+ }
9292+9393+ /**
9494+ * Runs the provided function in a batch update context
9595+ * @param updateFn Function that will make updates to the state
9696+ */
9797+ batch<T = void>(updateFn: (state: InternalState) => T): T {
9898+ let resp: T
9999+ this.#isBatchingUpdates = true
100100+ try {
101101+ resp = updateFn(this.state)
102102+ } finally {
103103+ this.#isBatchingUpdates = false
104104+ if (this.#isPendingNotification) {
105105+ this.notify()
106106+ this.#isPendingNotification = false
107107+ }
108108+ }
109109+ return resp
110110+ }
111111+112112+ /** Load state from somewhere */
113113+ async load(loader: () => Promise<InternalState>): Promise<void> {
114114+ this.loading = true
115115+ this.notify()
116116+ try {
117117+ const result = await loader()
118118+ this.#state = { ...this.#state, ...result }
119119+ this.error = null
120120+ } catch (err) {
121121+ this.error = err as Error
122122+ } finally {
123123+ this.loading = false
124124+ this.notify()
125125+ }
126126+ }
127127+128128+ /**
129129+ * Notify only returns a COPY of the state.
130130+ * This is because notify is often used to track history over time,
131131+ * so a reference to a mutating state is not useful.
132132+ */
133133+ notify({ bypassSave = false }: { bypassSave?: boolean } = {}) {
134134+ if (this.#isBatchingUpdates) {
135135+ this.#isPendingNotification = true
136136+ return
137137+ }
138138+ if (this.initialized && this.#storage && !bypassSave) {
139139+ this.save(async () => {
140140+ await this.#storage?.set(this.#state)
141141+ return true
142142+ })
143143+ }
144144+ this.#watchers.forEach((cb) => cb({ ...this.#state }))
145145+ }
146146+147147+ /** Removes an event listener. */
148148+ removeEventListener(func: (state: InternalState) => void) {
149149+ this.#watchers = this.#watchers.filter((watcher) => watcher !== func)
150150+ }
151151+152152+ /** Saves state to somewhere */
153153+ async save(saver: (state: InternalState) => Promise<boolean>): Promise<void> {
154154+ this.saving = true
155155+ this.notify({ bypassSave: true })
156156+ try {
157157+ await saver(this.#state)
158158+ this.error = null
159159+ } catch (err) {
160160+ this.error = err as Error
161161+ } finally {
162162+ this.saving = false
163163+ this.notify({ bypassSave: true })
164164+ }
165165+ }
166166+167167+ /** Resolves the next time that state is ready */
168168+ waitUntilReady(): Promise<boolean> {
169169+ return new Promise((resolve) => {
170170+ if (this.initialized && !this.loading && !this.saving) resolve(true)
171171+ const listener = () => {
172172+ if (this.initialized && !this.loading && !this.saving) {
173173+ this.removeEventListener(listener)
174174+ resolve(true)
175175+ }
176176+ }
177177+ this.addEventListener(listener)
178178+ })
179179+ }
180180+}
+660
packages/store/storage.ts
···11+/**
22+ * @module
33+ * Creates a common interface for interacting with different storage mechanisms.
44+ * Specifically, designed for dealing with key-value mechanisms where the value
55+ * is a collection entity. So if you want to deal with two different items in
66+ * LocalStorage, you would create TWO LocalStorage classes; one for each key.
77+ *
88+ * Provides type-safety by forcing declaration of serialization mechanisms
99+ * and internally tracks metadata (createdAt, updatedAt) for synchronization.
1010+ *
1111+ * Migrations Execution order:
1212+ * 1. Load data from storage
1313+ * 2. Extract version from data
1414+ * 3. Compare versions
1515+ * 4. IF mismatch AND onVersionMismatch exists:
1616+ * - Call onVersionMismatch
1717+ * - Handle return value
1818+ * 5. ELSE IF mismatch:
1919+ * - Build migration chain
2020+ * - Apply migrations
2121+ * 6. Return final data
2222+ */
2323+2424+export enum MigrationMismatchAction {
2525+ Continue = 'continue',
2626+ UseCurrent = 'use-current',
2727+ UseDefault = 'use-default',
2828+}
2929+const { Continue, UseCurrent, UseDefault } = MigrationMismatchAction
3030+3131+export enum ErrorCode {
3232+ NoPath = 'migration-no-path',
3333+ BrokenChain = 'migration-broken-chain',
3434+ MigrationFailed = 'migration-failed',
3535+}
3636+3737+/**
3838+ * Version can be a string (e.g., "1.0.0") or a number (e.g., 1, 2, 3)
3939+ */
4040+export type Version = string | number
4141+4242+/**
4343+ * Default version comparison function
4444+ * Returns: negative if v1 < v2, zero if v1 === v2, positive if v1 > v2
4545+ */
4646+export function defaultCompareVersions(
4747+ v1: Version | undefined,
4848+ v2: Version | undefined,
4949+): number {
5050+ // Handle undefined (treat as version 0)
5151+ if (v1 === undefined && v2 === undefined) return 0
5252+ if (v1 === undefined) return -1
5353+ if (v2 === undefined) return 1
5454+5555+ // Both are numbers
5656+ if (typeof v1 === 'number' && typeof v2 === 'number') {
5757+ return v1 - v2
5858+ }
5959+6060+ // Both are strings
6161+ if (typeof v1 === 'string' && typeof v2 === 'string') {
6262+ if (v1 === v2) return 0
6363+ return v1 < v2 ? -1 : 1
6464+ }
6565+6666+ // Mixed types: convert to strings and compare
6767+ const s1 = String(v1)
6868+ const s2 = String(v2)
6969+ if (s1 === s2) return 0
7070+ return s1 < s2 ? -1 : 1
7171+}
7272+7373+/**
7474+ * Internal metadata wrapper for storage items
7575+ */
7676+export interface StorageMetadata<T> {
7777+ /** The actual user data */
7878+ data: T
7979+ /** When the item was first created */
8080+ createdAt: string
8181+ /** When the item was last updated */
8282+ updatedAt: string
8383+ /** Schema version of the data (optional, used for migrations) */
8484+ version?: Version
8585+}
8686+8787+/**
8888+ * Migration function that upgrades data from one version to the next
8989+ * @param data The data to migrate (type unknown as it may be from an older schema)
9090+ * @returns Migrated data (unknown since intermediate steps may have different types)
9191+ */
9292+export type MigrationFunction = (data: unknown) => unknown
9393+9494+/**
9595+ * Single migration step in a migration chain
9696+ */
9797+export interface MigrationStep {
9898+ /** Version this migration upgrades FROM (undefined means no version field exists) */
9999+ fromVersion: Version | undefined
100100+ /** Version this migration upgrades TO */
101101+ toVersion: Version
102102+ /** Migration function to apply */
103103+ migrate: MigrationFunction
104104+ /**
105105+ * Extract version from data for this specific migration step
106106+ * Useful when different versions store version info differently
107107+ * If not provided, falls back to the global extractVersion
108108+ */
109109+ extractVersion?: (data: unknown) => Version | undefined
110110+}
111111+112112+/**
113113+ * Configuration for schema migrations
114114+ */
115115+export interface MigrationConfig<T> {
116116+ /** Current schema version */
117117+ currentVersion: Version
118118+ /**
119119+ * Ordered array of migration steps
120120+ * Each step migrates from version N to version N+1
121121+ * Example: [v0->v1, v1->v2, v2->v3] will chain automatically
122122+ */
123123+ migrations: MigrationStep[]
124124+ /**
125125+ * Default function to extract version from data
126126+ * Can be overridden per migration step
127127+ */
128128+ extractVersion?: (data: unknown) => Version | undefined
129129+ /**
130130+ * Function to compare two versions
131131+ * Returns: negative if v1 < v2, zero if v1 === v2, positive if v1 > v2
132132+ *
133133+ * Default implementation:
134134+ * - If both are numbers: numeric comparison
135135+ * - If both are strings: lexicographic comparison
136136+ * - Mixed types: convert to strings and compare
137137+ *
138138+ * Override for semantic versioning or custom version schemes
139139+ */
140140+ compareVersions?: (v1: Version | undefined, v2: Version | undefined) => number
141141+ /**
142142+ * Called when stored data version doesn't match current version
143143+ * Provides opportunity to handle version mismatches before attempting migration chain
144144+ *
145145+ * Common use cases:
146146+ * - Handle data from newer app versions (e.g., trigger cache refresh)
147147+ * - Implement custom fallback strategies
148148+ * - Log version mismatch analytics
149149+ *
150150+ * @param dataVersion Version of the stored data
151151+ * @param currentVersion Current app/schema version
152152+ * @param data The stored data that has version mismatch
153153+ * @returns Strategy for handling the mismatch:
154154+ * - 'continue': Proceed with normal migration chain (may fail if no path exists)
155155+ * - 'use-current': Use current data as-is (bypass migration)
156156+ * - 'use-default': Use default value (ignore stored data)
157157+ * - Custom data: Use provided data directly
158158+ */
159159+ onVersionMismatch?: (
160160+ dataVersion: Version | undefined,
161161+ currentVersion: Version,
162162+ data: unknown,
163163+ ) => T | MigrationMismatchAction
164164+ /**
165165+ * Called after migration/version handling is complete, before returning final data
166166+ * Useful for final parsing/validation (e.g., Zod transforms, date string parsing)
167167+ *
168168+ * This runs regardless of whether:
169169+ * - No migration was needed (versions matched)
170170+ * - onVersionMismatch returned 'use-current'
171171+ * - Migration chain was applied
172172+ * - Custom data was returned from onVersionMismatch
173173+ *
174174+ * @param data The data after migration processing
175175+ * @param wasMigrated Whether any migration/transformation occurred
176176+ * @returns Final processed data
177177+ */
178178+ onMigrationComplete?: (data: unknown, wasMigrated: boolean) => T
179179+}
180180+181181+/**
182182+ * Storage error information passed to onError handler
183183+ */
184184+export interface StorageError {
185185+ /** Error code identifying the type of error */
186186+ code: ErrorCode
187187+ /** Human-readable error message */
188188+ message: string
189189+ /** Version being migrated from (if applicable) */
190190+ fromVersion?: Version
191191+ /** Version being migrated to (if applicable) */
192192+ toVersion?: Version
193193+ /** Original error cause (if migration function threw) */
194194+ cause?: Error
195195+ /** The data that failed to migrate/process */
196196+ data: unknown
197197+}
198198+199199+/**
200200+ * Error class for storage-related errors
201201+ * Extends Error with StorageError properties
202202+ */
203203+export class StorageErrorClass extends Error implements StorageError {
204204+ code: ErrorCode
205205+ fromVersion?: Version
206206+ toVersion?: Version
207207+ override cause?: Error
208208+ data: unknown
209209+210210+ constructor(error: StorageError) {
211211+ super(error.message)
212212+ this.name = 'StorageError'
213213+ this.code = error.code
214214+ this.fromVersion = error.fromVersion
215215+ this.toVersion = error.toVersion
216216+ this.cause = error.cause
217217+ this.data = error.data
218218+ }
219219+}
220220+221221+/**
222222+ * Provided functionality to tell storage how to interact with your data
223223+ */
224224+export interface StorageProps<T> {
225225+ /** Default value, if the item doesn't exist within storage */
226226+ defaultValue: T
227227+ /** Function for deserializing data from the external data source */
228228+ deserialize: (str: string) => T
229229+ /** Key name that external data is stored under */
230230+ name: string
231231+ /** Function for serializing data to external data source */
232232+ serialize: (toSerialize: T) => string
233233+ /** Function for determining whether a value matches our expected data */
234234+ verify: (toCheck: unknown) => boolean
235235+ /**
236236+ * Handler for exceptional storage errors (migrations, etc.)
237237+ * If not provided, errors will throw by default
238238+ * Return value will be used as the data, or throw to propagate error
239239+ *
240240+ * Common patterns:
241241+ * - Return defaultValue to recover from errors
242242+ * - Log and throw custom error
243243+ * - Return data as-is (dangerous for broken migrations!)
244244+ */
245245+ onError?: (error: StorageError) => T
246246+ /** Optional migration configuration for schema versioning */
247247+ migrations?: MigrationConfig<T>
248248+}
249249+250250+/**
251251+ * Storage Class is not meant to be used by itself. Extend it with different
252252+ * functionality and different storage systems.
253253+ *
254254+ * @example
255255+ * ```ts
256256+ * import LocalStorage from '@inro/simple-tools/storage/local-storage'
257257+ *
258258+ * const store = new LocalStorage<{ count: number } | null>({
259259+ * name: 'count',
260260+ * defaultValue: null,
261261+ * deserialize: (str) => str ? JSON.parse(str) : null,
262262+ * serialize: (state) => JSON.stringify(state),
263263+ * verify: (state) => Boolean((state as { count?: number })?.count),
264264+ * })
265265+ * await store.set({ count: 5 })
266266+ * await store.get()
267267+ * ````
268268+ */
269269+export default class Storage<T> implements StorageProps<T> {
270270+ /** Initializes storage with props */
271271+ constructor(props: StorageProps<T>) {
272272+ this.name = props.name
273273+ this.defaultValue = props.defaultValue
274274+ this.deserialize = props.deserialize
275275+ this.serialize = props.serialize
276276+ this.verify = props.verify
277277+ this.onError = props.onError
278278+ this.migrations = props.migrations
279279+ }
280280+281281+ /** Key for finding in storage */
282282+ name: string
283283+284284+ /** Default value if there is no value in storage */
285285+ defaultValue: T
286286+287287+ /** Transform from database entity to js object */
288288+ deserialize: (str: string) => T
289289+290290+ /** Transform from js object to database entity */
291291+ serialize: (toStringify: T) => string
292292+293293+ /** Predicate function that returns true if it is the correct entity */
294294+ verify: (toCheck: unknown) => boolean
295295+296296+ /** Error handler for exceptional storage errors */
297297+ onError?: (error: StorageError) => T
298298+299299+ /** Optional migration configuration */
300300+ migrations?: MigrationConfig<T>
301301+302302+ /** Check if a value exists in storage */
303303+ has(): Promise<boolean> {
304304+ throw new Error('not implemented')
305305+ }
306306+307307+ /** Retrieve a value from storage */
308308+ get(): Promise<T> {
309309+ throw new Error('not implemented')
310310+ }
311311+312312+ /** Add a value to storage */
313313+ set(_value: T): Promise<boolean> {
314314+ throw new Error('not implemented')
315315+ }
316316+317317+ /** Remove a value from storage */
318318+ remove(): Promise<boolean> {
319319+ throw new Error('not implemented')
320320+ }
321321+322322+ /** Deserialize, returning defaultValue if an error occurs */
323323+ safeParse(toParse: string): T {
324324+ try {
325325+ if (!toParse) return this.defaultValue
326326+ return this.deserialize(toParse)
327327+ } catch {
328328+ return this.defaultValue
329329+ }
330330+ }
331331+332332+ /** Get current ISO timestamp */
333333+ protected now(): string {
334334+ return new Date().toISOString()
335335+ }
336336+337337+ /**
338338+ * Get the version comparison function (user-provided or default)
339339+ */
340340+ protected getCompareVersions(): (
341341+ v1: Version | undefined,
342342+ v2: Version | undefined,
343343+ ) => number {
344344+ return this.migrations?.compareVersions ?? defaultCompareVersions
345345+ }
346346+347347+ /**
348348+ * Extract version from data using step-specific or global extractor
349349+ * @param data The data to extract version from
350350+ * @param step Optional migration step with its own extractor
351351+ * @returns Version or undefined
352352+ */
353353+ protected extractVersionFromData(
354354+ data: unknown,
355355+ step?: MigrationStep,
356356+ ): Version | undefined {
357357+ // Try step-specific extractor first
358358+ if (step?.extractVersion) {
359359+ const version = step.extractVersion(data)
360360+ if (version !== undefined) return version
361361+ }
362362+363363+ // Try global extractor
364364+ if (this.migrations?.extractVersion) {
365365+ const version = this.migrations.extractVersion(data)
366366+ if (version !== undefined) return version
367367+ }
368368+369369+ // If no specific step provided, try all step extractors
370370+ if (!step && this.migrations?.migrations) {
371371+ for (const migrationStep of this.migrations.migrations) {
372372+ if (migrationStep.extractVersion) {
373373+ const version = migrationStep.extractVersion(data)
374374+ if (version !== undefined) return version
375375+ }
376376+ }
377377+ }
378378+379379+ return undefined
380380+ }
381381+382382+ /**
383383+ * Build a chain of migrations from start version to end version
384384+ * @param fromVersion Starting version (undefined for v0/no version)
385385+ * @param toVersion Target version
386386+ * @param data The data being migrated (for error reporting)
387387+ * @returns Array of migration steps to apply in order
388388+ * @throws StorageErrorClass if no migration path exists or chain is broken
389389+ */
390390+ protected buildMigrationChain(
391391+ fromVersion: Version | undefined,
392392+ toVersion: Version,
393393+ data: unknown,
394394+ ): MigrationStep[] {
395395+ if (!this.migrations) return []
396396+397397+ const compareVersions = this.getCompareVersions()
398398+ const chain: MigrationStep[] = []
399399+ let currentVersion = fromVersion
400400+401401+ // Keep finding next migration step until we reach target version
402402+ while (compareVersions(currentVersion, toVersion) !== 0) {
403403+ const nextStep = this.migrations.migrations.find(
404404+ (step) => compareVersions(step.fromVersion, currentVersion) === 0,
405405+ )
406406+407407+ if (!nextStep) {
408408+ // No migration path found
409409+ if (chain.length === 0) {
410410+ throw new StorageErrorClass({
411411+ code: ErrorCode.NoPath,
412412+ message:
413413+ `No migration found from version ${currentVersion} to ${toVersion}`,
414414+ fromVersion: currentVersion,
415415+ toVersion,
416416+ data,
417417+ })
418418+ } else {
419419+ throw new StorageErrorClass({
420420+ code: ErrorCode.BrokenChain,
421421+ message:
422422+ `Migration chain broken at version ${currentVersion}, cannot reach ${toVersion}`,
423423+ fromVersion: currentVersion,
424424+ toVersion,
425425+ data,
426426+ })
427427+ }
428428+ }
429429+430430+ chain.push(nextStep)
431431+ currentVersion = nextStep.toVersion
432432+ }
433433+434434+ return chain
435435+ }
436436+437437+ /**
438438+ * Apply migrations by chaining from old version to current version
439439+ * Example: v0 -> v1 -> v2 -> v3
440440+ * @param data The data to migrate
441441+ * @param dataVersion The version of the data
442442+ * @returns Migrated data
443443+ * @throws StorageErrorClass if migration fails and no onError handler is provided
444444+ */
445445+ protected applyMigrations(
446446+ data: unknown,
447447+ dataVersion: Version | undefined,
448448+ ): T {
449449+ if (!this.migrations) return data as T
450450+451451+ const compareVersions = this.getCompareVersions()
452452+ const currentVersion = this.migrations.currentVersion
453453+ let finalData: unknown = data
454454+ let wasMigrated = false
455455+456456+ // No migration needed if versions are equal
457457+ if (compareVersions(dataVersion, currentVersion) === 0) {
458458+ return this.migrations.onMigrationComplete
459459+ ? this.migrations.onMigrationComplete(data, false)
460460+ : data as T
461461+ }
462462+463463+ // Check if there's a version mismatch handler
464464+ if (this.migrations.onVersionMismatch) {
465465+ const result = this.migrations.onVersionMismatch(
466466+ dataVersion,
467467+ currentVersion,
468468+ data,
469469+ )
470470+471471+ if (result === UseCurrent) {
472472+ finalData = data
473473+ wasMigrated = false
474474+ } else if (result === UseDefault) {
475475+ finalData = this.defaultValue
476476+ wasMigrated = true
477477+ } else if (result !== Continue) {
478478+ finalData = result
479479+ wasMigrated = true
480480+ } else {
481481+ // Continue with normal migration chain
482482+ try {
483483+ finalData = this.performMigrationChain(
484484+ data,
485485+ dataVersion,
486486+ currentVersion,
487487+ )
488488+ wasMigrated = true
489489+ } catch (error) {
490490+ if (error instanceof StorageErrorClass && this.onError) {
491491+ finalData = this.onError(error)
492492+ wasMigrated = true
493493+ } else {
494494+ throw error
495495+ }
496496+ }
497497+ }
498498+ } else {
499499+ // No onVersionMismatch handler, proceed with migration chain
500500+ try {
501501+ finalData = this.performMigrationChain(
502502+ data,
503503+ dataVersion,
504504+ currentVersion,
505505+ )
506506+ wasMigrated = true
507507+ } catch (error) {
508508+ if (error instanceof StorageErrorClass && this.onError) {
509509+ finalData = this.onError(error)
510510+ wasMigrated = true
511511+ } else {
512512+ throw error
513513+ }
514514+ }
515515+ }
516516+517517+ // Apply final processing hook if provided
518518+ return this.migrations.onMigrationComplete
519519+ ? this.migrations.onMigrationComplete(finalData, wasMigrated)
520520+ : finalData as T
521521+ }
522522+523523+ /**
524524+ * Helper method to perform the actual migration chain
525525+ */
526526+ private performMigrationChain(
527527+ data: unknown,
528528+ dataVersion: Version | undefined,
529529+ currentVersion: Version,
530530+ ): unknown {
531531+ const chain = this.buildMigrationChain(dataVersion, currentVersion, data)
532532+533533+ let migratedData = data
534534+ for (const step of chain) {
535535+ const { fromVersion, toVersion } = step
536536+ console.info(`Applying migration: ${fromVersion} -> ${toVersion}`)
537537+ try {
538538+ migratedData = step.migrate(migratedData)
539539+ } catch (error) {
540540+ console.error(`Migration failed: ${fromVersion} -> ${toVersion}`, error)
541541+ throw new StorageErrorClass({
542542+ code: ErrorCode.MigrationFailed,
543543+ message: `Migration failed at step ${fromVersion} -> ${toVersion}: ${
544544+ error instanceof Error ? error.message : String(error)
545545+ }`,
546546+ fromVersion,
547547+ toVersion,
548548+ cause: error instanceof Error ? error : undefined,
549549+ data: migratedData,
550550+ })
551551+ }
552552+ }
553553+554554+ return migratedData
555555+ }
556556+557557+ /** Wrap user data with metadata */
558558+ protected wrapWithMetadata(
559559+ data: T,
560560+ existingMetadata?: StorageMetadata<T>,
561561+ ): StorageMetadata<T> {
562562+ const now = this.now()
563563+ return {
564564+ data,
565565+ createdAt: existingMetadata?.createdAt ?? now,
566566+ updatedAt: now,
567567+ version: this.migrations?.currentVersion,
568568+ }
569569+ }
570570+571571+ /** Parse metadata wrapper, returning defaultValue if invalid */
572572+ parseMetadata(toParse: string): StorageMetadata<T> {
573573+ try {
574574+ if (!toParse) return this.wrapWithMetadata(this.defaultValue)
575575+576576+ const parsed = JSON.parse(toParse)
577577+578578+ if (
579579+ parsed && typeof parsed === 'object' && 'data' in parsed &&
580580+ 'createdAt' in parsed && 'updatedAt' in parsed
581581+ ) {
582582+ let data: unknown = typeof parsed.data === 'string'
583583+ ? this.safeParse(parsed.data)
584584+ : parsed.data
585585+586586+ // Extract version from metadata wrapper or from data itself
587587+ const metadataVersion = parsed.version
588588+ const dataVersion = metadataVersion ?? this.extractVersionFromData(data)
589589+590590+ if (this.migrations) {
591591+ const compare = this.getCompareVersions()
592592+ if (compare(dataVersion, this.migrations.currentVersion) !== 0) {
593593+ data = this.applyMigrations(data, dataVersion)
594594+ }
595595+ }
596596+597597+ if (this.verify(data)) {
598598+ return {
599599+ data: data as T,
600600+ createdAt: parsed.createdAt,
601601+ updatedAt: parsed.updatedAt,
602602+ version: this.migrations?.currentVersion,
603603+ }
604604+ }
605605+ }
606606+607607+ // If JSON.parse succeeded but it's not valid metadata format,
608608+ // treat it as raw data (backward compatibility)
609609+ let rawData: unknown = this.safeParse(toParse)
610610+ const dataVersion = this.extractVersionFromData(rawData)
611611+612612+ if (this.migrations) {
613613+ const compare = this.getCompareVersions()
614614+ if (compare(dataVersion, this.migrations.currentVersion) !== 0) {
615615+ rawData = this.applyMigrations(rawData, dataVersion)
616616+ }
617617+ }
618618+619619+ return this.wrapWithMetadata(rawData as T)
620620+ } catch (error) {
621621+ // Rethrow StorageErrors - they've already been through onError handler
622622+ if (error instanceof StorageErrorClass) throw error
623623+624624+ // JSON.parse failed, so this might be raw data (backward compatibility)
625625+ let rawData: unknown = this.safeParse(toParse)
626626+ const dataVersion = this.extractVersionFromData(rawData)
627627+628628+ if (this.migrations) {
629629+ const compare = this.getCompareVersions()
630630+ if (compare(dataVersion, this.migrations.currentVersion) !== 0) {
631631+ rawData = this.applyMigrations(rawData, dataVersion)
632632+ }
633633+ }
634634+635635+ return this.wrapWithMetadata(rawData as T)
636636+ }
637637+ }
638638+639639+ /** Serialize metadata wrapper */
640640+ protected serializeMetadata(metadata: StorageMetadata<T>): string {
641641+ return JSON.stringify({
642642+ data: this.serialize(metadata.data),
643643+ createdAt: metadata.createdAt,
644644+ updatedAt: metadata.updatedAt,
645645+ version: metadata.version,
646646+ })
647647+ }
648648+649649+ /** Get metadata for the stored item (useful for sync implementations) */
650650+ getMetadata(): Promise<StorageMetadata<T> | null> {
651651+ throw new Error('not implemented')
652652+ }
653653+654654+ /** Get metadata, creating it with current timestamp if no data exists */
655655+ async getOrCreateMetadata(): Promise<StorageMetadata<T>> {
656656+ const metadata = await this.getMetadata()
657657+ if (metadata === null) return this.wrapWithMetadata(this.defaultValue)
658658+ return metadata
659659+ }
660660+}