@vobs/dict 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 +55 -0
- package/package.json +25 -0
- package/src/index.test.ts +139 -0
- package/src/index.ts +421 -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,55 @@
|
|
|
1
|
+
# @vobs/dict
|
|
2
|
+
|
|
3
|
+
Business dictionaries (value/label lists) with TTL caching, per-name request dedup, and revision-based race protection.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @vobs/dict
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick start
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { createDict } from '@vobs/dict'
|
|
15
|
+
|
|
16
|
+
const dict = createDict({
|
|
17
|
+
data: { user_status: [{ value: 'active', label: 'Active' }] },
|
|
18
|
+
loader: async (name, signal) => {
|
|
19
|
+
const response = await fetch(`/api/dicts/${name}`, { signal })
|
|
20
|
+
return response.json()
|
|
21
|
+
},
|
|
22
|
+
staleTime: 60_000
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
dict.label('user_status', 'active') // 'Active'
|
|
26
|
+
|
|
27
|
+
const query = dict.query('roles')
|
|
28
|
+
await query.load()
|
|
29
|
+
query.items.value // [{ value, label, ... }]
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## API
|
|
33
|
+
|
|
34
|
+
| Signature | Description |
|
|
35
|
+
| --- | --- |
|
|
36
|
+
| `createDict(options?: DictOptions): DictContext` | Creates a dict context with optional static `data`, `loader`, `staleTime` (default 5 min), and `onError`. |
|
|
37
|
+
| `dict.data: Signal<DictData>` | Reactive map of dict name to items. |
|
|
38
|
+
| `dict.loading: Signal<boolean>` / `dict.error: Signal<Error | null>` | Global loading and error state. |
|
|
39
|
+
| `dict.get(name: DictName): DictItems` | Current items, or an empty array when unknown. |
|
|
40
|
+
| `dict.find(name: DictName, value: DictValue): DictItem | undefined` | Finds one item by value. |
|
|
41
|
+
| `dict.label(name: DictName, value: DictValue, fallback?): string` | Label for a value; falls back to the given string, then `String(value)`. |
|
|
42
|
+
| `dict.query(name: DictName): DictQuery` | Per-name view with `items`, `loading`, `error`, `updatedAt` signals plus `load()` and `invalidate()`. |
|
|
43
|
+
| `dict.load(name: DictName, options?): Promise<DictItems>` | Loads through the `loader`; skips while fresh unless `{ force: true }`. |
|
|
44
|
+
| `dict.set(name: DictName, items: DictItems): void` | Writes items directly and aborts any in-flight load. |
|
|
45
|
+
| `dict.invalidate(name?: DictName): void` | Marks one or all dicts stale and aborts in-flight requests. |
|
|
46
|
+
| `dict.dehydrate()` / `dict.hydrate(snapshot)` | Serializes and restores cached entries for SSR. |
|
|
47
|
+
| `dict.dispose(): void` | Aborts requests and disposes all signals. |
|
|
48
|
+
| `dictPlugin(options?): VobsPlugin` | Provides the context through `DICT_KEY`. |
|
|
49
|
+
| `useDict(): DictContext` | Injects the dict context inside components. |
|
|
50
|
+
|
|
51
|
+
Loads are deduplicated per name, and every load carries a revision: `invalidate()` or `set()` aborts the in-flight request through the `AbortSignal` and a late response from a superseded request can never overwrite newer data. Failed loads keep the previous items and surface a `DictError` on `query().error`.
|
|
52
|
+
|
|
53
|
+
## Types
|
|
54
|
+
|
|
55
|
+
`DictName`, `DictValue`, `DictItem`, `DictItems`, `DictData`, `DictLoader`, `DictQuery`, `DictOptions`, `DictPluginOptions`, `DictContext`, `DictDehydratedEntry`, `DictDehydratedState`, `DictErrorCode`
|
package/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"license": "MIT",
|
|
3
|
+
"files": [
|
|
4
|
+
"src",
|
|
5
|
+
"README.md",
|
|
6
|
+
"LICENSE"
|
|
7
|
+
],
|
|
8
|
+
"name": "@vobs/dict",
|
|
9
|
+
"version": "1.0.0",
|
|
10
|
+
"description": "Reactive business dictionary sources and SSR snapshots for Vobs.",
|
|
11
|
+
"type": "module",
|
|
12
|
+
"main": "src/index.ts",
|
|
13
|
+
"types": "src/index.ts",
|
|
14
|
+
"sideEffects": false,
|
|
15
|
+
"exports": {
|
|
16
|
+
".": "./src/index.ts"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"@vobs/reactivity": "1.0.0",
|
|
20
|
+
"@vobs/vobs": "1.0.0"
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"test": "vitest --environment jsdom"
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { createText, createVobs } from '@vobs/vobs'
|
|
3
|
+
import {
|
|
4
|
+
DICT_KEY,
|
|
5
|
+
DictError,
|
|
6
|
+
createDict,
|
|
7
|
+
dictPlugin,
|
|
8
|
+
useDict
|
|
9
|
+
} from './index'
|
|
10
|
+
|
|
11
|
+
const statusItems = [
|
|
12
|
+
{ value: 'active', label: 'Active' },
|
|
13
|
+
{ value: 'disabled', label: 'Disabled', disabled: true }
|
|
14
|
+
] as const
|
|
15
|
+
|
|
16
|
+
describe('@vobs/dict', () => {
|
|
17
|
+
it('支持静态业务字典、查询、value 查找和 label 回退,不依赖 i18n', () => {
|
|
18
|
+
const dict = createDict({ data: { user_status: statusItems } })
|
|
19
|
+
const query = dict.query('user_status')
|
|
20
|
+
|
|
21
|
+
expect(dict.get('user_status')).toEqual(statusItems)
|
|
22
|
+
expect(query.items.value).toEqual(statusItems)
|
|
23
|
+
expect(dict.find('user_status', 'disabled')).toMatchObject({ disabled: true })
|
|
24
|
+
expect(dict.label('user_status', 'active')).toBe('Active')
|
|
25
|
+
expect(dict.label('user_status', 'missing', 'Unknown')).toBe('Unknown')
|
|
26
|
+
dict.set('user_status', [{ value: 1, label: 'One' }])
|
|
27
|
+
expect(query.items.value).toEqual([{ value: 1, label: 'One' }])
|
|
28
|
+
dict.dispose()
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('按字典名去重请求、遵守 TTL,并在失效后重新加载', async () => {
|
|
32
|
+
let time = 1_000
|
|
33
|
+
const loader = vi.fn(async (name: string) => [{ value: `${name}-${loader.mock.calls.length}`, label: 'Loaded' }])
|
|
34
|
+
const dict = createDict({ loader, staleTime: 100, now: () => time })
|
|
35
|
+
const query = dict.query('roles')
|
|
36
|
+
|
|
37
|
+
const [first, second] = await Promise.all([dict.load('roles'), query.load()])
|
|
38
|
+
expect(first).toEqual(second)
|
|
39
|
+
expect(loader).toHaveBeenCalledTimes(1)
|
|
40
|
+
expect(query.loading.value).toBe(false)
|
|
41
|
+
await dict.load('roles')
|
|
42
|
+
expect(loader).toHaveBeenCalledTimes(1)
|
|
43
|
+
time += 100
|
|
44
|
+
await dict.load('roles')
|
|
45
|
+
expect(loader).toHaveBeenCalledTimes(2)
|
|
46
|
+
dict.invalidate('roles')
|
|
47
|
+
await dict.load('roles')
|
|
48
|
+
expect(loader).toHaveBeenCalledTimes(3)
|
|
49
|
+
dict.dispose()
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('加载失败保留旧字典、暴露错误并报告观察者', async () => {
|
|
53
|
+
const onError = vi.fn()
|
|
54
|
+
const dict = createDict({
|
|
55
|
+
data: { user_status: statusItems },
|
|
56
|
+
loader: async () => { throw new Error('network unavailable') },
|
|
57
|
+
onError,
|
|
58
|
+
staleTime: 0
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
await expect(dict.load('user_status')).rejects.toMatchObject({ code: 'DICT_LOAD_FAILED' })
|
|
62
|
+
expect(dict.get('user_status')).toEqual(statusItems)
|
|
63
|
+
expect(dict.query('user_status').error.value).toMatchObject({ code: 'DICT_LOAD_FAILED' })
|
|
64
|
+
expect(onError).toHaveBeenCalledWith(expect.objectContaining({ code: 'DICT_LOAD_FAILED' }), 'user_status')
|
|
65
|
+
dict.dispose()
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('失效会中止在途请求,并阻止慢请求结果覆盖缓存', async () => {
|
|
69
|
+
let resolveLoader: ((items: readonly { value: string; label: string }[]) => void) | undefined
|
|
70
|
+
let aborted = false
|
|
71
|
+
const dict = createDict({
|
|
72
|
+
loader: (_name, signal) => new Promise(resolve => {
|
|
73
|
+
resolveLoader = resolve
|
|
74
|
+
signal.addEventListener('abort', () => { aborted = true }, { once: true })
|
|
75
|
+
})
|
|
76
|
+
})
|
|
77
|
+
const query = dict.query('roles')
|
|
78
|
+
const pending = dict.load('roles')
|
|
79
|
+
await vi.waitFor(() => expect(query.loading.value).toBe(true))
|
|
80
|
+
dict.invalidate('roles')
|
|
81
|
+
resolveLoader?.([{ value: 'stale', label: 'Stale' }])
|
|
82
|
+
await pending
|
|
83
|
+
|
|
84
|
+
expect(aborted).toBe(true)
|
|
85
|
+
expect(query.loading.value).toBe(false)
|
|
86
|
+
expect(dict.get('roles')).toEqual([])
|
|
87
|
+
dict.dispose()
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('序列化并恢复 SSR 状态,恢复后的缓存不会重复调用 loader', async () => {
|
|
91
|
+
let time = 10_000
|
|
92
|
+
const source = createDict({
|
|
93
|
+
data: { user_status: statusItems },
|
|
94
|
+
now: () => time,
|
|
95
|
+
staleTime: 5_000
|
|
96
|
+
})
|
|
97
|
+
const snapshot = source.dehydrate()
|
|
98
|
+
const loader = vi.fn(async () => [{ value: 'unexpected', label: 'Unexpected' }])
|
|
99
|
+
const target = createDict({ loader, now: () => time, staleTime: 5_000 })
|
|
100
|
+
target.hydrate(JSON.stringify(snapshot))
|
|
101
|
+
|
|
102
|
+
expect(target.get('user_status')).toEqual(statusItems)
|
|
103
|
+
await expect(target.load('user_status')).resolves.toEqual(statusItems)
|
|
104
|
+
expect(loader).not.toHaveBeenCalled()
|
|
105
|
+
expect(() => target.hydrate({ version: 2, entries: [] })).toThrowError(
|
|
106
|
+
expect.objectContaining({ code: 'INVALID_DEHYDRATED_STATE' })
|
|
107
|
+
)
|
|
108
|
+
source.dispose()
|
|
109
|
+
target.dispose()
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('dictPlugin 注入上下文并在应用销毁后释放自有状态', () => {
|
|
113
|
+
let injected: ReturnType<typeof createDict> | undefined
|
|
114
|
+
const app = createVobs({
|
|
115
|
+
render: () => createText('dict'),
|
|
116
|
+
plugins: [
|
|
117
|
+
dictPlugin({ data: { user_status: statusItems } }),
|
|
118
|
+
{ name: 'consumer', install(context) { injected = context.inject(DICT_KEY) } }
|
|
119
|
+
]
|
|
120
|
+
})
|
|
121
|
+
expect(injected?.label('user_status', 'active')).toBe('Active')
|
|
122
|
+
app.destroy()
|
|
123
|
+
expect(() => injected?.get('user_status')).toThrow('已销毁')
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('未安装插件与无 loader 的加载给出明确错误', async () => {
|
|
127
|
+
const noLoader = createDict()
|
|
128
|
+
await expect(noLoader.load('roles')).rejects.toMatchObject({ code: 'DICT_LOADER_MISSING' })
|
|
129
|
+
noLoader.dispose()
|
|
130
|
+
const app = createVobs({ render: () => {
|
|
131
|
+
useDict()
|
|
132
|
+
return createText('')
|
|
133
|
+
} })
|
|
134
|
+
expect(() => app.mount(document.createElement('div'))).toThrowError(
|
|
135
|
+
expect.objectContaining({ code: 'DICT_CONTEXT_MISSING' })
|
|
136
|
+
)
|
|
137
|
+
expect(DictError).toBeDefined()
|
|
138
|
+
})
|
|
139
|
+
})
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
import { getCurrentOwner, onDispose, state, type Signal } from '@vobs/reactivity'
|
|
2
|
+
import { createInjectionKey, inject, type InjectionKey, type VobsPlugin } from '@vobs/vobs'
|
|
3
|
+
|
|
4
|
+
export type DictName = string
|
|
5
|
+
export type DictValue = string | number
|
|
6
|
+
|
|
7
|
+
export interface DictItem {
|
|
8
|
+
readonly value: DictValue
|
|
9
|
+
readonly label: string
|
|
10
|
+
readonly disabled?: boolean
|
|
11
|
+
readonly [key: string]: unknown
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type DictItems = readonly DictItem[]
|
|
15
|
+
export type DictData = Readonly<Record<DictName, DictItems>>
|
|
16
|
+
export type DictLoader = (name: DictName, signal: AbortSignal) => DictItems | PromiseLike<DictItems>
|
|
17
|
+
|
|
18
|
+
export interface DictQuery {
|
|
19
|
+
readonly name: DictName
|
|
20
|
+
readonly items: Signal<DictItems>
|
|
21
|
+
readonly loading: Signal<boolean>
|
|
22
|
+
readonly error: Signal<Error | null>
|
|
23
|
+
readonly updatedAt: Signal<number>
|
|
24
|
+
load(options?: { readonly force?: boolean }): Promise<DictItems>
|
|
25
|
+
invalidate(): void
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface DictDehydratedEntry {
|
|
29
|
+
readonly name: DictName
|
|
30
|
+
readonly items: DictItems
|
|
31
|
+
readonly updatedAt: number
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface DictDehydratedState {
|
|
35
|
+
readonly version: 1
|
|
36
|
+
readonly entries: readonly DictDehydratedEntry[]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface DictOptions {
|
|
40
|
+
readonly data?: DictData
|
|
41
|
+
readonly loader?: DictLoader
|
|
42
|
+
readonly staleTime?: number
|
|
43
|
+
readonly now?: () => number
|
|
44
|
+
readonly onError?: (error: DictError, name: DictName) => void
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface DictPluginOptions extends DictOptions {
|
|
48
|
+
readonly dict?: DictContext
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface DictContext {
|
|
52
|
+
readonly data: Signal<DictData>
|
|
53
|
+
readonly loading: Signal<boolean>
|
|
54
|
+
readonly error: Signal<Error | null>
|
|
55
|
+
readonly staleTime: number
|
|
56
|
+
get(name: DictName): DictItems
|
|
57
|
+
find(name: DictName, value: DictValue): DictItem | undefined
|
|
58
|
+
label(name: DictName, value: DictValue, fallback?: string): string
|
|
59
|
+
query(name: DictName): DictQuery
|
|
60
|
+
load(name: DictName, options?: { readonly force?: boolean }): Promise<DictItems>
|
|
61
|
+
set(name: DictName, items: DictItems): void
|
|
62
|
+
invalidate(name?: DictName): void
|
|
63
|
+
dehydrate(): DictDehydratedState
|
|
64
|
+
hydrate(snapshot: unknown): void
|
|
65
|
+
dispose(): void
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export type DictErrorCode =
|
|
69
|
+
| 'DICT_CONTEXT_MISSING'
|
|
70
|
+
| 'DICT_CONTEXT_DISPOSED'
|
|
71
|
+
| 'INVALID_DICT_NAME'
|
|
72
|
+
| 'INVALID_DICT_ITEMS'
|
|
73
|
+
| 'DICT_LOADER_MISSING'
|
|
74
|
+
| 'DICT_LOAD_FAILED'
|
|
75
|
+
| 'INVALID_DEHYDRATED_STATE'
|
|
76
|
+
|
|
77
|
+
export class DictError extends Error {
|
|
78
|
+
readonly code: DictErrorCode
|
|
79
|
+
readonly cause: unknown
|
|
80
|
+
|
|
81
|
+
constructor(code: DictErrorCode, message: string, cause?: unknown) {
|
|
82
|
+
super(message)
|
|
83
|
+
this.name = 'DictError'
|
|
84
|
+
this.code = code
|
|
85
|
+
this.cause = cause
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export const DICT_KEY: InjectionKey<DictContext> = createInjectionKey<DictContext>('vobs.dict')
|
|
90
|
+
|
|
91
|
+
const EMPTY_ITEMS: DictItems = Object.freeze([])
|
|
92
|
+
|
|
93
|
+
interface DictEntry extends DictQuery {
|
|
94
|
+
controller: AbortController | null
|
|
95
|
+
inFlight: Promise<DictItems> | null
|
|
96
|
+
revision: number
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function createDict(options: DictOptions = {}): DictContext {
|
|
100
|
+
const staleTime = validateStaleTime(options.staleTime ?? 5 * 60 * 1000)
|
|
101
|
+
const now = options.now ?? Date.now
|
|
102
|
+
const initial = normalizeData(options.data ?? {})
|
|
103
|
+
const data = state<DictData>(initial)
|
|
104
|
+
const loading = state(false)
|
|
105
|
+
const error = state<Error | null>(null)
|
|
106
|
+
const entries = new Map<DictName, DictEntry>()
|
|
107
|
+
let disposed = false
|
|
108
|
+
|
|
109
|
+
for (const [name, items] of Object.entries(initial)) {
|
|
110
|
+
const entry = createEntry(name, items, now())
|
|
111
|
+
entries.set(name, entry)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const context: DictContext = {
|
|
115
|
+
data,
|
|
116
|
+
loading,
|
|
117
|
+
error,
|
|
118
|
+
staleTime,
|
|
119
|
+
|
|
120
|
+
get(name): DictItems {
|
|
121
|
+
ensureActive()
|
|
122
|
+
return data.value[validateName(name)] ?? EMPTY_ITEMS
|
|
123
|
+
},
|
|
124
|
+
|
|
125
|
+
find(name, value): DictItem | undefined {
|
|
126
|
+
ensureActive()
|
|
127
|
+
return context.get(name).find(item => item.value === value)
|
|
128
|
+
},
|
|
129
|
+
|
|
130
|
+
label(name, value, fallback): string {
|
|
131
|
+
ensureActive()
|
|
132
|
+
return context.find(name, value)?.label ?? fallback ?? String(value)
|
|
133
|
+
},
|
|
134
|
+
|
|
135
|
+
query(name): DictQuery {
|
|
136
|
+
ensureActive()
|
|
137
|
+
return getEntry(validateName(name))
|
|
138
|
+
},
|
|
139
|
+
|
|
140
|
+
load(name, loadOptions = {}): Promise<DictItems> {
|
|
141
|
+
ensureActive()
|
|
142
|
+
const entry = getEntry(validateName(name))
|
|
143
|
+
if (!loadOptions.force && isFresh(entry)) return Promise.resolve(entry.items.value)
|
|
144
|
+
if (entry.inFlight) return entry.inFlight
|
|
145
|
+
if (!options.loader) {
|
|
146
|
+
const loadError = new DictError('DICT_LOADER_MISSING', `Vobs Dict: ${entry.name} 未配置 loader`)
|
|
147
|
+
entry.error.value = loadError
|
|
148
|
+
error.value = loadError
|
|
149
|
+
report(loadError, entry.name)
|
|
150
|
+
return Promise.reject(loadError)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const revision = ++entry.revision
|
|
154
|
+
const controller = new AbortController()
|
|
155
|
+
entry.controller = controller
|
|
156
|
+
entry.loading.value = true
|
|
157
|
+
refreshLoading()
|
|
158
|
+
const request = Promise.resolve()
|
|
159
|
+
.then(() => options.loader!(entry.name, controller.signal))
|
|
160
|
+
.then(items => {
|
|
161
|
+
if (disposed || revision !== entry.revision) return entry.items.value
|
|
162
|
+
const normalized = normalizeItems(items)
|
|
163
|
+
commit(entry, normalized, now(), false)
|
|
164
|
+
return normalized
|
|
165
|
+
})
|
|
166
|
+
.catch(reason => {
|
|
167
|
+
if (disposed || revision !== entry.revision || isAbortError(reason)) return entry.items.value
|
|
168
|
+
const loadError = reason instanceof DictError
|
|
169
|
+
? reason
|
|
170
|
+
: new DictError('DICT_LOAD_FAILED', `Vobs Dict: ${entry.name} 加载失败`, reason)
|
|
171
|
+
entry.error.value = loadError
|
|
172
|
+
error.value = loadError
|
|
173
|
+
report(loadError, entry.name)
|
|
174
|
+
throw loadError
|
|
175
|
+
})
|
|
176
|
+
.finally(() => {
|
|
177
|
+
if (revision !== entry.revision) return
|
|
178
|
+
entry.inFlight = null
|
|
179
|
+
entry.controller = null
|
|
180
|
+
entry.loading.value = false
|
|
181
|
+
refreshLoading()
|
|
182
|
+
})
|
|
183
|
+
entry.inFlight = request
|
|
184
|
+
return request
|
|
185
|
+
},
|
|
186
|
+
|
|
187
|
+
set(name, items): void {
|
|
188
|
+
ensureActive()
|
|
189
|
+
commit(getEntry(validateName(name)), normalizeItems(items), now())
|
|
190
|
+
},
|
|
191
|
+
|
|
192
|
+
invalidate(name): void {
|
|
193
|
+
ensureActive()
|
|
194
|
+
if (name === undefined) {
|
|
195
|
+
for (const entry of entries.values()) invalidateEntry(entry)
|
|
196
|
+
return
|
|
197
|
+
}
|
|
198
|
+
invalidateEntry(getEntry(validateName(name)))
|
|
199
|
+
},
|
|
200
|
+
|
|
201
|
+
dehydrate(): DictDehydratedState {
|
|
202
|
+
ensureActive()
|
|
203
|
+
return {
|
|
204
|
+
version: 1,
|
|
205
|
+
entries: Object.freeze([...entries.values()]
|
|
206
|
+
.filter(entry => entry.updatedAt.value > 0)
|
|
207
|
+
.map(entry => Object.freeze({
|
|
208
|
+
name: entry.name,
|
|
209
|
+
items: entry.items.value,
|
|
210
|
+
updatedAt: entry.updatedAt.value
|
|
211
|
+
})))
|
|
212
|
+
}
|
|
213
|
+
},
|
|
214
|
+
|
|
215
|
+
hydrate(snapshot): void {
|
|
216
|
+
ensureActive()
|
|
217
|
+
const restored = parseDehydratedState(snapshot)
|
|
218
|
+
const nextData: Record<DictName, DictItems> = {}
|
|
219
|
+
const restoredNames = new Set<DictName>()
|
|
220
|
+
for (const item of restored.entries) {
|
|
221
|
+
const entry = getEntry(item.name)
|
|
222
|
+
entry.revision++
|
|
223
|
+
entry.controller?.abort()
|
|
224
|
+
entry.controller = null
|
|
225
|
+
entry.inFlight = null
|
|
226
|
+
entry.loading.value = false
|
|
227
|
+
entry.items.value = item.items
|
|
228
|
+
entry.error.value = null
|
|
229
|
+
entry.updatedAt.value = item.updatedAt
|
|
230
|
+
nextData[item.name] = item.items
|
|
231
|
+
restoredNames.add(item.name)
|
|
232
|
+
}
|
|
233
|
+
for (const [name, entry] of entries) {
|
|
234
|
+
if (restoredNames.has(name)) continue
|
|
235
|
+
entry.revision++
|
|
236
|
+
entry.controller?.abort()
|
|
237
|
+
entry.controller = null
|
|
238
|
+
entry.inFlight = null
|
|
239
|
+
entry.loading.value = false
|
|
240
|
+
entry.items.value = EMPTY_ITEMS
|
|
241
|
+
entry.error.value = null
|
|
242
|
+
entry.updatedAt.value = 0
|
|
243
|
+
}
|
|
244
|
+
data.value = Object.freeze(nextData)
|
|
245
|
+
error.value = null
|
|
246
|
+
refreshLoading()
|
|
247
|
+
},
|
|
248
|
+
|
|
249
|
+
dispose(): void {
|
|
250
|
+
if (disposed) return
|
|
251
|
+
disposed = true
|
|
252
|
+
for (const entry of entries.values()) {
|
|
253
|
+
entry.controller?.abort()
|
|
254
|
+
entry.items.dispose()
|
|
255
|
+
entry.loading.dispose()
|
|
256
|
+
entry.error.dispose()
|
|
257
|
+
entry.updatedAt.dispose()
|
|
258
|
+
}
|
|
259
|
+
entries.clear()
|
|
260
|
+
data.dispose()
|
|
261
|
+
loading.dispose()
|
|
262
|
+
error.dispose()
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (getCurrentOwner()) onDispose(context.dispose)
|
|
267
|
+
return context
|
|
268
|
+
|
|
269
|
+
function getEntry(name: DictName): DictEntry {
|
|
270
|
+
const existing = entries.get(name)
|
|
271
|
+
if (existing) return existing
|
|
272
|
+
const entry = createEntry(name, data.value[name] ?? EMPTY_ITEMS, 0)
|
|
273
|
+
entries.set(name, entry)
|
|
274
|
+
return entry
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function createEntry(name: DictName, items: DictItems, updatedAt: number): DictEntry {
|
|
278
|
+
const entry = {
|
|
279
|
+
name,
|
|
280
|
+
items: state(items),
|
|
281
|
+
loading: state(false),
|
|
282
|
+
error: state<Error | null>(null),
|
|
283
|
+
updatedAt: state(updatedAt),
|
|
284
|
+
controller: null,
|
|
285
|
+
inFlight: null,
|
|
286
|
+
revision: 0,
|
|
287
|
+
load: (loadOptions?: { readonly force?: boolean }) => context.load(name, loadOptions),
|
|
288
|
+
invalidate: () => context.invalidate(name)
|
|
289
|
+
} as DictEntry
|
|
290
|
+
return entry
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function commit(entry: DictEntry, items: DictItems, updatedAt: number, replaceRequest = true): void {
|
|
294
|
+
if (replaceRequest) {
|
|
295
|
+
entry.revision++
|
|
296
|
+
entry.controller?.abort()
|
|
297
|
+
entry.controller = null
|
|
298
|
+
entry.inFlight = null
|
|
299
|
+
}
|
|
300
|
+
entry.items.value = items
|
|
301
|
+
entry.error.value = null
|
|
302
|
+
entry.updatedAt.value = updatedAt
|
|
303
|
+
data.value = Object.freeze({ ...data.value, [entry.name]: items })
|
|
304
|
+
error.value = null
|
|
305
|
+
refreshLoading()
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function invalidateEntry(entry: DictEntry): void {
|
|
309
|
+
entry.revision++
|
|
310
|
+
entry.controller?.abort()
|
|
311
|
+
entry.controller = null
|
|
312
|
+
entry.inFlight = null
|
|
313
|
+
entry.loading.value = false
|
|
314
|
+
entry.updatedAt.value = 0
|
|
315
|
+
refreshLoading()
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function isFresh(entry: DictEntry): boolean {
|
|
319
|
+
return entry.updatedAt.value > 0 && now() - entry.updatedAt.value < staleTime
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function refreshLoading(): void {
|
|
323
|
+
loading.value = [...entries.values()].some(entry => entry.loading.value)
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function report(dictError: DictError, name: DictName): void {
|
|
327
|
+
try { options.onError?.(dictError, name) } catch { /* observers cannot break dictionary state */ }
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function ensureActive(): void {
|
|
331
|
+
if (disposed) throw new DictError('DICT_CONTEXT_DISPOSED', 'Vobs Dict: 上下文已销毁')
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export function dictPlugin(options: DictPluginOptions = {}): VobsPlugin {
|
|
336
|
+
return {
|
|
337
|
+
name: '@vobs/dict',
|
|
338
|
+
version: '0.1.0',
|
|
339
|
+
install(context) {
|
|
340
|
+
const ownedDict = options.dict ? undefined : createDict(options)
|
|
341
|
+
context.provide(DICT_KEY, options.dict ?? ownedDict!)
|
|
342
|
+
return () => ownedDict?.dispose()
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export function useDict(): DictContext {
|
|
348
|
+
const dict = inject(DICT_KEY)
|
|
349
|
+
if (!dict) throw new DictError('DICT_CONTEXT_MISSING', 'Vobs Dict: 找不到上下文,请安装 dictPlugin')
|
|
350
|
+
return dict
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function validateName(name: string): DictName {
|
|
354
|
+
if (typeof name !== 'string' || name.trim() === '') {
|
|
355
|
+
throw new DictError('INVALID_DICT_NAME', 'Vobs Dict: 字典名必须是非空字符串')
|
|
356
|
+
}
|
|
357
|
+
return name
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function validateStaleTime(value: number): number {
|
|
361
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
362
|
+
throw new DictError('INVALID_DICT_ITEMS', 'Vobs Dict: staleTime 必须是大于等于 0 的有限数字')
|
|
363
|
+
}
|
|
364
|
+
return value
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function normalizeData(data: DictData): DictData {
|
|
368
|
+
const normalized: Record<DictName, DictItems> = {}
|
|
369
|
+
for (const [name, items] of Object.entries(data)) normalized[validateName(name)] = normalizeItems(items)
|
|
370
|
+
return Object.freeze(normalized)
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function normalizeItems(items: unknown): DictItems {
|
|
374
|
+
if (!Array.isArray(items)) throw new DictError('INVALID_DICT_ITEMS', 'Vobs Dict: 字典项必须是数组')
|
|
375
|
+
return Object.freeze(items.map((item, index) => {
|
|
376
|
+
if (!item || typeof item !== 'object'
|
|
377
|
+
|| (typeof (item as DictItem).value !== 'string' && typeof (item as DictItem).value !== 'number')
|
|
378
|
+
|| typeof (item as DictItem).label !== 'string') {
|
|
379
|
+
throw new DictError('INVALID_DICT_ITEMS', `Vobs Dict: 第 ${index + 1} 个字典项必须包含 value 和 label`)
|
|
380
|
+
}
|
|
381
|
+
return Object.freeze({ ...item }) as DictItem
|
|
382
|
+
}))
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function parseDehydratedState(snapshot: unknown): DictDehydratedState {
|
|
386
|
+
let value: unknown = snapshot
|
|
387
|
+
if (typeof snapshot === 'string') {
|
|
388
|
+
try { value = JSON.parse(snapshot) } catch {
|
|
389
|
+
throw new DictError('INVALID_DEHYDRATED_STATE', 'Vobs Dict: 初始状态不是有效 JSON')
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
if (!value || typeof value !== 'object') {
|
|
393
|
+
throw new DictError('INVALID_DEHYDRATED_STATE', 'Vobs Dict: 初始状态格式无效')
|
|
394
|
+
}
|
|
395
|
+
const candidate = value as Partial<DictDehydratedState>
|
|
396
|
+
if (candidate.version !== 1 || !Array.isArray(candidate.entries)) {
|
|
397
|
+
throw new DictError('INVALID_DEHYDRATED_STATE', 'Vobs Dict: 初始状态版本或 entries 无效')
|
|
398
|
+
}
|
|
399
|
+
const seen = new Set<string>()
|
|
400
|
+
const entries = candidate.entries.map(entry => {
|
|
401
|
+
if (!entry || typeof entry !== 'object') {
|
|
402
|
+
throw new DictError('INVALID_DEHYDRATED_STATE', 'Vobs Dict: 初始状态条目无效')
|
|
403
|
+
}
|
|
404
|
+
const name = validateName((entry as Partial<DictDehydratedEntry>).name as string)
|
|
405
|
+
const updatedAt = (entry as Partial<DictDehydratedEntry>).updatedAt
|
|
406
|
+
if (seen.has(name) || typeof updatedAt !== 'number' || !Number.isFinite(updatedAt) || updatedAt < 0) {
|
|
407
|
+
throw new DictError('INVALID_DEHYDRATED_STATE', 'Vobs Dict: 初始状态条目字段无效')
|
|
408
|
+
}
|
|
409
|
+
seen.add(name)
|
|
410
|
+
return Object.freeze({
|
|
411
|
+
name,
|
|
412
|
+
items: normalizeItems((entry as Partial<DictDehydratedEntry>).items),
|
|
413
|
+
updatedAt
|
|
414
|
+
})
|
|
415
|
+
})
|
|
416
|
+
return { version: 1, entries: Object.freeze(entries) }
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function isAbortError(value: unknown): boolean {
|
|
420
|
+
return Boolean(value) && typeof value === 'object' && (value as { name?: unknown }).name === 'AbortError'
|
|
421
|
+
}
|