@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 +21 -0
- package/README.md +50 -0
- package/package.json +20 -0
- package/src/index.test.ts +160 -0
- package/src/index.ts +391 -0
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
|
+
}
|