@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,102 @@
1
+ import { render, screen, waitFor } from '@solidjs/testing-library'
2
+ import userEvent from '@testing-library/user-event'
3
+ import { untrack } from 'solid-js'
4
+ import { describe, expect, it, vi } from 'vitest'
5
+ import { createAppForm, Form, fieldError } from './form'
6
+
7
+ function Harness(props: {
8
+ onSubmit: (value: { name: string }) => Promise<void>
9
+ }): import('solid-js').JSX.Element {
10
+ const { form, submitting } = createAppForm({
11
+ defaultValues: { name: '' },
12
+ onSubmit: untrack(() => props.onSubmit),
13
+ })
14
+ return (
15
+ <Form form={form} class="flex flex-col gap-2">
16
+ <form.Field
17
+ name="name"
18
+ validators={{
19
+ onChange: ({ value }) =>
20
+ value.length < 3 ? 'Use at least 3 characters.' : undefined,
21
+ onSubmit: ({ value }) =>
22
+ value.length < 3 ? 'Use at least 3 characters.' : undefined,
23
+ }}
24
+ >
25
+ {(field) => (
26
+ <>
27
+ <input
28
+ aria-label="Name"
29
+ value={field().state.value}
30
+ onInput={(event) =>
31
+ field().handleChange(event.currentTarget.value)
32
+ }
33
+ />
34
+ <p role="alert">{fieldError(field())}</p>
35
+ <button type="submit" disabled={submitting()}>
36
+ Save
37
+ </button>
38
+ </>
39
+ )}
40
+ </form.Field>
41
+ </Form>
42
+ )
43
+ }
44
+
45
+ describe('createAppForm', () => {
46
+ it('submits a valid value and blocks an invalid one', async () => {
47
+ const onSubmit = vi.fn(async (_value: { name: string }) => {})
48
+ render(() => <Harness onSubmit={onSubmit} />)
49
+ const field = screen.getByRole('textbox', { name: 'Name' })
50
+
51
+ await userEvent.type(field, 'ab')
52
+ await userEvent.click(screen.getByRole('button', { name: 'Save' }))
53
+ expect(onSubmit).not.toHaveBeenCalled()
54
+ await waitFor(() =>
55
+ expect(screen.getByRole('alert').textContent).toContain(
56
+ 'at least 3 characters',
57
+ ),
58
+ )
59
+
60
+ await userEvent.type(field, 'c')
61
+ await userEvent.click(screen.getByRole('button', { name: 'Save' }))
62
+ await waitFor(() => expect(onSubmit).toHaveBeenCalled())
63
+ expect(onSubmit.mock.calls[0]?.[0]).toEqual({ name: 'abc' })
64
+ })
65
+
66
+ it('reports submitting while the handler is pending, and disables the button', async () => {
67
+ let finish!: () => void
68
+ const onSubmit = vi.fn(
69
+ () =>
70
+ new Promise<void>((resolve) => {
71
+ finish = resolve
72
+ }),
73
+ )
74
+ render(() => <Harness onSubmit={onSubmit} />)
75
+ await userEvent.type(screen.getByRole('textbox', { name: 'Name' }), 'abc')
76
+ const button = screen.getByRole('button', { name: 'Save' })
77
+ await userEvent.click(button)
78
+ await waitFor(() => expect(onSubmit).toHaveBeenCalled())
79
+ await waitFor(() => expect(button).toBeDisabled())
80
+
81
+ finish()
82
+ await waitFor(() => expect(button).not.toBeDisabled())
83
+ })
84
+ })
85
+
86
+ describe('fieldError', () => {
87
+ it('turns TanStack’s error list into the single string a field takes', () => {
88
+ expect(fieldError({ state: { meta: { errors: [] } } })).toBeUndefined()
89
+ expect(fieldError({ state: { meta: { errors: ['Required.'] } } })).toBe(
90
+ 'Required.',
91
+ )
92
+ expect(
93
+ fieldError({
94
+ state: { meta: { errors: [{ message: 'Required.' }, 'Too short.'] } },
95
+ }),
96
+ ).toBe('Required. Too short.')
97
+ // The same message from `onChange` and `onSubmit` is one sentence, not two.
98
+ expect(
99
+ fieldError({ state: { meta: { errors: ['Required.', 'Required.'] } } }),
100
+ ).toBe('Required.')
101
+ })
102
+ })
@@ -0,0 +1,85 @@
1
+ import { createForm } from '@tanstack/solid-form'
2
+ import { type Accessor, type JSX, splitProps } from 'solid-js'
3
+
4
+ /**
5
+ * The form surface: the kit's answer to "how does a page submit".
6
+ *
7
+ * TanStack Form stays the engine — validators, field state, reset. What this
8
+ * adds is the three things every page was writing by hand: a submit lifecycle
9
+ * the button can read, a `<form>` that never lets the browser's native
10
+ * validation paint first, and one place that turns a field's error list into the
11
+ * single `error` string the kit's `Field` contract takes.
12
+ */
13
+
14
+ export interface AppFormOptions<T extends object> {
15
+ defaultValues: T
16
+ /**
17
+ * Runs on submit. While the returned promise is pending the form reports
18
+ * `submitting()`, which is what a submit button's `loading` reads.
19
+ */
20
+ onSubmit: (value: T, api: { reset: () => void }) => void | Promise<void>
21
+ }
22
+
23
+ /** The `error` prop of a field, from TanStack's error list. */
24
+ export function fieldError(field: {
25
+ state: { meta: { errors: unknown[] } }
26
+ }): string | undefined {
27
+ const { errors } = field.state.meta
28
+ if (errors.length === 0) return undefined
29
+ const messages = errors.map((error) =>
30
+ typeof error === 'string'
31
+ ? error
32
+ : error && typeof error === 'object' && 'message' in error
33
+ ? String((error as { message: unknown }).message)
34
+ : String(error),
35
+ )
36
+ // A field usually declares the same message for `onChange` and `onSubmit`
37
+ // (validate as you type, and again on the way out). TanStack keeps one entry
38
+ // per validator, so the naive join prints the sentence twice.
39
+ return [...new Set(messages)].join(' ')
40
+ }
41
+
42
+ export function createAppForm<T extends object>(options: AppFormOptions<T>) {
43
+ const form = createForm(() => ({
44
+ defaultValues: options.defaultValues,
45
+ onSubmit: async ({ value, formApi }) => {
46
+ await options.onSubmit(value as T, { reset: () => formApi.reset() })
47
+ },
48
+ }))
49
+ const submitting = form.useSelector((state) => state.isSubmitting)
50
+ return {
51
+ form,
52
+ /** True while the submit handler's promise is pending. */
53
+ submitting: submitting as Accessor<boolean>,
54
+ }
55
+ }
56
+
57
+ /**
58
+ * The `<form>` element, wired.
59
+ *
60
+ * `noValidate` because the kit's own field messages are the error UI; the native
61
+ * bubble would appear first, say less, and disappear on the next keystroke.
62
+ * `preventDefault` because this is a single-page app: a real submit would
63
+ * navigate.
64
+ */
65
+ export function Form(props: {
66
+ form: { handleSubmit: () => unknown }
67
+ children: JSX.Element
68
+ class?: string
69
+ }): JSX.Element {
70
+ const [local, rest] = splitProps(props, ['form', 'children', 'class'])
71
+ return (
72
+ <form
73
+ {...rest}
74
+ noValidate
75
+ class={local.class}
76
+ onSubmit={(event) => {
77
+ event.preventDefault()
78
+ event.stopPropagation()
79
+ void local.form.handleSubmit()
80
+ }}
81
+ >
82
+ {local.children}
83
+ </form>
84
+ )
85
+ }
@@ -0,0 +1 @@
1
+ export * from './form'
@@ -0,0 +1,22 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { bytes, count, isEmail, usd, usdK } from './format'
3
+
4
+ describe('format', () => {
5
+ it('formats money, counts and sizes the way the pages did', () => {
6
+ expect(usd(128430)).toBe('$128,430')
7
+ expect(usd(0)).toBe('$0')
8
+ expect(usdK(42500)).toBe('$43k')
9
+ expect(usdK(400)).toBe('$0k')
10
+ expect(count(128430)).toBe('128,430')
11
+ expect(bytes(512)).toBe('512 B')
12
+ expect(bytes(2048)).toBe('2.0 KB')
13
+ expect(bytes(5 * 1024 * 1024)).toBe('5.0 MB')
14
+ })
15
+
16
+ it('accepts ordinary addresses and rejects the rest', () => {
17
+ expect(isEmail('rhea@example.com')).toBe(true)
18
+ expect(isEmail('rhea@example')).toBe(false)
19
+ expect(isEmail('rhea kapoor@example.com')).toBe(false)
20
+ expect(isEmail('')).toBe(false)
21
+ })
22
+ })
package/src/format.ts ADDED
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Shared formatting: money, counts, sizes, addresses.
3
+ *
4
+ * Built once at module scope — an `Intl.NumberFormat` per render is the kind of
5
+ * cost that never shows in a profile and always shows in a list. The examples
6
+ * had four near-identical USD copies; this is the one.
7
+ */
8
+
9
+ const usdFormat = new Intl.NumberFormat('en-US', {
10
+ style: 'currency',
11
+ currency: 'USD',
12
+ maximumFractionDigits: 0,
13
+ })
14
+
15
+ /** Whole-dollar USD: `usd(128430)` → `$128,430`. */
16
+ export const usd = (value: number): string => usdFormat.format(value)
17
+
18
+ /** Compact axis form: `usdK(42500)` → `$43k`. */
19
+ export const usdK = (value: number): string => `$${Math.round(value / 1000)}k`
20
+
21
+ const countFormat = new Intl.NumberFormat('en-US')
22
+
23
+ /** Grouped integer: `count(128430)` → `128,430`. */
24
+ export const count = (value: number): string => countFormat.format(value)
25
+
26
+ /** Human file size: `bytes(2048)` → `2.0 KB`. */
27
+ export const bytes = (value: number): string =>
28
+ value < 1024
29
+ ? `${value} B`
30
+ : value < 1024 * 1024
31
+ ? `${(value / 1024).toFixed(1)} KB`
32
+ : `${(value / (1024 * 1024)).toFixed(1)} MB`
33
+
34
+ /** The one address check every example form shares. */
35
+ export const isEmail = (value: string): boolean =>
36
+ /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
package/src/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ export * from './download'
2
+ export * from './format'
3
+ export * from './select'
4
+ export * from './undo'
@@ -0,0 +1,53 @@
1
+ import { QueryClient, QueryClientProvider } from '@tanstack/solid-query'
2
+ import { type JSX, onCleanup, untrack } from 'solid-js'
3
+
4
+ /**
5
+ * The query client an app boots with.
6
+ *
7
+ * The defaults are the kit's opinion, stated once: local data is not a
8
+ * dashboard (no refetch on window focus), a transient failure is worth one retry,
9
+ * and an error is data the page renders rather than an exception that tears the
10
+ * tree down. A page overrides any of them per query.
11
+ */
12
+ export interface DataClientOptions {
13
+ /** How long a list is fresh. Default `30_000`. */
14
+ staleTime?: number
15
+ /** Retries after the first failure. Default `1`. */
16
+ retry?: number
17
+ }
18
+
19
+ export function createDataClient(options: DataClientOptions = {}): QueryClient {
20
+ return new QueryClient({
21
+ defaultOptions: {
22
+ queries: {
23
+ staleTime: options.staleTime ?? 30_000,
24
+ retry: options.retry ?? 1,
25
+ refetchOnWindowFocus: false,
26
+ throwOnError: false,
27
+ },
28
+ mutations: { retry: 0 },
29
+ },
30
+ })
31
+ }
32
+
33
+ /**
34
+ * The provider a page wraps its workspace in.
35
+ *
36
+ * Pass `client` to share one (a workspace with several panes shares its cache);
37
+ * omit it and the provider owns a client and clears it on unmount, so a route
38
+ * change never leaves timers or caches behind.
39
+ */
40
+ export function DataProvider(props: {
41
+ client?: QueryClient
42
+ options?: DataClientOptions
43
+ children: JSX.Element
44
+ }): JSX.Element {
45
+ // Chosen once, by design: swapping a provider's client mid-tree would orphan
46
+ // the cache every consumer is reading from.
47
+ const provided = untrack(() => props.client)
48
+ const client = provided ?? createDataClient(untrack(() => props.options))
49
+ if (!provided) onCleanup(() => client.clear())
50
+ return (
51
+ <QueryClientProvider client={client}>{props.children}</QueryClientProvider>
52
+ )
53
+ }
@@ -0,0 +1,151 @@
1
+ import { render, screen, waitFor } from '@solidjs/testing-library'
2
+ import { For, type JSX, untrack } from 'solid-js'
3
+ import { describe, expect, it, vi } from 'vitest'
4
+ import { DataProvider } from './client'
5
+ import { type Collection, createCollection } from './collection'
6
+ import type { Repository } from './repository'
7
+
8
+ interface Row {
9
+ id: string
10
+ title: string
11
+ }
12
+
13
+ interface Deferred<T> {
14
+ promise: Promise<T>
15
+ resolve: (value: T) => void
16
+ reject: (error: unknown) => void
17
+ }
18
+
19
+ function deferred<T>(): Deferred<T> {
20
+ let resolve!: (value: T) => void
21
+ let reject!: (error: unknown) => void
22
+ const promise = new Promise<T>((ok, fail) => {
23
+ resolve = ok
24
+ reject = fail
25
+ })
26
+ return { promise, resolve, reject }
27
+ }
28
+
29
+ function makeRepository(initial: Row[]) {
30
+ let rows = [...initial]
31
+ const repository: Repository<Row, { title: string }> = {
32
+ list: async () => [...rows],
33
+ create: async (input) => {
34
+ const row = { id: `R-${rows.length + 1}`, title: input.title }
35
+ rows = [row, ...rows]
36
+ return row
37
+ },
38
+ update: vi.fn(async (id, patch) => {
39
+ rows = rows.map((row) => (row.id === id ? { ...row, ...patch } : row))
40
+ return rows.find((row) => row.id === id) as Row
41
+ }),
42
+ remove: async (ids) => {
43
+ const gone = new Set(ids)
44
+ rows = rows.filter((row) => !gone.has(row.id))
45
+ },
46
+ }
47
+ return { repository, rows: () => rows }
48
+ }
49
+
50
+ function Harness(props: {
51
+ repository: Repository<Row, { title: string }>
52
+ ready: (collection: Collection<Row, { title: string }>) => void
53
+ }): JSX.Element {
54
+ const collection = createCollection(() => ({
55
+ key: ['rows'],
56
+ repository: props.repository,
57
+ retry: false,
58
+ }))
59
+ untrack(() => props.ready(collection))
60
+ return (
61
+ <ul>
62
+ <For each={collection.query.rows()}>{(row) => <li>{row.title}</li>}</For>
63
+ </ul>
64
+ )
65
+ }
66
+
67
+ function mount(repository: Repository<Row, { title: string }>) {
68
+ let collection!: Collection<Row, { title: string }>
69
+ render(() => (
70
+ <DataProvider>
71
+ <Harness
72
+ repository={repository}
73
+ ready={(value) => (collection = value)}
74
+ />
75
+ </DataProvider>
76
+ ))
77
+ return () => collection
78
+ }
79
+
80
+ describe('createCollection', () => {
81
+ it('loads the list and reports its pending and error states', async () => {
82
+ const failing = deferred<Row[]>()
83
+ const { repository } = makeRepository([{ id: '1', title: 'One' }])
84
+ const broken: Repository<Row, { title: string }> = {
85
+ ...repository,
86
+ list: () => failing.promise,
87
+ }
88
+ const collection = mount(broken)
89
+ expect(collection().query.isPending()).toBe(true)
90
+
91
+ failing.reject(new Error('The service is down'))
92
+ await waitFor(() => expect(collection().query.isError()).toBe(true))
93
+ expect(collection().query.error()?.message).toBe('The service is down')
94
+ })
95
+
96
+ it('patches optimistically and restores the list when the request fails', async () => {
97
+ const gate = deferred<Row>()
98
+ const { repository } = makeRepository([
99
+ { id: '1', title: 'One' },
100
+ { id: '2', title: 'Two' },
101
+ ])
102
+ const gated: Repository<Row, { title: string }> = {
103
+ ...repository,
104
+ update: (id, patch) => {
105
+ gate.promise.then(
106
+ () => repository.update(id, patch),
107
+ () => {},
108
+ )
109
+ return gate.promise
110
+ },
111
+ }
112
+ const collection = mount(gated)
113
+ await waitFor(() => expect(screen.getByText('One')).toBeTruthy())
114
+
115
+ collection().update.mutate({ id: '1', patch: { title: 'Changed' } })
116
+ // On screen before the request settles — that is the point of optimistic.
117
+ await waitFor(() => expect(screen.getByText('Changed')).toBeTruthy())
118
+
119
+ gate.reject(new Error('nope'))
120
+ await waitFor(() => expect(screen.getByText('One')).toBeTruthy())
121
+ expect(screen.queryByText('Changed')).toBeNull()
122
+ })
123
+
124
+ it('creates through the repository and refreshes the list', async () => {
125
+ const { repository, rows } = makeRepository([{ id: '1', title: 'One' }])
126
+ const collection = mount(repository)
127
+ await waitFor(() => expect(screen.getByText('One')).toBeTruthy())
128
+
129
+ await collection().create.mutateAsync({ title: 'New' })
130
+ expect(rows()[0]?.title).toBe('New')
131
+ await waitFor(() => expect(screen.getByText('New')).toBeTruthy())
132
+ })
133
+
134
+ it('completes many at once and removes through the same seam', async () => {
135
+ const { repository } = makeRepository([
136
+ { id: '1', title: 'One' },
137
+ { id: '2', title: 'Two' },
138
+ ])
139
+ const collection = mount(repository)
140
+ await waitFor(() => expect(screen.getByText('One')).toBeTruthy())
141
+
142
+ await collection().updateMany.mutateAsync({
143
+ ids: ['1', '2'],
144
+ patch: { title: 'Done' },
145
+ })
146
+ await waitFor(() => expect(screen.getAllByText('Done')).toHaveLength(2))
147
+
148
+ await collection().remove.mutateAsync({ ids: ['1', '2'] })
149
+ await waitFor(() => expect(screen.queryByText('Done')).toBeNull())
150
+ })
151
+ })
@@ -0,0 +1,192 @@
1
+ import {
2
+ createMutation,
3
+ createQuery,
4
+ type QueryKey,
5
+ useQueryClient,
6
+ } from '@tanstack/solid-query'
7
+ import type { Accessor } from 'solid-js'
8
+ import type { NewRecord, Repository } from './repository'
9
+
10
+ /**
11
+ * A list and the four things a page does to it.
12
+ *
13
+ * `createCollection` is the kit's answer to "how does a page hold its records":
14
+ * one query for the list, and mutations that are **optimistic and reversible** —
15
+ * a patch lands on screen immediately, and a failed request puts the previous
16
+ * list back. The page renders `isPending` / `error` and never touches the cache.
17
+ *
18
+ * The list is keyed by `key`, so two panes reading the same collection share one
19
+ * request and one update. The repository stays the only place that knows where
20
+ * the records come from.
21
+ */
22
+
23
+ export interface ListQuery<T> {
24
+ /** The records; `[]` while the first request is in flight. */
25
+ rows: Accessor<T[]>
26
+ /** The first request has not settled. */
27
+ isPending: Accessor<boolean>
28
+ /** A request is in flight, first or refetch. */
29
+ isFetching: Accessor<boolean>
30
+ /** The last request failed; `error` says why. */
31
+ isError: Accessor<boolean>
32
+ error: Accessor<Error | undefined>
33
+ /** Re-run the list request (the "Try again" button). */
34
+ refetch: () => Promise<unknown>
35
+ }
36
+
37
+ export interface CollectionMutation<Input, Result> {
38
+ mutate: (input: Input) => void
39
+ /** Resolves when the mutation *and* the list refresh have settled. */
40
+ mutateAsync: (input: Input) => Promise<Result>
41
+ isPending: Accessor<boolean>
42
+ error: Accessor<Error | undefined>
43
+ }
44
+
45
+ export interface Collection<T extends { id: string }, New = NewRecord<T>> {
46
+ query: ListQuery<T>
47
+ create: CollectionMutation<New, T>
48
+ update: CollectionMutation<{ id: string; patch: Partial<T> }, T>
49
+ /** Patch many at once — the bulk action. */
50
+ updateMany: CollectionMutation<{ ids: string[]; patch: Partial<T> }, T[]>
51
+ remove: CollectionMutation<{ ids: string[] }, void>
52
+ }
53
+
54
+ export interface CollectionOptions<
55
+ T extends { id: string },
56
+ New = NewRecord<T>,
57
+ > {
58
+ /** The cache identity. Same key = same collection. */
59
+ key: QueryKey
60
+ repository: Repository<T, New>
61
+ /** The list's freshness. Defaults to the client's. */
62
+ staleTime?: number
63
+ /** Retries after the first failure. Defaults to the client's. */
64
+ retry?: number | false
65
+ /** Every mutation reports here first, so a page can word one notice. */
66
+ onError?: (error: Error) => void
67
+ }
68
+
69
+ function asError(value: unknown): Error | undefined {
70
+ if (value === null || value === undefined) return undefined
71
+ return value instanceof Error ? value : new Error(String(value))
72
+ }
73
+
74
+ export function createListQuery<T extends { id: string }>(
75
+ options: () => CollectionOptions<T, never>,
76
+ ): ListQuery<T> {
77
+ const query = createQuery(() => ({
78
+ queryKey: options().key,
79
+ queryFn: ({ signal }) => options().repository.list({ signal }),
80
+ staleTime: options().staleTime,
81
+ retry: options().retry,
82
+ throwOnError: false,
83
+ }))
84
+ return {
85
+ rows: () => query.data ?? [],
86
+ isPending: () => query.isPending,
87
+ isFetching: () => query.isFetching,
88
+ isError: () => query.isError,
89
+ error: () => asError(query.error),
90
+ refetch: () => query.refetch(),
91
+ }
92
+ }
93
+
94
+ export function createCollection<T extends { id: string }, New = NewRecord<T>>(
95
+ options: () => CollectionOptions<T, New>,
96
+ ): Collection<T, New> {
97
+ const queryClient = useQueryClient()
98
+ const key = () => options().key
99
+ const fail = (error: unknown) => {
100
+ const normalised = asError(error)
101
+ if (normalised) options().onError?.(normalised)
102
+ return normalised
103
+ }
104
+ const previous = () => queryClient.getQueryData<T[]>(key())
105
+ const restore = (snapshot: T[] | undefined) => {
106
+ if (snapshot) queryClient.setQueryData(key(), snapshot)
107
+ }
108
+ const patchRows = (ids: string[], patch: Partial<T>) => {
109
+ const wanted = new Set(ids)
110
+ queryClient.setQueryData<T[]>(key(), (rows = []) =>
111
+ rows.map((row) => (wanted.has(row.id) ? { ...row, ...patch } : row)),
112
+ )
113
+ }
114
+
115
+ const query = createListQuery(options)
116
+
117
+ const create = createMutation(() => ({
118
+ mutationFn: (input: New) => options().repository.create(input),
119
+ // Not optimistic: a new record's id and defaults are the repository's to
120
+ // decide, so guessing them would show a row that then changes identity.
121
+ onError: (error) => void fail(error),
122
+ onSettled: () => queryClient.invalidateQueries({ queryKey: key() }),
123
+ }))
124
+
125
+ const update = createMutation(() => ({
126
+ mutationFn: ({ id, patch }: { id: string; patch: Partial<T> }) =>
127
+ options().repository.update(id, patch),
128
+ onMutate: async ({ id, patch }) => {
129
+ await queryClient.cancelQueries({ queryKey: key() })
130
+ const snapshot = previous()
131
+ patchRows([id], patch)
132
+ return { snapshot }
133
+ },
134
+ onError: (error, _input, context) => {
135
+ restore(context?.snapshot)
136
+ void fail(error)
137
+ },
138
+ onSettled: () => queryClient.invalidateQueries({ queryKey: key() }),
139
+ }))
140
+
141
+ const updateMany = createMutation(() => ({
142
+ mutationFn: async ({ ids, patch }: { ids: string[]; patch: Partial<T> }) =>
143
+ Promise.all(ids.map((id) => options().repository.update(id, patch))),
144
+ onMutate: async ({ ids, patch }) => {
145
+ await queryClient.cancelQueries({ queryKey: key() })
146
+ const snapshot = previous()
147
+ patchRows(ids, patch)
148
+ return { snapshot }
149
+ },
150
+ onError: (error, _input, context) => {
151
+ restore(context?.snapshot)
152
+ void fail(error)
153
+ },
154
+ onSettled: () => queryClient.invalidateQueries({ queryKey: key() }),
155
+ }))
156
+
157
+ const remove = createMutation(() => ({
158
+ mutationFn: ({ ids }: { ids: string[] }) =>
159
+ options().repository.remove(ids),
160
+ onMutate: async ({ ids }) => {
161
+ await queryClient.cancelQueries({ queryKey: key() })
162
+ const snapshot = previous()
163
+ const wanted = new Set(ids)
164
+ queryClient.setQueryData<T[]>(key(), (rows = []) =>
165
+ rows.filter((row) => !wanted.has(row.id)),
166
+ )
167
+ return { snapshot }
168
+ },
169
+ onError: (error, _input, context) => {
170
+ restore(context?.snapshot)
171
+ void fail(error)
172
+ },
173
+ onSettled: () => queryClient.invalidateQueries({ queryKey: key() }),
174
+ }))
175
+
176
+ const wrap = <Input, Result>(
177
+ mutation: ReturnType<typeof createMutation<Result, Error, Input>>,
178
+ ): CollectionMutation<Input, Result> => ({
179
+ mutate: (input: Input) => mutation.mutate(input),
180
+ mutateAsync: (input: Input) => mutation.mutateAsync(input),
181
+ isPending: () => mutation.isPending,
182
+ error: () => asError(mutation.error),
183
+ })
184
+
185
+ return {
186
+ query,
187
+ create: wrap(create),
188
+ update: wrap(update),
189
+ updateMany: wrap(updateMany),
190
+ remove: wrap(remove),
191
+ }
192
+ }
@@ -0,0 +1,3 @@
1
+ export * from './client'
2
+ export * from './collection'
3
+ export * from './repository'
@@ -0,0 +1,30 @@
1
+ /**
2
+ * A repository is the seam between a page and wherever its records live.
3
+ *
4
+ * It is deliberately small: `list`, `create`, `update`, `remove`. Domain verbs
5
+ * (`complete`, `archive`, `comment`) are `update(id, patch)` with a different
6
+ * name at the call site — a contract that grows a method per business action is
7
+ * a database, not a seam.
8
+ *
9
+ * `list` is the only read. A page that needs one record filters the list it
10
+ * already has; a remote adapter that wants a single request can still expose
11
+ * `list` and let the query cache hold the collection, which is what every local
12
+ * example does.
13
+ */
14
+ export interface Repository<T extends { id: string }, New = NewRecord<T>> {
15
+ /** Every record, in the order the collection wants them shown. */
16
+ list(options?: { signal?: AbortSignal }): Promise<T[]>
17
+ /** Create one; resolves with the stored record (its real id included). */
18
+ create(input: New, options?: { signal?: AbortSignal }): Promise<T>
19
+ /** Patch one; resolves with the stored record. */
20
+ update(
21
+ id: string,
22
+ patch: Partial<T>,
23
+ options?: { signal?: AbortSignal },
24
+ ): Promise<T>
25
+ /** Delete many at once; resolves when they are gone. */
26
+ remove(ids: string[], options?: { signal?: AbortSignal }): Promise<void>
27
+ }
28
+
29
+ /** What a caller must supply to create a record: everything but the id it gets. */
30
+ export type NewRecord<T> = Omit<T, 'id'> & { id?: string }