@talelk/newui-data 0.0.0-stage → 1.1.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 (68) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +65 -2
  3. package/package.json +54 -3
  4. package/skills/newui-data/SKILL.md +170 -0
  5. package/src/aggregate.test.ts +231 -0
  6. package/src/aggregate.ts +320 -0
  7. package/src/config-store.test.ts +157 -0
  8. package/src/config-store.ts +295 -0
  9. package/src/csv-export.test.ts +156 -0
  10. package/src/csv-export.ts +129 -0
  11. package/src/dashboard.test.tsx +285 -0
  12. package/src/dashboard.tsx +381 -0
  13. package/src/download.test.ts +14 -0
  14. package/src/download.ts +39 -0
  15. package/src/exports.test.ts +135 -0
  16. package/src/form/autosave.test.ts +186 -0
  17. package/src/form/autosave.ts +194 -0
  18. package/src/form/config.test.tsx +288 -0
  19. package/src/form/config.ts +187 -0
  20. package/src/form/form.test.tsx +102 -0
  21. package/src/form/form.tsx +205 -0
  22. package/src/form/index.ts +3 -0
  23. package/src/format.test.ts +22 -0
  24. package/src/format.ts +36 -0
  25. package/src/index.ts +10 -0
  26. package/src/query/client.tsx +53 -0
  27. package/src/query/collection.test.tsx +278 -0
  28. package/src/query/collection.ts +207 -0
  29. package/src/query/index.ts +4 -0
  30. package/src/query/repository.ts +30 -0
  31. package/src/query/server-table.test.ts +136 -0
  32. package/src/query/server-table.ts +181 -0
  33. package/src/resource/client.test.ts +173 -0
  34. package/src/resource/client.ts +307 -0
  35. package/src/resource/connect-realtime.test.ts +135 -0
  36. package/src/resource/connect-realtime.ts +121 -0
  37. package/src/resource/document.test.ts +151 -0
  38. package/src/resource/document.ts +198 -0
  39. package/src/resource/idb.test.ts +169 -0
  40. package/src/resource/idb.ts +167 -0
  41. package/src/resource/index.ts +77 -0
  42. package/src/resource/list.test.ts +150 -0
  43. package/src/resource/list.ts +230 -0
  44. package/src/resource/local.test.ts +111 -0
  45. package/src/resource/local.ts +155 -0
  46. package/src/resource/realtime.test.ts +331 -0
  47. package/src/resource/realtime.ts +287 -0
  48. package/src/resource/resource.test.ts +186 -0
  49. package/src/resource/resource.ts +213 -0
  50. package/src/resource/useCall.test.ts +60 -0
  51. package/src/resource/useCall.ts +74 -0
  52. package/src/resource/useDoc.test.ts +31 -0
  53. package/src/resource/useDoc.ts +23 -0
  54. package/src/resource/useDoctype.test.ts +104 -0
  55. package/src/resource/useDoctype.ts +128 -0
  56. package/src/resource/useList.test.ts +32 -0
  57. package/src/resource/useList.ts +22 -0
  58. package/src/resource/useNewDoc.test.ts +67 -0
  59. package/src/resource/useNewDoc.ts +78 -0
  60. package/src/select.test.ts +13 -0
  61. package/src/select.ts +19 -0
  62. package/src/table-persist.test.ts +258 -0
  63. package/src/table-persist.ts +371 -0
  64. package/src/undo.test.ts +24 -0
  65. package/src/undo.ts +37 -0
  66. package/src/url-serialize.ts +93 -0
  67. package/src/url-state.test.ts +183 -0
  68. package/src/url-state.ts +168 -0
