@talelk/newui-data 0.0.0-stage → 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 +202 -0
- package/README.md +54 -2
- package/package.json +54 -3
- package/skills/newui-data/SKILL.md +116 -0
- package/src/download.test.ts +14 -0
- package/src/download.ts +39 -0
- package/src/exports.test.ts +105 -0
- package/src/form/form.test.tsx +102 -0
- package/src/form/form.tsx +85 -0
- package/src/form/index.ts +1 -0
- package/src/format.test.ts +22 -0
- package/src/format.ts +36 -0
- package/src/index.ts +4 -0
- package/src/query/client.tsx +53 -0
- package/src/query/collection.test.tsx +151 -0
- package/src/query/collection.ts +192 -0
- package/src/query/index.ts +3 -0
- package/src/query/repository.ts +30 -0
- package/src/resource/client.test.ts +173 -0
- package/src/resource/client.ts +307 -0
- package/src/resource/document.test.ts +151 -0
- package/src/resource/document.ts +198 -0
- package/src/resource/idb.test.ts +169 -0
- package/src/resource/idb.ts +167 -0
- package/src/resource/index.ts +70 -0
- package/src/resource/list.test.ts +150 -0
- package/src/resource/list.ts +230 -0
- package/src/resource/local.test.ts +111 -0
- package/src/resource/local.ts +155 -0
- package/src/resource/realtime.test.ts +331 -0
- package/src/resource/realtime.ts +287 -0
- package/src/resource/resource.test.ts +186 -0
- package/src/resource/resource.ts +213 -0
- package/src/resource/useCall.test.ts +60 -0
- package/src/resource/useCall.ts +74 -0
- package/src/resource/useDoc.test.ts +31 -0
- package/src/resource/useDoc.ts +23 -0
- package/src/resource/useDoctype.test.ts +104 -0
- package/src/resource/useDoctype.ts +128 -0
- package/src/resource/useList.test.ts +32 -0
- package/src/resource/useList.ts +22 -0
- package/src/resource/useNewDoc.test.ts +67 -0
- package/src/resource/useNewDoc.ts +78 -0
- package/src/select.test.ts +13 -0
- package/src/select.ts +19 -0
- package/src/undo.test.ts +24 -0
- package/src/undo.ts +37 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { createListResource } from './list'
|
|
3
|
+
|
|
4
|
+
function mockResponse(body: unknown): Response {
|
|
5
|
+
return {
|
|
6
|
+
ok: true,
|
|
7
|
+
status: 200,
|
|
8
|
+
statusText: 'OK',
|
|
9
|
+
json: async () => body,
|
|
10
|
+
text: async () => JSON.stringify(body),
|
|
11
|
+
} as unknown as Response
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface FetchCall {
|
|
15
|
+
mock: { calls: Array<[string, RequestInit]> }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function stubFetch(...responses: Response[]): FetchCall {
|
|
19
|
+
const mock = vi.fn()
|
|
20
|
+
for (const response of responses) mock.mockResolvedValueOnce(response)
|
|
21
|
+
vi.stubGlobal('fetch', mock)
|
|
22
|
+
return mock as unknown as FetchCall
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function urlAt(mock: FetchCall, index = 0): string {
|
|
26
|
+
return mock.mock.calls[index]?.[0] ?? ''
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function bodyAt(mock: FetchCall, index = 0): Record<string, unknown> {
|
|
30
|
+
const call = mock.mock.calls[index]
|
|
31
|
+
return JSON.parse(String(call?.[1]?.body)) as Record<string, unknown>
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
afterEach(() => {
|
|
35
|
+
vi.unstubAllGlobals()
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
describe('createListResource', () => {
|
|
39
|
+
it('reloads from the list endpoint with filters and a window limit', async () => {
|
|
40
|
+
const fetchMock = stubFetch(
|
|
41
|
+
mockResponse({ message: [{ name: 'T-1' }, { name: 'T-2' }] }),
|
|
42
|
+
)
|
|
43
|
+
const list = createListResource({
|
|
44
|
+
doctype: 'Task',
|
|
45
|
+
filters: { status: 'Open' },
|
|
46
|
+
pageLength: 20,
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
const rows = await list.reload()
|
|
50
|
+
|
|
51
|
+
expect(rows).toEqual([{ name: 'T-1' }, { name: 'T-2' }])
|
|
52
|
+
expect(urlAt(fetchMock)).toBe('/api/method/frappe.client.get_list')
|
|
53
|
+
expect(bodyAt(fetchMock)).toEqual({
|
|
54
|
+
doctype: 'Task',
|
|
55
|
+
filters: { status: 'Open' },
|
|
56
|
+
start: 0,
|
|
57
|
+
limit: 20,
|
|
58
|
+
limit_start: 0,
|
|
59
|
+
limit_page_length: 20,
|
|
60
|
+
})
|
|
61
|
+
expect(list.hasNextPage()).toBe(false)
|
|
62
|
+
expect(list.hasPreviousPage()).toBe(false)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('next advances the window and refetches', async () => {
|
|
66
|
+
const page = Array.from({ length: 20 }, (_, i) => ({ name: `T-${i}` }))
|
|
67
|
+
const fetchMock = stubFetch(
|
|
68
|
+
mockResponse({ message: page }),
|
|
69
|
+
mockResponse({ message: page }),
|
|
70
|
+
)
|
|
71
|
+
const list = createListResource({ doctype: 'Task', pageLength: 20 })
|
|
72
|
+
|
|
73
|
+
await list.reload()
|
|
74
|
+
expect(list.hasNextPage()).toBe(true)
|
|
75
|
+
|
|
76
|
+
await list.next()
|
|
77
|
+
expect(list.start()).toBe(20)
|
|
78
|
+
expect(list.hasPreviousPage()).toBe(true)
|
|
79
|
+
expect(bodyAt(fetchMock, 1)).toEqual({
|
|
80
|
+
doctype: 'Task',
|
|
81
|
+
filters: null,
|
|
82
|
+
start: 0,
|
|
83
|
+
limit: 40,
|
|
84
|
+
limit_start: 0,
|
|
85
|
+
limit_page_length: 40,
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
await list.previous()
|
|
89
|
+
expect(list.start()).toBe(0)
|
|
90
|
+
expect(bodyAt(fetchMock, 2)).toEqual({
|
|
91
|
+
doctype: 'Task',
|
|
92
|
+
filters: null,
|
|
93
|
+
start: 0,
|
|
94
|
+
limit: 20,
|
|
95
|
+
limit_start: 0,
|
|
96
|
+
limit_page_length: 20,
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('setFilters resets to the first page and refetches', async () => {
|
|
101
|
+
const fetchMock = stubFetch(
|
|
102
|
+
mockResponse({ message: [] }),
|
|
103
|
+
mockResponse({ message: [] }),
|
|
104
|
+
)
|
|
105
|
+
const list = createListResource({ doctype: 'Task', pageLength: 20 })
|
|
106
|
+
|
|
107
|
+
await list.next()
|
|
108
|
+
await list.setFilters({ status: 'Closed' })
|
|
109
|
+
|
|
110
|
+
expect(list.start()).toBe(0)
|
|
111
|
+
expect(list.filters()).toEqual({ status: 'Closed' })
|
|
112
|
+
expect(bodyAt(fetchMock, 1)).toEqual({
|
|
113
|
+
doctype: 'Task',
|
|
114
|
+
filters: { status: 'Closed' },
|
|
115
|
+
start: 0,
|
|
116
|
+
limit: 20,
|
|
117
|
+
limit_start: 0,
|
|
118
|
+
limit_page_length: 20,
|
|
119
|
+
})
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('sets error and resolves on failure', async () => {
|
|
123
|
+
stubFetch({
|
|
124
|
+
ok: false,
|
|
125
|
+
status: 500,
|
|
126
|
+
statusText: 'Server Error',
|
|
127
|
+
json: async () => ({}),
|
|
128
|
+
text: async () => JSON.stringify({ _error_message: 'boom' }),
|
|
129
|
+
} as unknown as Response)
|
|
130
|
+
const list = createListResource({ doctype: 'Task' })
|
|
131
|
+
|
|
132
|
+
const rows = await list.reload()
|
|
133
|
+
|
|
134
|
+
expect(rows).toBe(null)
|
|
135
|
+
expect(list.error()).not.toBe(null)
|
|
136
|
+
expect(list.loading()).toBe(false)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('applies transform to the rows', async () => {
|
|
140
|
+
stubFetch(mockResponse({ message: [{ name: 'T-1' }, { name: 'T-2' }] }))
|
|
141
|
+
const list = createListResource<{ name: string }>({
|
|
142
|
+
doctype: 'Task',
|
|
143
|
+
transform: (rows) => rows.slice(0, 1),
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
const rows = await list.reload()
|
|
147
|
+
expect(rows).toEqual([{ name: 'T-1' }])
|
|
148
|
+
expect(list.data()).toEqual([{ name: 'T-1' }])
|
|
149
|
+
})
|
|
150
|
+
})
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { type Accessor, createSignal } from 'solid-js'
|
|
2
|
+
import {
|
|
3
|
+
isAbortError,
|
|
4
|
+
normalizeError,
|
|
5
|
+
type RequestError,
|
|
6
|
+
request,
|
|
7
|
+
} from './client'
|
|
8
|
+
import type { ResourceFetcher } from './resource'
|
|
9
|
+
|
|
10
|
+
/** A Frappe filter map (`{ status: 'Open', name: ['like', '%x%'] }`). */
|
|
11
|
+
export type ListFilters = Record<string, unknown>
|
|
12
|
+
|
|
13
|
+
/** Options for {@link createListResource}. */
|
|
14
|
+
export interface ListResourceOptions<TRow = Record<string, unknown>> {
|
|
15
|
+
doctype: string
|
|
16
|
+
/** Fields to select. */
|
|
17
|
+
fields?: string[]
|
|
18
|
+
/** Filters, or an accessor for reactive filters. */
|
|
19
|
+
filters?: ListFilters | (() => ListFilters)
|
|
20
|
+
/** OR-filters (`or_filters`). */
|
|
21
|
+
orFilters?: ListFilters | (() => ListFilters)
|
|
22
|
+
/** `order_by` clause, e.g. `modified desc`. */
|
|
23
|
+
orderBy?: string
|
|
24
|
+
/** Initial page offset. Defaults to `0`. */
|
|
25
|
+
start?: number
|
|
26
|
+
/** Rows per page. Defaults to `20`. */
|
|
27
|
+
pageLength?: number
|
|
28
|
+
groupBy?: string
|
|
29
|
+
parent?: string
|
|
30
|
+
debug?: boolean | number
|
|
31
|
+
/** Overrides the default list endpoint. */
|
|
32
|
+
url?: string
|
|
33
|
+
/** Fetch on creation. Defaults to `false`. */
|
|
34
|
+
auto?: boolean
|
|
35
|
+
/** Map the raw rows. */
|
|
36
|
+
transform?: (rows: TRow[]) => TRow[]
|
|
37
|
+
onSuccess?: (rows: TRow[]) => void
|
|
38
|
+
onError?: (error: RequestError) => void
|
|
39
|
+
fetcher?: ResourceFetcher
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A paginated, filterable list resource. */
|
|
43
|
+
export interface ListResource<TRow = Record<string, unknown>> {
|
|
44
|
+
data: Accessor<TRow[] | null>
|
|
45
|
+
loading: Accessor<boolean>
|
|
46
|
+
error: Accessor<RequestError | null>
|
|
47
|
+
fetched: Accessor<boolean>
|
|
48
|
+
filters: Accessor<ListFilters | null>
|
|
49
|
+
/** Current page offset (the `start` of the window). */
|
|
50
|
+
start: Accessor<number>
|
|
51
|
+
pageLength: Accessor<number>
|
|
52
|
+
hasNextPage: Accessor<boolean>
|
|
53
|
+
hasPreviousPage: Accessor<boolean>
|
|
54
|
+
/** Refetch the window from the start. */
|
|
55
|
+
reload: () => Promise<TRow[] | null>
|
|
56
|
+
/** Alias of {@link ListResource.reload}. */
|
|
57
|
+
fetch: () => Promise<TRow[] | null>
|
|
58
|
+
/** Advance one page and refetch. */
|
|
59
|
+
next: () => Promise<TRow[] | null>
|
|
60
|
+
/** Go back one page and refetch. */
|
|
61
|
+
previous: () => Promise<TRow[] | null>
|
|
62
|
+
/** Replace the filters, reset to the first page, and refetch. */
|
|
63
|
+
setFilters: (filters: ListFilters) => Promise<TRow[] | null>
|
|
64
|
+
/** Replace `data` without a request. */
|
|
65
|
+
setData: (rows: TRow[]) => void
|
|
66
|
+
reset: () => void
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const DEFAULT_LIST_URL = 'frappe.client.get_list'
|
|
70
|
+
|
|
71
|
+
function resolveFilters(
|
|
72
|
+
value: ListFilters | (() => ListFilters) | undefined,
|
|
73
|
+
): ListFilters | undefined {
|
|
74
|
+
return typeof value === 'function' ? value() : value
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The signal-based replacement for frappe-ui's `createListResource`.
|
|
79
|
+
*
|
|
80
|
+
* Pagination is a sliding window: `reload` always fetches from `start = 0` with
|
|
81
|
+
* a limit of `start + pageLength`, so `data` holds every row up to the current
|
|
82
|
+
* page. `next`/`previous` move the window and refetch; `hasNextPage` is true
|
|
83
|
+
* when the server returned a full window.
|
|
84
|
+
*/
|
|
85
|
+
export function createListResource<TRow = Record<string, unknown>>(
|
|
86
|
+
options: ListResourceOptions<TRow>,
|
|
87
|
+
): ListResource<TRow> {
|
|
88
|
+
const fetcher: ResourceFetcher = options.fetcher ?? request
|
|
89
|
+
|
|
90
|
+
const [data, setDataSignal] = createSignal<TRow[] | null>(null)
|
|
91
|
+
const [loading, setLoading] = createSignal(false)
|
|
92
|
+
const [error, setError] = createSignal<RequestError | null>(null)
|
|
93
|
+
const [fetched, setFetched] = createSignal(false)
|
|
94
|
+
const [start, setStart] = createSignal(options.start ?? 0)
|
|
95
|
+
const [pageLength] = createSignal(options.pageLength ?? 20)
|
|
96
|
+
const [hasNextPage, setHasNextPage] = createSignal(true)
|
|
97
|
+
const hasPreviousPage = (): boolean => start() > 0
|
|
98
|
+
|
|
99
|
+
// `setFilters` wins over the option; both may be an accessor.
|
|
100
|
+
const [filtersOverride, setFiltersOverride] =
|
|
101
|
+
createSignal<ListFilters | null>(null)
|
|
102
|
+
const filters = (): ListFilters | null => {
|
|
103
|
+
const override = filtersOverride()
|
|
104
|
+
if (override) return override
|
|
105
|
+
const value = options.filters
|
|
106
|
+
if (typeof value === 'function') return value()
|
|
107
|
+
return value ?? null
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
let controller: AbortController | null = null
|
|
111
|
+
let sequence = 0
|
|
112
|
+
|
|
113
|
+
function makeParams(limit: number): Record<string, unknown> {
|
|
114
|
+
return {
|
|
115
|
+
doctype: options.doctype,
|
|
116
|
+
fields: options.fields,
|
|
117
|
+
filters: filters(),
|
|
118
|
+
or_filters: resolveFilters(options.orFilters),
|
|
119
|
+
order_by: options.orderBy,
|
|
120
|
+
start: 0,
|
|
121
|
+
limit,
|
|
122
|
+
limit_start: 0,
|
|
123
|
+
limit_page_length: limit,
|
|
124
|
+
group_by: options.groupBy,
|
|
125
|
+
parent: options.parent,
|
|
126
|
+
debug: options.debug,
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function transform(rows: TRow[]): TRow[] {
|
|
131
|
+
if (options.transform) {
|
|
132
|
+
const result = options.transform(rows)
|
|
133
|
+
if (Array.isArray(result)) return result
|
|
134
|
+
}
|
|
135
|
+
return rows
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function reload(): Promise<TRow[] | null> {
|
|
139
|
+
const current = ++sequence
|
|
140
|
+
controller?.abort()
|
|
141
|
+
controller = new AbortController()
|
|
142
|
+
setLoading(true)
|
|
143
|
+
setError(null)
|
|
144
|
+
|
|
145
|
+
const limit = start() + pageLength()
|
|
146
|
+
const result = fetcher<TRow[]>({
|
|
147
|
+
url: options.url ?? DEFAULT_LIST_URL,
|
|
148
|
+
method: 'POST',
|
|
149
|
+
params: makeParams(limit),
|
|
150
|
+
signal: controller.signal,
|
|
151
|
+
})
|
|
152
|
+
.then((rows) => {
|
|
153
|
+
if (current !== sequence) return data()
|
|
154
|
+
const list = Array.isArray(rows) ? rows : []
|
|
155
|
+
setHasNextPage(list.length >= limit)
|
|
156
|
+
const transformed = transform(list)
|
|
157
|
+
setDataSignal(() => transformed)
|
|
158
|
+
setFetched(true)
|
|
159
|
+
options.onSuccess?.(transformed)
|
|
160
|
+
return transformed
|
|
161
|
+
})
|
|
162
|
+
.catch((caught: unknown) => {
|
|
163
|
+
if (current !== sequence || isAbortError(caught)) return data()
|
|
164
|
+
const normalized = normalizeError(caught)
|
|
165
|
+
setError(normalized)
|
|
166
|
+
options.onError?.(normalized)
|
|
167
|
+
return data()
|
|
168
|
+
})
|
|
169
|
+
.finally(() => {
|
|
170
|
+
if (current === sequence) setLoading(false)
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
return result
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function next(): Promise<TRow[] | null> {
|
|
177
|
+
setStart(start() + pageLength())
|
|
178
|
+
return reload()
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function previous(): Promise<TRow[] | null> {
|
|
182
|
+
setStart(Math.max(0, start() - pageLength()))
|
|
183
|
+
return reload()
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function setFilters(next: ListFilters): Promise<TRow[] | null> {
|
|
187
|
+
setFiltersOverride(() => next)
|
|
188
|
+
setStart(0)
|
|
189
|
+
return reload()
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function setData(rows: TRow[]): void {
|
|
193
|
+
setDataSignal(() => transform(rows))
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function reset(): void {
|
|
197
|
+
controller?.abort()
|
|
198
|
+
sequence++
|
|
199
|
+
setDataSignal(() => null)
|
|
200
|
+
setLoading(false)
|
|
201
|
+
setError(null)
|
|
202
|
+
setFetched(false)
|
|
203
|
+
setStart(options.start ?? 0)
|
|
204
|
+
setHasNextPage(true)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Auto-fetch on creation. `reload` is a plain action, not a signal — the
|
|
208
|
+
// reactivity rule cannot tell the difference, so it is silenced here.
|
|
209
|
+
// eslint-disable-next-line solid/reactivity
|
|
210
|
+
if (options.auto) void reload()
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
data,
|
|
214
|
+
loading,
|
|
215
|
+
error,
|
|
216
|
+
fetched,
|
|
217
|
+
filters,
|
|
218
|
+
start,
|
|
219
|
+
pageLength,
|
|
220
|
+
hasNextPage,
|
|
221
|
+
hasPreviousPage,
|
|
222
|
+
reload,
|
|
223
|
+
fetch: reload,
|
|
224
|
+
next,
|
|
225
|
+
previous,
|
|
226
|
+
setFilters,
|
|
227
|
+
setData,
|
|
228
|
+
reset,
|
|
229
|
+
}
|
|
230
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { createLocalStore } from './local'
|
|
3
|
+
|
|
4
|
+
// jsdom provides a working `localStorage` (vitest sets an http origin), so the
|
|
5
|
+
// default backend is exercised for real; the memory fallback is tested by
|
|
6
|
+
// passing `storage: null` and by hiding the global.
|
|
7
|
+
|
|
8
|
+
let prefix = 0
|
|
9
|
+
function nextPrefix(): string {
|
|
10
|
+
prefix += 1
|
|
11
|
+
return `test-${prefix}:`
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
localStorage.clear()
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
vi.unstubAllGlobals()
|
|
20
|
+
localStorage.clear()
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
describe('createLocalStore', () => {
|
|
24
|
+
it('round-trips JSON values through localStorage', () => {
|
|
25
|
+
const store = createLocalStore({ prefix: nextPrefix() })
|
|
26
|
+
|
|
27
|
+
store.set('doc', { name: 'T-1', tags: ['a', 'b'] })
|
|
28
|
+
store.set('count', 3)
|
|
29
|
+
store.set('flag', false)
|
|
30
|
+
|
|
31
|
+
expect(store.persistent).toBe(true)
|
|
32
|
+
expect(store.get('doc')).toEqual({ name: 'T-1', tags: ['a', 'b'] })
|
|
33
|
+
expect(store.get('count')).toBe(3)
|
|
34
|
+
expect(store.get('flag')).toBe(false)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('returns null for a missing or unparseable entry', () => {
|
|
38
|
+
const store = createLocalStore<unknown>({ prefix: 'broken:' })
|
|
39
|
+
expect(store.get('nope')).toBe(null)
|
|
40
|
+
|
|
41
|
+
// A value written outside the JSON contract must not throw on read.
|
|
42
|
+
localStorage.setItem('broken:x', 'not json')
|
|
43
|
+
expect(store.get('x')).toBe(null)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('scopes keys, delete, has, keys and clear to its prefix', () => {
|
|
47
|
+
const store = createLocalStore({ prefix: 'mine:' })
|
|
48
|
+
const other = createLocalStore({ prefix: 'other:' })
|
|
49
|
+
|
|
50
|
+
store.set('a', 1)
|
|
51
|
+
store.set('b', 2)
|
|
52
|
+
other.set('a', 9)
|
|
53
|
+
|
|
54
|
+
expect(store.has('a')).toBe(true)
|
|
55
|
+
expect(store.keys().sort()).toEqual(['a', 'b'])
|
|
56
|
+
expect(other.keys()).toEqual(['a'])
|
|
57
|
+
|
|
58
|
+
store.delete('a')
|
|
59
|
+
expect(store.has('a')).toBe(false)
|
|
60
|
+
expect(other.get('a')).toBe(9)
|
|
61
|
+
|
|
62
|
+
store.clear()
|
|
63
|
+
expect(store.keys()).toEqual([])
|
|
64
|
+
expect(other.get('a')).toBe(9)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('persists across store instances that share a prefix', () => {
|
|
68
|
+
const first = createLocalStore<{ v: number }>({ prefix: 'shared:' })
|
|
69
|
+
first.set('k', { v: 1 })
|
|
70
|
+
|
|
71
|
+
const second = createLocalStore<{ v: number }>({ prefix: 'shared:' })
|
|
72
|
+
expect(second.get('k')).toEqual({ v: 1 })
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('falls back to memory when storage is explicitly null', () => {
|
|
76
|
+
const store = createLocalStore({ prefix: nextPrefix(), storage: null })
|
|
77
|
+
|
|
78
|
+
expect(store.persistent).toBe(false)
|
|
79
|
+
store.set('k', 'v')
|
|
80
|
+
expect(store.get('k')).toBe('v')
|
|
81
|
+
expect(store.has('k')).toBe(true)
|
|
82
|
+
expect(store.keys()).toEqual(['k'])
|
|
83
|
+
store.clear()
|
|
84
|
+
expect(store.keys()).toEqual([])
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('falls back to memory when localStorage is unavailable (SSR)', () => {
|
|
88
|
+
vi.stubGlobal('localStorage', undefined)
|
|
89
|
+
const store = createLocalStore({ prefix: nextPrefix() })
|
|
90
|
+
|
|
91
|
+
expect(store.persistent).toBe(false)
|
|
92
|
+
store.set('k', { v: 1 })
|
|
93
|
+
expect(store.get('k')).toEqual({ v: 1 })
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('drops writes when the backing storage throws', () => {
|
|
97
|
+
const throwing = {
|
|
98
|
+
length: 0,
|
|
99
|
+
key: () => null,
|
|
100
|
+
getItem: () => null,
|
|
101
|
+
setItem: () => {
|
|
102
|
+
throw new Error('QuotaExceededError')
|
|
103
|
+
},
|
|
104
|
+
removeItem: () => {},
|
|
105
|
+
} as unknown as Storage
|
|
106
|
+
const store = createLocalStore({ prefix: nextPrefix(), storage: throwing })
|
|
107
|
+
|
|
108
|
+
expect(() => store.set('k', 1)).not.toThrow()
|
|
109
|
+
expect(store.get('k')).toBe(null)
|
|
110
|
+
})
|
|
111
|
+
})
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A `localStorage`-backed key/value store for caching resource data.
|
|
3
|
+
*
|
|
4
|
+
* The synchronous sibling of {@link createIdbStore}: values are JSON-encoded, so
|
|
5
|
+
* anything `JSON.parse(JSON.stringify(value))` round-trips is safe to cache.
|
|
6
|
+
* Every call is guarded for SSR — with no `localStorage` the store silently
|
|
7
|
+
* falls back to an in-memory `Map`, so importing and using it on the server
|
|
8
|
+
* never throws. It is a plain API (no Solid reactivity): a cache is not a view.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** The storage backend a {@link LocalStore} reads and writes. */
|
|
12
|
+
type LocalStoreBackend = Storage | null
|
|
13
|
+
|
|
14
|
+
/** A synchronous, JSON, key/value cache. */
|
|
15
|
+
export interface LocalStore<T = unknown> {
|
|
16
|
+
/** The stored value, or `null` when the key is absent or not JSON. */
|
|
17
|
+
get(key: string): T | null
|
|
18
|
+
/** JSON-encode and persist a value. */
|
|
19
|
+
set(key: string, value: T): void
|
|
20
|
+
/** Remove a key. */
|
|
21
|
+
delete(key: string): void
|
|
22
|
+
/** Whether the key exists. */
|
|
23
|
+
has(key: string): boolean
|
|
24
|
+
/** Every key owned by this store, with the prefix stripped. */
|
|
25
|
+
keys(): string[]
|
|
26
|
+
/** Remove every key owned by this store. */
|
|
27
|
+
clear(): void
|
|
28
|
+
/** False when the store fell back to memory (SSR, or storage disabled). */
|
|
29
|
+
readonly persistent: boolean
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Options for {@link createLocalStore}. */
|
|
33
|
+
export interface LocalStoreOptions {
|
|
34
|
+
/** Key prefix, so several stores can share one `localStorage`. */
|
|
35
|
+
prefix?: string
|
|
36
|
+
/** Storage backend. Defaults to the global `localStorage`. */
|
|
37
|
+
storage?: LocalStoreBackend
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function defaultStorage(): LocalStoreBackend {
|
|
41
|
+
try {
|
|
42
|
+
return typeof localStorage === 'undefined' ? null : localStorage
|
|
43
|
+
} catch {
|
|
44
|
+
// A sandboxed or disabled `localStorage` throws on access, not on use.
|
|
45
|
+
return null
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Create a synchronous cache over `localStorage`.
|
|
51
|
+
*
|
|
52
|
+
* Keys are namespaced by `prefix` (default `newui:`), so a store owns only the
|
|
53
|
+
* entries it wrote: `keys()` and `clear()` never touch another store's data.
|
|
54
|
+
* A storage that throws (quota exceeded, disabled) degrades to a no-op write
|
|
55
|
+
* rather than failing the caller.
|
|
56
|
+
*/
|
|
57
|
+
export function createLocalStore<T = unknown>(
|
|
58
|
+
options: LocalStoreOptions = {},
|
|
59
|
+
): LocalStore<T> {
|
|
60
|
+
const prefix = options.prefix ?? 'newui:'
|
|
61
|
+
const storage =
|
|
62
|
+
options.storage === undefined ? defaultStorage() : options.storage
|
|
63
|
+
const memory = new Map<string, string>()
|
|
64
|
+
|
|
65
|
+
const scoped = (key: string): string => `${prefix}${key}`
|
|
66
|
+
|
|
67
|
+
function get(key: string): T | null {
|
|
68
|
+
let raw: string | null
|
|
69
|
+
if (storage) {
|
|
70
|
+
try {
|
|
71
|
+
raw = storage.getItem(scoped(key))
|
|
72
|
+
} catch {
|
|
73
|
+
raw = null
|
|
74
|
+
}
|
|
75
|
+
} else {
|
|
76
|
+
raw = memory.get(key) ?? null
|
|
77
|
+
}
|
|
78
|
+
if (raw === null) return null
|
|
79
|
+
try {
|
|
80
|
+
return JSON.parse(raw) as T
|
|
81
|
+
} catch {
|
|
82
|
+
return null
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function set(key: string, value: T): void {
|
|
87
|
+
const raw = JSON.stringify(value)
|
|
88
|
+
if (storage) {
|
|
89
|
+
try {
|
|
90
|
+
storage.setItem(scoped(key), raw)
|
|
91
|
+
} catch {
|
|
92
|
+
// Quota or disabled storage: the write is dropped, not fatal.
|
|
93
|
+
}
|
|
94
|
+
} else {
|
|
95
|
+
memory.set(key, raw)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function remove(key: string): void {
|
|
100
|
+
if (storage) {
|
|
101
|
+
try {
|
|
102
|
+
storage.removeItem(scoped(key))
|
|
103
|
+
} catch {
|
|
104
|
+
// Nothing to do — the entry is already unreachable.
|
|
105
|
+
}
|
|
106
|
+
} else {
|
|
107
|
+
memory.delete(key)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function has(key: string): boolean {
|
|
112
|
+
if (storage) {
|
|
113
|
+
try {
|
|
114
|
+
return storage.getItem(scoped(key)) !== null
|
|
115
|
+
} catch {
|
|
116
|
+
return false
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return memory.has(key)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function keys(): string[] {
|
|
123
|
+
if (!storage) return [...memory.keys()]
|
|
124
|
+
const owned: string[] = []
|
|
125
|
+
try {
|
|
126
|
+
for (let index = 0; index < storage.length; index++) {
|
|
127
|
+
const key = storage.key(index)
|
|
128
|
+
if (key?.startsWith(prefix)) {
|
|
129
|
+
owned.push(key.slice(prefix.length))
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
} catch {
|
|
133
|
+
return []
|
|
134
|
+
}
|
|
135
|
+
return owned
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function clear(): void {
|
|
139
|
+
if (!storage) {
|
|
140
|
+
memory.clear()
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
for (const key of keys()) remove(key)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
get,
|
|
148
|
+
set,
|
|
149
|
+
delete: remove,
|
|
150
|
+
has,
|
|
151
|
+
keys,
|
|
152
|
+
clear,
|
|
153
|
+
persistent: storage !== null,
|
|
154
|
+
}
|
|
155
|
+
}
|