@vobs/storage 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 vobs contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,50 @@
1
+ # @vobs/storage
2
+
3
+ Versioned key-value storage for vobs with a JSON envelope, cross-tab change events, migration, and automatic memory fallback.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install @vobs/storage
9
+ ```
10
+
11
+ ## Quick start
12
+
13
+ ```ts
14
+ import { createStorage } from '@vobs/storage'
15
+
16
+ const storage = createStorage({
17
+ storage: 'local', // 'local', 'session', 'memory', or a custom StorageLike
18
+ prefix: 'myapp:',
19
+ version: 2,
20
+ migrate: (value, from, to) => (to === 2 ? { theme: value } : value)
21
+ })
22
+
23
+ storage.set('settings', { theme: 'dark' })
24
+ storage.get<{ theme: string }>('settings') // { theme: 'dark' }
25
+
26
+ const unsubscribe = storage.subscribe(change => {
27
+ change.source // 'local' for own writes, 'external' for other tabs
28
+ })
29
+ ```
30
+
31
+ Every value is written as an envelope carrying its version, so reads of older data run through `migrate` and are persisted back at the new version. Invalid JSON is reported as `CORRUPT_DATA`, removed, and reads return null. If the backend throws, the context degrades to the memory fallback and reports `STORAGE_UNAVAILABLE`. Writes from other tabs arrive through browser `storage` events with `source: 'external'`.
32
+
33
+ ## API
34
+
35
+ | Signature | Description |
36
+ | --- | --- |
37
+ | `createStorage(options?: StorageOptions): StorageContext` | Options: backend, `fallback`, `prefix` (default `vobs:`), `version`, `migrate`, `onError`. |
38
+ | `storage.get(key) / set(key, value) / remove(key)` | JSON round-trip under the configured prefix. |
39
+ | `storage.has(key) / keys() / clear()` | Prefix-scoped inspection and cleanup. |
40
+ | `storage.subscribe(listener)` | Receives `{ key, value, source }`; returns an unsubscribe function. |
41
+ | `storage.kind / persistent / prefix / version` | Current backend kind (`local`, `session`, `memory`, `custom`) and configuration. |
42
+ | `storage.dispose()` | Stops listeners; further use of the context throws. |
43
+ | `createMemoryStorage() / memoryStorage` | In-memory `StorageLike` for tests and SSR. |
44
+ | `storagePlugin(options?)` | Provide the context as `STORAGE_KEY`; disposes it on uninstall. |
45
+ | `useStorage()` | Inject the context installed by `storagePlugin`. |
46
+ | `StorageError` | Error with `code` (`STORAGE_UNAVAILABLE`, `CORRUPT_DATA`, `SERIALIZATION_FAILED`, `MIGRATION_FAILED`), `key`, and `cause`. |
47
+
48
+ ## Types
49
+
50
+ StorageType, StorageKind, StorageLike, StorageMigration, StorageOptions, StorageChange, StorageContext, StoragePluginOptions, StorageErrorCode
package/package.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "license": "MIT",
3
+ "files": [
4
+ "src",
5
+ "README.md",
6
+ "LICENSE"
7
+ ],
8
+ "name": "@vobs/storage",
9
+ "version": "1.0.0",
10
+ "type": "module",
11
+ "main": "src/index.ts",
12
+ "types": "src/index.ts",
13
+ "exports": {
14
+ ".": "./src/index.ts"
15
+ },
16
+ "dependencies": {
17
+ "@vobs/reactivity": "1.0.0",
18
+ "@vobs/vobs": "1.0.0"
19
+ }
20
+ }
@@ -0,0 +1,160 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import { createText, createVobs, createDOMRenderer, setRenderer } from '@vobs/vobs'
3
+ import {
4
+ STORAGE_KEY,
5
+ StorageError,
6
+ createMemoryStorage,
7
+ createStorage,
8
+ storagePlugin,
9
+ useStorage
10
+ } from './index'
11
+
12
+ describe('@vobs/storage', () => {
13
+ beforeEach(() => {
14
+ setRenderer(createDOMRenderer())
15
+ window.localStorage.clear()
16
+ window.sessionStorage.clear()
17
+ })
18
+
19
+ it('支持 JSON set/get、has、keys、remove 和 prefix 隔离', () => {
20
+ const storage = createStorage({ storage: 'memory', prefix: 'test:' })
21
+ storage.set('user', { id: '1', roles: ['admin'] })
22
+ storage.set('count', 2)
23
+
24
+ expect(storage.get('user')).toEqual({ id: '1', roles: ['admin'] })
25
+ expect(storage.has('count')).toBe(true)
26
+ expect(storage.keys()).toEqual(['user', 'count'])
27
+ expect(storage.get('missing')).toBeNull()
28
+ storage.remove('count')
29
+ expect(storage.has('count')).toBe(false)
30
+ expect(storage.keys()).toEqual(['user'])
31
+ storage.dispose()
32
+ })
33
+
34
+ it('优先使用 localStorage/sessionStorage,并在 SSR 或不可用时使用内存 fallback', () => {
35
+ const local = createStorage({ prefix: 'local:' })
36
+ const session = createStorage({ storage: 'session', prefix: 'session:' })
37
+ local.set('value', 'local')
38
+ session.set('value', 'session')
39
+ expect(window.localStorage.getItem('local:value')).toContain('local')
40
+ expect(window.sessionStorage.getItem('session:value')).toContain('session')
41
+ expect(local.kind).toBe('local')
42
+ expect(session.kind).toBe('session')
43
+
44
+ const broken: Storage = {
45
+ get length(): number { throw new Error('blocked') },
46
+ getItem(): string | null { throw new Error('blocked') },
47
+ setItem(): void { throw new Error('blocked') },
48
+ removeItem(): void { throw new Error('blocked') },
49
+ key(): string | null { throw new Error('blocked') },
50
+ clear(): void { throw new Error('blocked') }
51
+ }
52
+ const onError = vi.fn()
53
+ const fallback = createMemoryStorage()
54
+ const degraded = createStorage({ storage: broken, fallback, onError })
55
+ degraded.set('value', 'memory')
56
+ expect(degraded.kind).toBe('memory')
57
+ expect(degraded.get('value')).toBe('memory')
58
+ expect(onError).toHaveBeenCalledWith(expect.objectContaining({ code: 'STORAGE_UNAVAILABLE' }))
59
+ local.dispose()
60
+ session.dispose()
61
+ degraded.dispose()
62
+ })
63
+
64
+ it('损坏 JSON 会报告错误、删除坏值并返回 null', () => {
65
+ const backend = createMemoryStorage()
66
+ backend.setItem('vobs:broken', '{not-json')
67
+ const onError = vi.fn()
68
+ const storage = createStorage({ storage: backend, onError })
69
+
70
+ expect(storage.get('broken')).toBeNull()
71
+ expect(backend.getItem('vobs:broken')).toBeNull()
72
+ expect(onError).toHaveBeenCalledWith(expect.objectContaining({ code: 'CORRUPT_DATA' }))
73
+ storage.dispose()
74
+ })
75
+
76
+ it('读取旧版本时执行迁移并保存新 envelope', () => {
77
+ const backend = createMemoryStorage()
78
+ backend.setItem('vobs:settings', JSON.stringify({ __vobsStorage: true, version: 1, value: { compact: true } }))
79
+ const migrate = vi.fn((value: unknown, from: number, to: number) => ({
80
+ density: (value as { compact: boolean }).compact ? 'compact' : 'comfortable',
81
+ migrated: `${from}->${to}`
82
+ }))
83
+ const storage = createStorage({ storage: backend, version: 2, migrate })
84
+
85
+ expect(storage.get('settings')).toEqual({ density: 'compact', migrated: '1->2' })
86
+ expect(migrate).toHaveBeenCalledWith({ compact: true }, 1, 2)
87
+ expect(backend.getItem('vobs:settings')).toContain('"version":2')
88
+ storage.dispose()
89
+ })
90
+
91
+ it('迁移失败返回 null 且保留旧数据', () => {
92
+ const backend = createMemoryStorage()
93
+ const oldValue = JSON.stringify({ __vobsStorage: true, version: 1, value: { old: true } })
94
+ backend.setItem('vobs:settings', oldValue)
95
+ const onError = vi.fn()
96
+ const storage = createStorage({
97
+ storage: backend,
98
+ version: 2,
99
+ migrate: () => { throw new Error('cannot migrate') },
100
+ onError
101
+ })
102
+
103
+ expect(storage.get('settings')).toBeNull()
104
+ expect(backend.getItem('vobs:settings')).toBe(oldValue)
105
+ expect(onError).toHaveBeenCalledWith(expect.objectContaining({ code: 'MIGRATION_FAILED' }))
106
+ storage.dispose()
107
+ })
108
+
109
+ it('订阅本实例写入、删除、清空和浏览器跨标签页变化', () => {
110
+ const storage = createStorage({ prefix: 'events:' })
111
+ const changes: string[] = []
112
+ storage.subscribe(change => changes.push(`${change.source}:${change.key}:${String(change.value)}`))
113
+ storage.set('one', 1)
114
+ storage.remove('one')
115
+ storage.set('two', 2)
116
+ storage.clear()
117
+ window.dispatchEvent(new StorageEvent('storage', {
118
+ key: 'events:remote',
119
+ newValue: JSON.stringify({ __vobsStorage: true, version: 1, value: 'yes' }),
120
+ storageArea: window.localStorage
121
+ }))
122
+
123
+ expect(changes).toEqual([
124
+ 'local:one:1',
125
+ 'local:one:null',
126
+ 'local:two:2',
127
+ 'local:two:null',
128
+ 'external:remote:yes'
129
+ ])
130
+ storage.dispose()
131
+ })
132
+
133
+ it('插件注入 StorageContext,应用销毁后上下文不可用', () => {
134
+ let injected: ReturnType<typeof createStorage> | undefined
135
+ const app = createVobs({
136
+ render: () => createText('app'),
137
+ plugins: [{
138
+ name: 'consumer',
139
+ requires: [storagePlugin({ storage: 'memory' })],
140
+ install(context) { injected = context.inject(STORAGE_KEY) }
141
+ }]
142
+ })
143
+ expect(injected).toBeDefined()
144
+ injected?.set('answer', 42)
145
+ expect(injected?.get('answer')).toBe(42)
146
+ app.destroy()
147
+ expect(() => injected?.get('answer')).toThrow('已销毁')
148
+ })
149
+
150
+ it('未安装插件时 useStorage 给出明确错误', () => {
151
+ const app = createVobs({ render: () => {
152
+ useStorage()
153
+ return createText('')
154
+ } })
155
+ expect(() => app.mount(document.createElement('div'))).toThrowError(
156
+ expect.objectContaining({ code: 'STORAGE_UNAVAILABLE' })
157
+ )
158
+ expect(StorageError).toBeDefined()
159
+ })
160
+ })
package/src/index.ts ADDED
@@ -0,0 +1,391 @@
1
+ import { getCurrentOwner, onDispose } from '@vobs/reactivity'
2
+ import { createInjectionKey, inject, type InjectionKey, type VobsPlugin } from '@vobs/vobs'
3
+
4
+ export type StorageType = 'local' | 'session' | 'memory'
5
+ export type StorageKind = StorageType | 'custom'
6
+
7
+ export interface StorageLike {
8
+ readonly length: number
9
+ getItem(key: string): string | null
10
+ setItem(key: string, value: string): void
11
+ removeItem(key: string): void
12
+ key(index: number): string | null
13
+ }
14
+
15
+ export type StorageMigration = (
16
+ value: unknown,
17
+ fromVersion: number,
18
+ toVersion: number
19
+ ) => unknown
20
+
21
+ export interface StorageOptions {
22
+ readonly storage?: StorageLike | StorageType
23
+ readonly fallback?: StorageLike
24
+ readonly prefix?: string
25
+ readonly version?: number
26
+ readonly migrate?: StorageMigration
27
+ readonly onError?: (error: StorageError) => void
28
+ }
29
+
30
+ export interface StorageChange {
31
+ readonly key: string
32
+ readonly value: unknown | null
33
+ readonly source: 'local' | 'external'
34
+ }
35
+
36
+ export interface StorageContext {
37
+ readonly kind: StorageKind
38
+ readonly persistent: boolean
39
+ readonly prefix: string
40
+ readonly version: number
41
+ get<T>(key: string): T | null
42
+ set<T>(key: string, value: T): void
43
+ remove(key: string): void
44
+ clear(): void
45
+ has(key: string): boolean
46
+ keys(): readonly string[]
47
+ subscribe(listener: (change: StorageChange) => void): () => void
48
+ dispose(): void
49
+ }
50
+
51
+ export type StorageErrorCode =
52
+ | 'STORAGE_UNAVAILABLE'
53
+ | 'CORRUPT_DATA'
54
+ | 'SERIALIZATION_FAILED'
55
+ | 'MIGRATION_FAILED'
56
+
57
+ export class StorageError extends Error {
58
+ readonly code: StorageErrorCode
59
+ readonly key: string | undefined
60
+ readonly cause: unknown
61
+
62
+ constructor(code: StorageErrorCode, message: string, key?: string, cause?: unknown) {
63
+ super(message)
64
+ this.name = 'StorageError'
65
+ this.code = code
66
+ this.key = key
67
+ this.cause = cause
68
+ }
69
+ }
70
+
71
+ export const STORAGE_KEY: InjectionKey<StorageContext> = createInjectionKey<StorageContext>('vobs.storage')
72
+
73
+ export interface StoragePluginOptions extends StorageOptions {
74
+ readonly context?: StorageContext
75
+ }
76
+
77
+ interface Envelope {
78
+ readonly __vobsStorage: true
79
+ readonly version: number
80
+ readonly value: unknown
81
+ }
82
+
83
+ export function createMemoryStorage(): StorageLike {
84
+ const values = new Map<string, string>()
85
+ return {
86
+ get length(): number {
87
+ return values.size
88
+ },
89
+ getItem(key): string | null {
90
+ return values.get(key) ?? null
91
+ },
92
+ setItem(key, value): void {
93
+ values.set(key, value)
94
+ },
95
+ removeItem(key): void {
96
+ values.delete(key)
97
+ },
98
+ key(index): string | null {
99
+ return [...values.keys()][index] ?? null
100
+ }
101
+ }
102
+ }
103
+
104
+ export const memoryStorage = createMemoryStorage()
105
+
106
+ export function createStorage(options: StorageOptions = {}): StorageContext {
107
+ const prefix = options.prefix ?? 'vobs:'
108
+ const version = validateVersion(options.version ?? 1)
109
+ const fallback = options.fallback ?? createMemoryStorage()
110
+ const selected = resolveStorage(options.storage)
111
+ let backend = selected.backend ?? fallback
112
+ let kind: StorageKind = selected.kind
113
+ let disposed = false
114
+ let stopBrowserListener: (() => void) | undefined
115
+ const listeners = new Set<(change: StorageChange) => void>()
116
+
117
+ const context: StorageContext = {
118
+ get kind(): StorageKind {
119
+ return kind
120
+ },
121
+
122
+ get persistent(): boolean {
123
+ return kind !== 'memory'
124
+ },
125
+
126
+ prefix,
127
+ version,
128
+
129
+ get<T>(key: string): T | null {
130
+ ensureActive()
131
+ const normalizedKey = validateKey(key)
132
+ const raw = read(normalizedKey)
133
+ if (raw === null) return null
134
+ return decode<T>(normalizedKey, raw)
135
+ },
136
+
137
+ set<T>(key: string, value: T): void {
138
+ ensureActive()
139
+ const normalizedKey = validateKey(key)
140
+ let raw: string
141
+ try {
142
+ raw = JSON.stringify({ __vobsStorage: true, version, value } satisfies Envelope)
143
+ } catch (error) {
144
+ const storageError = new StorageError(
145
+ 'SERIALIZATION_FAILED',
146
+ `Vobs Storage: 无法序列化键 ${normalizedKey}`,
147
+ normalizedKey,
148
+ error
149
+ )
150
+ report(storageError)
151
+ throw storageError
152
+ }
153
+ write(normalizedKey, raw)
154
+ emit({ key: normalizedKey, value, source: 'local' })
155
+ },
156
+
157
+ remove(key: string): void {
158
+ ensureActive()
159
+ const normalizedKey = validateKey(key)
160
+ withBackend(normalizedKey, () => backend.removeItem(toPhysicalKey(normalizedKey)))
161
+ emit({ key: normalizedKey, value: null, source: 'local' })
162
+ },
163
+
164
+ clear(): void {
165
+ ensureActive()
166
+ const physicalKeys = listPhysicalKeys()
167
+ for (const physicalKey of physicalKeys) {
168
+ withBackend(physicalKey, () => backend.removeItem(physicalKey))
169
+ }
170
+ for (const physicalKey of physicalKeys) {
171
+ emit({ key: physicalKey.slice(prefix.length), value: null, source: 'local' })
172
+ }
173
+ },
174
+
175
+ has(key: string): boolean {
176
+ return context.get(key) !== null
177
+ },
178
+
179
+ keys(): readonly string[] {
180
+ ensureActive()
181
+ return listPhysicalKeys().map(key => key.slice(prefix.length))
182
+ },
183
+
184
+ subscribe(listener): () => void {
185
+ ensureActive()
186
+ listeners.add(listener)
187
+ return () => listeners.delete(listener)
188
+ },
189
+
190
+ dispose(): void {
191
+ if (disposed) return
192
+ disposed = true
193
+ stopBrowserListener?.()
194
+ stopBrowserListener = undefined
195
+ listeners.clear()
196
+ }
197
+ }
198
+
199
+ if (selected.browserStorage && typeof window !== 'undefined') {
200
+ const onStorage = (event: StorageEvent): void => {
201
+ if (event.storageArea && event.storageArea !== backend) return
202
+ if (!event.key || !event.key.startsWith(prefix)) return
203
+ const key = event.key.slice(prefix.length)
204
+ emit({ key, value: event.newValue === null ? null : decodeExternal(key, event.newValue), source: 'external' })
205
+ }
206
+ window.addEventListener('storage', onStorage)
207
+ stopBrowserListener = () => window.removeEventListener('storage', onStorage)
208
+ }
209
+
210
+ if (getCurrentOwner()) onDispose(context.dispose)
211
+ return context
212
+
213
+ function read(key: string): string | null {
214
+ return withBackend(key, () => backend.getItem(toPhysicalKey(key)))
215
+ }
216
+
217
+ function write(key: string, value: string): void {
218
+ withBackend(key, () => backend.setItem(toPhysicalKey(key), value))
219
+ }
220
+
221
+ function decode<T>(key: string, raw: string): T | null {
222
+ let parsed: unknown
223
+ try {
224
+ parsed = JSON.parse(raw)
225
+ } catch (error) {
226
+ handleCorrupt(key, error)
227
+ return null
228
+ }
229
+
230
+ const envelope = isEnvelope(parsed) ? parsed : { version: 0, value: parsed }
231
+ if (envelope.version >= version || !options.migrate) return envelope.value as T | null
232
+
233
+ try {
234
+ const migrated = options.migrate(envelope.value, envelope.version, version)
235
+ write(key, JSON.stringify({ __vobsStorage: true, version, value: migrated } satisfies Envelope))
236
+ return migrated as T | null
237
+ } catch (error) {
238
+ const storageError = new StorageError(
239
+ 'MIGRATION_FAILED',
240
+ `Vobs Storage: 键 ${key} 迁移失败`,
241
+ key,
242
+ error
243
+ )
244
+ report(storageError)
245
+ return null
246
+ }
247
+ }
248
+
249
+ function decodeExternal(key: string, raw: string): unknown | null {
250
+ try {
251
+ const parsed = JSON.parse(raw)
252
+ return isEnvelope(parsed) ? parsed.value : parsed
253
+ } catch (error) {
254
+ handleCorrupt(key, error)
255
+ return null
256
+ }
257
+ }
258
+
259
+ function handleCorrupt(key: string, cause: unknown): void {
260
+ const storageError = new StorageError(
261
+ 'CORRUPT_DATA',
262
+ `Vobs Storage: 键 ${key} 的数据已损坏`,
263
+ key,
264
+ cause
265
+ )
266
+ report(storageError)
267
+ withBackend(key, () => backend.removeItem(toPhysicalKey(key)))
268
+ }
269
+
270
+ function withBackend<T>(key: string, operation: () => T): T {
271
+ try {
272
+ return operation()
273
+ } catch (error) {
274
+ if (backend === fallback) {
275
+ const storageError = new StorageError(
276
+ 'STORAGE_UNAVAILABLE',
277
+ 'Vobs Storage: 存储不可用',
278
+ key,
279
+ error
280
+ )
281
+ report(storageError)
282
+ throw storageError
283
+ }
284
+ const storageError = new StorageError(
285
+ 'STORAGE_UNAVAILABLE',
286
+ 'Vobs Storage: 存储不可用,已切换到内存 fallback',
287
+ key,
288
+ error
289
+ )
290
+ report(storageError)
291
+ backend = fallback
292
+ kind = 'memory'
293
+ return operation()
294
+ }
295
+ }
296
+
297
+ function listPhysicalKeys(): string[] {
298
+ const keys: string[] = []
299
+ for (let index = 0; index < backend.length; index++) {
300
+ const key = backend.key(index)
301
+ if (key?.startsWith(prefix)) keys.push(key)
302
+ }
303
+ return keys
304
+ }
305
+
306
+ function emit(change: StorageChange): void {
307
+ for (const listener of [...listeners]) {
308
+ try {
309
+ listener(change)
310
+ } catch {
311
+ // 订阅者错误不能阻止其他订阅者接收变化。
312
+ }
313
+ }
314
+ }
315
+
316
+ function report(error: StorageError): void {
317
+ try {
318
+ options.onError?.(error)
319
+ } catch {
320
+ // 错误回调不能覆盖存储操作的原始结果。
321
+ }
322
+ }
323
+
324
+ function ensureActive(): void {
325
+ if (disposed) throw new Error('Vobs Storage: 已销毁的上下文不能继续使用')
326
+ }
327
+
328
+ function toPhysicalKey(key: string): string {
329
+ return `${prefix}${key}`
330
+ }
331
+ }
332
+
333
+ export function storagePlugin(options: StoragePluginOptions = {}): VobsPlugin {
334
+ return {
335
+ name: '@vobs/storage',
336
+ version: '0.1.0',
337
+ install(context) {
338
+ const ownedStorage = options.context ? undefined : createStorage(options)
339
+ context.provide(STORAGE_KEY, options.context ?? ownedStorage!)
340
+ return () => ownedStorage?.dispose()
341
+ }
342
+ }
343
+ }
344
+
345
+ export function useStorage(): StorageContext {
346
+ const storage = inject(STORAGE_KEY)
347
+ if (!storage) throw new StorageError('STORAGE_UNAVAILABLE', 'Vobs Storage: 找不到上下文,请安装 storagePlugin')
348
+ return storage
349
+ }
350
+
351
+ function resolveStorage(input: StorageLike | StorageType | undefined): {
352
+ backend: StorageLike | undefined
353
+ kind: StorageKind
354
+ browserStorage: boolean
355
+ } {
356
+ if (input && typeof input !== 'string') return { backend: input, kind: 'custom', browserStorage: false }
357
+ const type = input ?? 'local'
358
+ if (type === 'memory') return { backend: memoryStorage, kind: 'memory', browserStorage: false }
359
+ const backend = getBrowserStorage(type)
360
+ return { backend, kind: backend ? type : 'memory', browserStorage: Boolean(backend) }
361
+ }
362
+
363
+ function getBrowserStorage(type: Exclude<StorageType, 'memory'>): StorageLike | undefined {
364
+ if (typeof window === 'undefined') return undefined
365
+ try {
366
+ return type === 'local' ? window.localStorage : window.sessionStorage
367
+ } catch {
368
+ return undefined
369
+ }
370
+ }
371
+
372
+ function isEnvelope(value: unknown): value is Envelope {
373
+ return Boolean(value)
374
+ && typeof value === 'object'
375
+ && value !== null
376
+ && (value as { __vobsStorage?: unknown }).__vobsStorage === true
377
+ && Number.isInteger((value as { version?: unknown }).version)
378
+ && 'value' in value
379
+ }
380
+
381
+ function validateKey(key: string): string {
382
+ if (typeof key !== 'string' || !key.trim()) throw new Error('Vobs Storage: key 不能为空')
383
+ return key
384
+ }
385
+
386
+ function validateVersion(version: number): number {
387
+ if (!Number.isInteger(version) || version < 0) {
388
+ throw new Error('Vobs Storage: version 必须是大于等于 0 的整数')
389
+ }
390
+ return version
391
+ }