@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.
Files changed (47) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +54 -2
  3. package/package.json +54 -3
  4. package/skills/newui-data/SKILL.md +116 -0
  5. package/src/download.test.ts +14 -0
  6. package/src/download.ts +39 -0
  7. package/src/exports.test.ts +105 -0
  8. package/src/form/form.test.tsx +102 -0
  9. package/src/form/form.tsx +85 -0
  10. package/src/form/index.ts +1 -0
  11. package/src/format.test.ts +22 -0
  12. package/src/format.ts +36 -0
  13. package/src/index.ts +4 -0
  14. package/src/query/client.tsx +53 -0
  15. package/src/query/collection.test.tsx +151 -0
  16. package/src/query/collection.ts +192 -0
  17. package/src/query/index.ts +3 -0
  18. package/src/query/repository.ts +30 -0
  19. package/src/resource/client.test.ts +173 -0
  20. package/src/resource/client.ts +307 -0
  21. package/src/resource/document.test.ts +151 -0
  22. package/src/resource/document.ts +198 -0
  23. package/src/resource/idb.test.ts +169 -0
  24. package/src/resource/idb.ts +167 -0
  25. package/src/resource/index.ts +70 -0
  26. package/src/resource/list.test.ts +150 -0
  27. package/src/resource/list.ts +230 -0
  28. package/src/resource/local.test.ts +111 -0
  29. package/src/resource/local.ts +155 -0
  30. package/src/resource/realtime.test.ts +331 -0
  31. package/src/resource/realtime.ts +287 -0
  32. package/src/resource/resource.test.ts +186 -0
  33. package/src/resource/resource.ts +213 -0
  34. package/src/resource/useCall.test.ts +60 -0
  35. package/src/resource/useCall.ts +74 -0
  36. package/src/resource/useDoc.test.ts +31 -0
  37. package/src/resource/useDoc.ts +23 -0
  38. package/src/resource/useDoctype.test.ts +104 -0
  39. package/src/resource/useDoctype.ts +128 -0
  40. package/src/resource/useList.test.ts +32 -0
  41. package/src/resource/useList.ts +22 -0
  42. package/src/resource/useNewDoc.test.ts +67 -0
  43. package/src/resource/useNewDoc.ts +78 -0
  44. package/src/select.test.ts +13 -0
  45. package/src/select.ts +19 -0
  46. package/src/undo.test.ts +24 -0
  47. 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
+ }