@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 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
+ }