@@ -0,0 +1,287 @@
1
+ import { type Accessor, createSignal } from 'solid-js'
2
+
3
+ /**
4
+ * A realtime transport over a `WebSocket`.
5
+ *
6
+ * This is a **plain-`WebSocket`** helper, not a socket.io client: it opens one
7
+ * socket to a configurable URL and multiplexes subscriptions over it through
8
+ * `on`/`off`/`close`. The socket is created by an injectable
9
+ * {@link RealtimeSocketFactory}, so a consumer who already uses socket.io can
10
+ * pass `io(url)` as the factory — a socket.io `Socket` satisfies
11
+ * {@link RealtimeSocket} — and get named events for free. The wire protocol is
12
+ * deliberately *not* implemented here.
13
+ *
14
+ * The default factory adapts the native `WebSocket` to the same surface: it
15
+ * forwards the `open`/`close`/`error` lifecycle events and delivers each
16
+ * message as a single `message` event carrying the raw `event.data`. With the
17
+ * native transport there is no event multiplexing on the wire, so named
18
+ * subscriptions only fire under a transport that provides them (socket.io).
19
+ */
20
+
21
+ /** A listener registered with {@link Realtime.on}. */
22
+ export type RealtimeListener = (...args: unknown[]) => void
23
+
24
+ /**
25
+ * The minimal socket surface the realtime helper drives. A native `WebSocket`
26
+ * is adapted to it by the default factory; a `socket.io-client` `Socket`
27
+ * already satisfies it, so it can be passed through
28
+ * {@link RealtimeOptions.factory} directly.
29
+ */
30
+ export interface RealtimeSocket {
31
+ on(event: string, listener: RealtimeListener): unknown
32
+ off(event: string, listener: RealtimeListener): unknown
33
+ close(): void
34
+ send?(data: unknown): void
35
+ /** Whether the socket is currently connected (socket.io exposes this). */
36
+ readonly connected?: boolean
37
+ }
38
+
39
+ /** Build the transport for a URL. See {@link RealtimeOptions.factory}. */
40
+ export type RealtimeSocketFactory = (
41
+ url: string,
42
+ protocols?: string | string[],
43
+ ) => RealtimeSocket
44
+
45
+ /** The map of event name to payload, for a typed {@link Realtime}. */
46
+ export interface RealtimeEventMap {
47
+ [event: string]: unknown
48
+ }
49
+
50
+ /** Options for {@link createRealtime}. */
51
+ export interface RealtimeOptions {
52
+ /** WebSocket URL, or an accessor for a reactive URL. */
53
+ url: string | (() => string)
54
+ /** Subprotocols passed to the native `WebSocket` constructor. */
55
+ protocols?: string | string[]
56
+ /**
57
+ * Socket factory. Defaults to a native `WebSocket` adapter. Pass a
58
+ * socket.io client factory (`(url) => io(url)`) to reuse that transport.
59
+ */
60
+ factory?: RealtimeSocketFactory
61
+ /** Connect on creation. Defaults to `true`. */
62
+ auto?: boolean
63
+ /**
64
+ * Reconnect after an unexpected close, with exponential backoff. Defaults to
65
+ * `true`. Set `false` when the injected transport (socket.io) reconnects on
66
+ * its own.
67
+ */
68
+ reconnect?: boolean
69
+ /** First reconnect delay in ms. Defaults to `1000`. */
70
+ reconnectDelay?: number
71
+ /** Cap on the reconnect delay in ms. Defaults to `30000`. */
72
+ maxReconnectDelay?: number
73
+ onOpen?: () => void
74
+ onClose?: (event?: unknown) => void
75
+ onError?: (event?: unknown) => void
76
+ }
77
+
78
+ /** The signal-based realtime handle {@link createRealtime} returns. */
79
+ export interface Realtime<TEvents extends RealtimeEventMap = RealtimeEventMap> {
80
+ /** Whether the socket is currently connected. */
81
+ connected: Accessor<boolean>
82
+ /** Open the connection. Idempotent while already open. */
83
+ connect(): void
84
+ /** Subscribe to an event. Returns an unsubscribe function. */
85
+ on<K extends keyof TEvents & string>(
86
+ event: K,
87
+ listener: (data: TEvents[K]) => void,
88
+ ): () => void
89
+ /** Unsubscribe a listener previously registered with `on`. */
90
+ off(event: string, listener: RealtimeListener): void
91
+ /** Send a payload; the native transport JSON-encodes non-strings. */
92
+ send(data: unknown): void
93
+ /** Close the connection and stop reconnecting. */
94
+ close(): void
95
+ }
96
+
97
+ const DEFAULT_RECONNECT_DELAY = 1000
98
+ const DEFAULT_MAX_RECONNECT_DELAY = 30_000
99
+
100
+ /** Adapt a native `WebSocket` to {@link RealtimeSocket}. */
101
+ function nativeFactory(
102
+ url: string,
103
+ protocols?: string | string[],
104
+ ): RealtimeSocket {
105
+ const socket = protocols ? new WebSocket(url, protocols) : new WebSocket(url)
106
+ const messageHandlers = new Map<
107
+ RealtimeListener,
108
+ (event: MessageEvent) => void
109
+ >()
110
+
111
+ return {
112
+ on(event, listener) {
113
+ if (event === 'message') {
114
+ const handler = (message: MessageEvent) => listener(message.data)
115
+ messageHandlers.set(listener, handler)
116
+ socket.addEventListener('message', handler)
117
+ return
118
+ }
119
+ socket.addEventListener(
120
+ event as keyof WebSocketEventMap,
121
+ listener as EventListener,
122
+ )
123
+ },
124
+ off(event, listener) {
125
+ if (event === 'message') {
126
+ const handler = messageHandlers.get(listener)
127
+ if (handler) {
128
+ socket.removeEventListener('message', handler)
129
+ messageHandlers.delete(listener)
130
+ }
131
+ return
132
+ }
133
+ socket.removeEventListener(
134
+ event as keyof WebSocketEventMap,
135
+ listener as EventListener,
136
+ )
137
+ },
138
+ close() {
139
+ socket.close()
140
+ },
141
+ send(data) {
142
+ socket.send(typeof data === 'string' ? data : JSON.stringify(data))
143
+ },
144
+ get connected() {
145
+ return socket.readyState === WebSocket.OPEN
146
+ },
147
+ }
148
+ }
149
+
150
+ /** The default factory, or `undefined` when there is no browser `WebSocket`. */
151
+ function defaultFactory(): RealtimeSocketFactory | undefined {
152
+ if (typeof window === 'undefined' || typeof WebSocket === 'undefined') {
153
+ return undefined
154
+ }
155
+ return nativeFactory
156
+ }
157
+
158
+ /**
159
+ * Open a WebSocket and expose typed `on`/`off`/`close` over it.
160
+ *
161
+ * `connected` is a signal. Subscriptions registered before the socket exists
162
+ * are replayed onto every (re)connection, so a consumer can call `on` in any
163
+ * order. Lifecycle events are accepted in both dialects — `open`/`close` for a
164
+ * native `WebSocket`, `connect`/`disconnect` for socket.io.
165
+ */
166
+ export function createRealtime<
167
+ TEvents extends RealtimeEventMap = RealtimeEventMap,
168
+ >(options: RealtimeOptions): Realtime<TEvents> {
169
+ const [connected, setConnected] = createSignal(false)
170
+ const listeners = new Map<string, Set<RealtimeListener>>()
171
+ const reconnectEnabled = options.reconnect !== false
172
+ const baseDelay = options.reconnectDelay ?? DEFAULT_RECONNECT_DELAY
173
+ const maxDelay = options.maxReconnectDelay ?? DEFAULT_MAX_RECONNECT_DELAY
174
+
175
+ let socket: RealtimeSocket | null = null
176
+ let stopped = false
177
+ let attempt = 0
178
+ let timer: ReturnType<typeof setTimeout> | null = null
179
+
180
+ const currentUrl = (): string =>
181
+ typeof options.url === 'function' ? options.url() : options.url
182
+
183
+ function handleOpen(): void {
184
+ attempt = 0
185
+ setConnected(true)
186
+ options.onOpen?.()
187
+ }
188
+
189
+ function handleClose(event?: unknown): void {
190
+ setConnected(false)
191
+ options.onClose?.(event)
192
+ socket = null
193
+ if (!stopped && reconnectEnabled) scheduleReconnect()
194
+ }
195
+
196
+ function handleError(event?: unknown): void {
197
+ options.onError?.(event)
198
+ }
199
+
200
+ function scheduleReconnect(): void {
201
+ if (timer) return
202
+ const delay = Math.min(baseDelay * 2 ** attempt, maxDelay)
203
+ attempt += 1
204
+ timer = setTimeout(() => {
205
+ timer = null
206
+ open()
207
+ }, delay)
208
+ }
209
+
210
+ function attach(target: RealtimeSocket): void {
211
+ const isCurrent = (): boolean => socket === target
212
+ target.on('open', () => {
213
+ if (isCurrent()) handleOpen()
214
+ })
215
+ target.on('connect', () => {
216
+ if (isCurrent()) handleOpen()
217
+ })
218
+ target.on('close', (event) => {
219
+ if (isCurrent()) handleClose(event)
220
+ })
221
+ target.on('disconnect', (event) => {
222
+ if (isCurrent()) handleClose(event)
223
+ })
224
+ target.on('error', (event) => {
225
+ if (isCurrent()) handleError(event)
226
+ })
227
+ for (const [event, set] of listeners) {
228
+ for (const listener of set) target.on(event, listener)
229
+ }
230
+ }
231
+
232
+ function open(): void {
233
+ if (socket || stopped) return
234
+ const create = options.factory ?? defaultFactory()
235
+ if (!create) return
236
+ const target = create(currentUrl(), options.protocols)
237
+ socket = target
238
+ attach(target)
239
+ // A socket that is already connected (e.g. socket.io with `autoConnect`)
240
+ // does not re-fire `connect`, so reflect its state now.
241
+ if (target.connected) handleOpen()
242
+ }
243
+
244
+ function connect(): void {
245
+ stopped = false
246
+ open()
247
+ }
248
+
249
+ function on<K extends keyof TEvents & string>(
250
+ event: K,
251
+ listener: (data: TEvents[K]) => void,
252
+ ): () => void {
253
+ const wrapped = listener as RealtimeListener
254
+ const set = listeners.get(event) ?? new Set<RealtimeListener>()
255
+ set.add(wrapped)
256
+ listeners.set(event, set)
257
+ socket?.on(event, wrapped)
258
+ return () => off(event, wrapped)
259
+ }
260
+
261
+ function off(event: string, listener: RealtimeListener): void {
262
+ listeners.get(event)?.delete(listener)
263
+ socket?.off(event, listener)
264
+ }
265
+
266
+ function send(data: unknown): void {
267
+ socket?.send?.(data)
268
+ }
269
+
270
+ function close(): void {
271
+ stopped = true
272
+ if (timer) {
273
+ clearTimeout(timer)
274
+ timer = null
275
+ }
276
+ const target = socket
277
+ // `handleClose` only runs for the current socket, so call it before the
278
+ // socket is detached; the transport's own close event is then ignored.
279
+ handleClose()
280
+ target?.close()
281
+ }
282
+
283
+ // Auto-connect on creation. `connect` is a plain action, not a signal read.
284
+ if (options.auto !== false) connect()
285
+
286
+ return { connected, connect, on, off, send, close }
287
+ }
@@ -0,0 +1,186 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest'
2
+ import { RequestError } from './client'
3
+ import { createResource } from './resource'
4
+
5
+ function mockResponse(
6
+ body: unknown,
7
+ init: { ok?: boolean; status?: number; statusText?: string } = {},
8
+ ): Response {
9
+ const { ok = true, status = 200, statusText = 'OK' } = init
10
+ return {
11
+ ok,
12
+ status,
13
+ statusText,
14
+ json: async () => body,
15
+ text: async () => (typeof body === 'string' ? body : JSON.stringify(body)),
16
+ } as unknown as Response
17
+ }
18
+
19
+ interface FetchCall {
20
+ mock: { calls: Array<[string, RequestInit]> }
21
+ }
22
+
23
+ function stubFetch(...responses: Response[]): FetchCall {
24
+ const mock = vi.fn()
25
+ for (const response of responses) mock.mockResolvedValueOnce(response)
26
+ vi.stubGlobal('fetch', mock)
27
+ return mock as unknown as FetchCall
28
+ }
29
+
30
+ function urlAt(mock: FetchCall, index = 0): string {
31
+ return mock.mock.calls[index]?.[0] ?? ''
32
+ }
33
+
34
+ function bodyAt(mock: FetchCall, index = 0): Record<string, unknown> {
35
+ const call = mock.mock.calls[index]
36
+ return JSON.parse(String(call?.[1]?.body)) as Record<string, unknown>
37
+ }
38
+
39
+ afterEach(() => {
40
+ vi.unstubAllGlobals()
41
+ })
42
+
43
+ describe('createResource', () => {
44
+ it('loads data and tracks the loading transition', async () => {
45
+ stubFetch(mockResponse({ message: { name: 'T-1' } }))
46
+ const resource = createResource<{ name: string }>({
47
+ url: 'frappe.client.get',
48
+ method: 'GET',
49
+ })
50
+
51
+ expect(resource.loading()).toBe(false)
52
+ const promise = resource.reload()
53
+ expect(resource.loading()).toBe(true)
54
+
55
+ const data = await promise
56
+ expect(data).toEqual({ name: 'T-1' })
57
+ expect(resource.data()).toEqual({ name: 'T-1' })
58
+ expect(resource.loading()).toBe(false)
59
+ expect(resource.error()).toBe(null)
60
+ expect(resource.fetched()).toBe(true)
61
+ })
62
+
63
+ it('reload resolves on error, sets error and restores previous data', async () => {
64
+ stubFetch(
65
+ mockResponse({ message: { name: 'A' } }),
66
+ mockResponse(
67
+ { _error_message: 'nope' },
68
+ { ok: false, status: 500, statusText: 'Server Error' },
69
+ ),
70
+ )
71
+ const resource = createResource<{ name: string }>({
72
+ url: 'frappe.client.get',
73
+ method: 'GET',
74
+ })
75
+
76
+ await resource.reload()
77
+ const result = await resource.reload()
78
+
79
+ expect(result).toEqual({ name: 'A' })
80
+ expect(resource.data()).toEqual({ name: 'A' })
81
+ expect(resource.error()).toBeInstanceOf(RequestError)
82
+ expect(resource.loading()).toBe(false)
83
+ })
84
+
85
+ it('submit rejects on error', async () => {
86
+ stubFetch(
87
+ mockResponse(
88
+ { _error_message: 'bad' },
89
+ { ok: false, status: 400, statusText: 'Bad Request' },
90
+ ),
91
+ )
92
+ const resource = createResource({ url: 'frappe.client.insert' })
93
+
94
+ await expect(resource.submit()).rejects.toBeInstanceOf(RequestError)
95
+ expect(resource.error()).toBeInstanceOf(RequestError)
96
+ })
97
+
98
+ it('sends makeParams output to the configured url and method', async () => {
99
+ const fetchMock = stubFetch(mockResponse({ message: 'ok' }))
100
+ const resource = createResource<string, { name: string }>({
101
+ url: 'frappe.client.delete',
102
+ method: 'POST',
103
+ makeParams: ({ name }) => ({ doctype: 'Task', name }),
104
+ })
105
+
106
+ await resource.submit({ name: 'T-1' })
107
+
108
+ expect(urlAt(fetchMock)).toBe('/api/method/frappe.client.delete')
109
+ expect(bodyAt(fetchMock)).toEqual({ doctype: 'Task', name: 'T-1' })
110
+ })
111
+
112
+ it('auto fetches on creation', async () => {
113
+ const fetchMock = stubFetch(mockResponse({ message: 1 }))
114
+ const resource = createResource<number>({
115
+ url: 'myapp.api.count',
116
+ method: 'GET',
117
+ auto: true,
118
+ })
119
+
120
+ await vi.waitFor(() => expect(resource.data()).toBe(1))
121
+ expect(fetchMock.mock.calls.length).toBe(1)
122
+ })
123
+
124
+ it('supports setData and reset', () => {
125
+ const resource = createResource<number>({ url: 'x', initialData: 0 })
126
+
127
+ resource.setData(5)
128
+ expect(resource.data()).toBe(5)
129
+
130
+ resource.setData((current) => (current ?? 0) + 1)
131
+ expect(resource.data()).toBe(6)
132
+
133
+ resource.reset()
134
+ expect(resource.data()).toBe(0)
135
+ expect(resource.fetched()).toBe(false)
136
+ expect(resource.loading()).toBe(false)
137
+ })
138
+
139
+ it('aborts an in-flight request without surfacing an error', async () => {
140
+ const mock = vi.fn(
141
+ (_url: string, init?: RequestInit) =>
142
+ new Promise<Response>((_resolve, reject) => {
143
+ init?.signal?.addEventListener('abort', () => {
144
+ const error = new Error('aborted')
145
+ error.name = 'AbortError'
146
+ reject(error)
147
+ })
148
+ }),
149
+ )
150
+ vi.stubGlobal('fetch', mock)
151
+
152
+ const resource = createResource({ url: 'myapp.api.slow' })
153
+ const promise = resource.reload()
154
+ expect(resource.loading()).toBe(true)
155
+
156
+ resource.abort()
157
+ await promise
158
+
159
+ expect(resource.loading()).toBe(false)
160
+ expect(resource.error()).toBe(null)
161
+ })
162
+
163
+ it('discards a superseded response', async () => {
164
+ let resolveFirst: ((value: Response) => void) | undefined
165
+ const first = new Promise<Response>((resolve) => {
166
+ resolveFirst = resolve
167
+ })
168
+ const mock = vi
169
+ .fn()
170
+ .mockReturnValueOnce(first)
171
+ .mockResolvedValueOnce(mockResponse({ message: { name: 'second' } }))
172
+ vi.stubGlobal('fetch', mock)
173
+
174
+ const resource = createResource<{ name: string }>({
175
+ url: 'frappe.client.get',
176
+ method: 'GET',
177
+ })
178
+
179
+ const stale = resource.reload()
180
+ const fresh = resource.reload()
181
+ resolveFirst?.(mockResponse({ message: { name: 'first' } }))
182
+ await Promise.all([stale, fresh])
183
+
184
+ expect(resource.data()).toEqual({ name: 'second' })
185
+ })
186
+ })
@@ -0,0 +1,213 @@
1
+ import { type Accessor, createSignal } from 'solid-js'
2
+ import {
3
+ isAbortError,
4
+ normalizeError,
5
+ type RequestError,
6
+ type RequestOptions,
7
+ request,
8
+ } from './client'
9
+
10
+ /** The request function a resource uses. Defaults to the module {@link request}. */
11
+ export type ResourceFetcher = <TResponse = unknown>(
12
+ options: RequestOptions,
13
+ ) => Promise<TResponse>
14
+
15
+ /**
16
+ * Options for {@link createResource}. `url` and `params` may be accessors so
17
+ * the resource re-reads them on every fetch (a reactive `name`, a filter).
18
+ */
19
+ export interface ResourceOptions<
20
+ TData = unknown,
21
+ TInput = unknown,
22
+ TParams = Record<string, unknown>,
23
+ > {
24
+ /** Absolute URL, `/api/method/…`, or a dotted method name. Accessor allowed. */
25
+ url: string | (() => string)
26
+ /** HTTP method. Defaults to `POST`. */
27
+ method?: string
28
+ /** Static params, or an accessor for reactive params. */
29
+ params?: TParams | (() => TParams)
30
+ /**
31
+ * Map the value passed to `reload`/`submit` into request params. When absent,
32
+ * a passed value is used as the params directly.
33
+ */
34
+ makeParams?: (input: TInput) => TParams
35
+ /** Value before the first fetch. */
36
+ initialData?: TData | null
37
+ /** Fetch on creation. Defaults to `false`. */
38
+ auto?: boolean
39
+ /** Map the raw response into `TData`. */
40
+ transform?: (data: unknown) => TData
41
+ onSuccess?: (data: TData) => void
42
+ onError?: (error: RequestError) => void
43
+ /** Custom request function; defaults to the module client. */
44
+ fetcher?: ResourceFetcher
45
+ }
46
+
47
+ /**
48
+ * A generic, signal-based resource. Every field is an accessor; every action is
49
+ * a plain function. There is no Vue `ref`/`reactive` and no `v-model`.
50
+ */
51
+ export interface Resource<TData = unknown, TInput = unknown> {
52
+ data: Accessor<TData | null>
53
+ loading: Accessor<boolean>
54
+ error: Accessor<RequestError | null>
55
+ /** True once a fetch has resolved at least once. */
56
+ fetched: Accessor<boolean>
57
+ /** The in-flight promise, or the last one. */
58
+ promise: Accessor<Promise<TData | null> | null>
59
+ /** Read: fetch, resolving with the data (or the previous data) even on failure. */
60
+ reload: (input?: TInput) => Promise<TData | null>
61
+ /** Alias of {@link Resource.reload}. */
62
+ fetch: (input?: TInput) => Promise<TData | null>
63
+ /** Write: like `reload`, but rejects on failure. */
64
+ submit: (input?: TInput) => Promise<TData | null>
65
+ /** Replace `data`, directly or from its current value. */
66
+ setData: (data: TData | ((current: TData | null) => TData)) => void
67
+ /** Back to `initialData`, clearing loading/error/fetched. */
68
+ reset: () => void
69
+ /** Abort the in-flight request without surfacing an error. */
70
+ abort: () => void
71
+ }
72
+
73
+ /**
74
+ * The signal-based replacement for frappe-ui's `createResource`.
75
+ *
76
+ * `reload` (and its `fetch` alias) resolves on failure — a read reports through
77
+ * `error`. `submit` rejects on failure — a write must not run its success path.
78
+ * A new fetch aborts the previous one; a superseded response is discarded, so
79
+ * `data` always reflects the newest request.
80
+ */
81
+ export function createResource<
82
+ TData = unknown,
83
+ TInput = unknown,
84
+ TParams = Record<string, unknown>,
85
+ >(options: ResourceOptions<TData, TInput, TParams>): Resource<TData, TInput> {
86
+ const fetcher: ResourceFetcher = options.fetcher ?? request
87
+
88
+ const [data, setDataSignal] = createSignal<TData | null>(
89
+ options.initialData ?? null,
90
+ )
91
+ const [loading, setLoading] = createSignal(false)
92
+ const [error, setError] = createSignal<RequestError | null>(null)
93
+ const [fetched, setFetched] = createSignal(false)
94
+ const [promise, setPromise] = createSignal<Promise<TData | null> | null>(null)
95
+
96
+ let controller: AbortController | null = null
97
+ // Monotonic request id. Only the newest request may write state; an older
98
+ // response (or a superseded abort) is ignored.
99
+ let sequence = 0
100
+
101
+ function resolveUrl(): string {
102
+ return typeof options.url === 'function' ? options.url() : options.url
103
+ }
104
+
105
+ function resolveParams(input: TInput | undefined): TParams | undefined {
106
+ if (options.makeParams) return options.makeParams(input as TInput)
107
+ if (input !== undefined) return input as unknown as TParams
108
+ const params = options.params
109
+ if (typeof params === 'function') return (params as () => TParams)()
110
+ return params
111
+ }
112
+
113
+ function transform(value: unknown): TData {
114
+ if (options.transform) {
115
+ const result = options.transform(value)
116
+ if (result !== undefined) return result
117
+ }
118
+ return value as TData
119
+ }
120
+
121
+ function execute(
122
+ input: TInput | undefined,
123
+ rejectOnError: boolean,
124
+ ): Promise<TData | null> {
125
+ const current = ++sequence
126
+ controller?.abort()
127
+ controller = new AbortController()
128
+ setLoading(true)
129
+ setError(null)
130
+ const previous = data()
131
+
132
+ const params = resolveParams(input)
133
+ const result = fetcher<TData>({
134
+ url: resolveUrl(),
135
+ method: options.method,
136
+ params: params as Record<string, unknown> | undefined,
137
+ signal: controller.signal,
138
+ })
139
+ .then((response) => {
140
+ if (current !== sequence) return data()
141
+ const transformed = transform(response)
142
+ setDataSignal(() => transformed)
143
+ setFetched(true)
144
+ options.onSuccess?.(transformed)
145
+ return transformed
146
+ })
147
+ .catch((caught: unknown) => {
148
+ if (current !== sequence || isAbortError(caught)) return data()
149
+ const normalized = normalizeError(caught)
150
+ setError(normalized)
151
+ setDataSignal(() => previous)
152
+ options.onError?.(normalized)
153
+ if (rejectOnError) throw normalized
154
+ return data()
155
+ })
156
+ .finally(() => {
157
+ if (current === sequence) setLoading(false)
158
+ })
159
+
160
+ setPromise(result)
161
+ return result
162
+ }
163
+
164
+ function reload(input?: TInput): Promise<TData | null> {
165
+ return execute(input, false)
166
+ }
167
+
168
+ function submit(input?: TInput): Promise<TData | null> {
169
+ return execute(input, true)
170
+ }
171
+
172
+ function setData(next: TData | ((current: TData | null) => TData)): void {
173
+ if (typeof next === 'function') {
174
+ const updater = next as (current: TData | null) => TData
175
+ setDataSignal(() => updater(data()))
176
+ return
177
+ }
178
+ setDataSignal(() => next)
179
+ }
180
+
181
+ function reset(): void {
182
+ controller?.abort()
183
+ sequence++
184
+ setDataSignal(() => options.initialData ?? null)
185
+ setLoading(false)
186
+ setError(null)
187
+ setFetched(false)
188
+ setPromise(null)
189
+ }
190
+
191
+ function abort(): void {
192
+ controller?.abort()
193
+ }
194
+
195
+ // Auto-fetch on creation. `reload` is a plain action, not a signal — the
196
+ // reactivity rule cannot tell the difference, so it is silenced here.
197
+ // eslint-disable-next-line solid/reactivity
198
+ if (options.auto) void reload()
199
+
200
+ return {
201
+ data,
202
+ loading,
203
+ error,
204
+ fetched,
205
+ promise,
206
+ reload,
207
+ fetch: reload,
208
+ submit,
209
+ setData,
210
+ reset,
211
+ abort,
212
+ }
213
+ }