optimistic-colada 0.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.
- package/LICENSE +21 -0
- package/README.md +249 -0
- package/dist/index.d.ts +44 -0
- package/dist/index.js +227 -0
- package/package.json +55 -0
- package/src/helpers.ts +9 -0
- package/src/import-meta.d.ts +3 -0
- package/src/index.ts +4 -0
- package/src/plugin.ts +161 -0
- package/src/types.ts +35 -0
- package/src/updaters.ts +113 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Prains
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
# optimistic-colada
|
|
2
|
+
|
|
3
|
+
Declarative optimistic updates for [Pinia Colada](https://pinia-colada.esm.dev/).
|
|
4
|
+
|
|
5
|
+
A Pinia Colada plugin that snapshots the query cache when a mutation starts, writes the expected result immediately, rolls the cache back if the mutation fails, and invalidates the affected queries once it settles. All of it is configured with a single `optimistic` option on `useMutation` instead of hand-written `onMutate` / `onError` / `onSettled` hooks in every mutation.
|
|
6
|
+
|
|
7
|
+
## Why
|
|
8
|
+
|
|
9
|
+
The manual pattern for optimistic updates looks like this, repeated in every mutation:
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
const queryCache = useQueryCache()
|
|
13
|
+
|
|
14
|
+
const { mutate: renameProject } = useMutation({
|
|
15
|
+
mutation: (input: { id: string; name: string }) => api.projects.rename(input),
|
|
16
|
+
onMutate(vars) {
|
|
17
|
+
const previous = queryCache.getQueryData<Project[]>(['projects'])
|
|
18
|
+
queryCache.setQueryData(
|
|
19
|
+
['projects'],
|
|
20
|
+
previous?.map((p) => (p.id === vars.id ? { ...p, ...vars } : p))
|
|
21
|
+
)
|
|
22
|
+
return { previous }
|
|
23
|
+
},
|
|
24
|
+
onError(_error, _vars, context) {
|
|
25
|
+
queryCache.setQueryData(['projects'], context.previous)
|
|
26
|
+
},
|
|
27
|
+
onSettled() {
|
|
28
|
+
queryCache.invalidateQueries({ key: ['projects'] })
|
|
29
|
+
}
|
|
30
|
+
})
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
This version handles exactly one query key. If the same record is cached under several keys — a list, a filtered list, a detail view — each one needs its own snapshot and rollback.
|
|
34
|
+
|
|
35
|
+
With the plugin:
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
const { mutate: renameProject } = useMutation({
|
|
39
|
+
mutation: (input: { id: string; name: string }) => api.projects.rename(input),
|
|
40
|
+
optimistic: {
|
|
41
|
+
action: OptimisticAction.update,
|
|
42
|
+
key: ['projects']
|
|
43
|
+
}
|
|
44
|
+
})
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The plugin applies the update to **every** cache entry under `['projects']` (prefix match), snapshots each one for rollback, and invalidates them after the mutation settles.
|
|
48
|
+
|
|
49
|
+
## Requirements
|
|
50
|
+
|
|
51
|
+
- `vue` >= 3.5
|
|
52
|
+
- `@pinia/colada` >= 1.3
|
|
53
|
+
|
|
54
|
+
## Installation
|
|
55
|
+
|
|
56
|
+
Not published to npm yet; install from GitHub:
|
|
57
|
+
|
|
58
|
+
```sh
|
|
59
|
+
npm install github:Prains/optimistic-colada
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
The package builds itself on install via the `prepare` script.
|
|
63
|
+
|
|
64
|
+
## Setup
|
|
65
|
+
|
|
66
|
+
Register the plugin in Pinia Colada options.
|
|
67
|
+
|
|
68
|
+
Vue:
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
import { createApp } from 'vue'
|
|
72
|
+
import { createPinia } from 'pinia'
|
|
73
|
+
import { PiniaColada } from '@pinia/colada'
|
|
74
|
+
import { createOptimisticPlugin } from 'optimistic-colada'
|
|
75
|
+
|
|
76
|
+
const app = createApp(App)
|
|
77
|
+
app.use(createPinia())
|
|
78
|
+
app.use(PiniaColada, {
|
|
79
|
+
plugins: [createOptimisticPlugin()]
|
|
80
|
+
})
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Nuxt (with `@pinia/colada-nuxt`):
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
// colada.options.ts
|
|
87
|
+
import type { PiniaColadaOptions } from '@pinia/colada'
|
|
88
|
+
import { createOptimisticPlugin } from 'optimistic-colada'
|
|
89
|
+
|
|
90
|
+
export default {
|
|
91
|
+
plugins: [createOptimisticPlugin()]
|
|
92
|
+
} satisfies PiniaColadaOptions
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Usage
|
|
96
|
+
|
|
97
|
+
### Update
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
import { useMutation } from '@pinia/colada'
|
|
101
|
+
import { OptimisticAction } from 'optimistic-colada'
|
|
102
|
+
|
|
103
|
+
const { mutate: renameProject } = useMutation({
|
|
104
|
+
mutation: (input: { id: string; name: string }) => api.projects.rename(input),
|
|
105
|
+
optimistic: {
|
|
106
|
+
action: OptimisticAction.update,
|
|
107
|
+
key: ['projects']
|
|
108
|
+
}
|
|
109
|
+
})
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
On `renameProject({ id, name })`, every cached query whose key starts with `['projects']` is scanned for records whose `id` matches the mutation variables. Matched records are shallow-merged with the variables. If the request fails, the previous data is restored. When the mutation settles, queries under `['projects']` are invalidated, so active queries refetch and the cache converges to server state.
|
|
113
|
+
|
|
114
|
+
### Delete
|
|
115
|
+
|
|
116
|
+
```ts
|
|
117
|
+
const { mutate: deleteProject } = useMutation({
|
|
118
|
+
mutation: (input: { id: string }) => api.projects.delete(input),
|
|
119
|
+
optimistic: {
|
|
120
|
+
action: OptimisticAction.delete,
|
|
121
|
+
key: ['projects']
|
|
122
|
+
}
|
|
123
|
+
})
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Matching records are removed from arrays and paginated results. In paginated shapes, `total` is decremented by the number of removed records.
|
|
127
|
+
|
|
128
|
+
### Create
|
|
129
|
+
|
|
130
|
+
There is no built-in updater for `create` — the shape of a new record cannot be inferred from mutation variables. Provide one:
|
|
131
|
+
|
|
132
|
+
```ts
|
|
133
|
+
import { OptimisticAction, createOptimisticId } from 'optimistic-colada'
|
|
134
|
+
|
|
135
|
+
const { mutate: createTask } = useMutation({
|
|
136
|
+
mutation: (input: { title: string }) => api.tasks.create(input),
|
|
137
|
+
optimistic: {
|
|
138
|
+
action: OptimisticAction.create,
|
|
139
|
+
key: ['tasks'],
|
|
140
|
+
updater: (cached, input) => [
|
|
141
|
+
...(cached as Task[]),
|
|
142
|
+
{ id: createOptimisticId(), title: input.title, done: false }
|
|
143
|
+
]
|
|
144
|
+
}
|
|
145
|
+
})
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
`createOptimisticId()` returns a prefixed UUID for the placeholder record, and `isOptimisticId(id)` lets components tell placeholders from persisted records — for example, to disable row actions until the record is real. The invalidation after settle replaces placeholders with server data.
|
|
149
|
+
|
|
150
|
+
### Custom updater
|
|
151
|
+
|
|
152
|
+
An `updater` overrides the built-in behavior for any action. It is called once per matching cache entry with the current cached data and the mutation variables, and must return the new data without mutating the input. Return `undefined` or the same reference to leave an entry untouched.
|
|
153
|
+
|
|
154
|
+
```ts
|
|
155
|
+
optimistic: {
|
|
156
|
+
action: OptimisticAction.update,
|
|
157
|
+
key: ['tasks'],
|
|
158
|
+
updater: (cached, input) => {
|
|
159
|
+
if (!Array.isArray(cached)) return undefined // skip this entry
|
|
160
|
+
return cached.map((t) => (t.id === input.id ? { ...t, done: input.done } : t))
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
### Skipping invalidation
|
|
166
|
+
|
|
167
|
+
```ts
|
|
168
|
+
optimistic: {
|
|
169
|
+
action: OptimisticAction.update,
|
|
170
|
+
key: ['settings'],
|
|
171
|
+
skipInvalidation: true
|
|
172
|
+
}
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
By default, queries under `key` are invalidated after a successful mutation. Set `skipInvalidation: true` when the optimistic write already is the final state and a refetch adds nothing. On error, rollback and invalidation happen regardless of this flag.
|
|
176
|
+
|
|
177
|
+
### Identity key
|
|
178
|
+
|
|
179
|
+
Built-in updaters match records by `id`. If your records use a different field:
|
|
180
|
+
|
|
181
|
+
```ts
|
|
182
|
+
createOptimisticPlugin({ identityKey: 'uuid' })
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
## Supported cache shapes
|
|
186
|
+
|
|
187
|
+
Built-in updaters detect the shape of each cache entry independently:
|
|
188
|
+
|
|
189
|
+
| Shape | Example | `update` | `delete` |
|
|
190
|
+
| --------- | ---------------------------- | ------------------------------------------- | ------------------------------------------ |
|
|
191
|
+
| Array | `[{ id, ... }, ...]` | shallow-merge variables into matched records | remove matched records |
|
|
192
|
+
| Paginated | `{ items: [...], total? }` | same, inside `items` | remove from `items`, decrement `total` |
|
|
193
|
+
| Single | `{ id, ... }` | shallow-merge when `id` matches | skipped |
|
|
194
|
+
|
|
195
|
+
Entries with any other shape are skipped, with a console warning in dev builds. Entries that have no data yet are skipped silently.
|
|
196
|
+
|
|
197
|
+
## How it works
|
|
198
|
+
|
|
199
|
+
1. On `mutate`, the plugin reads the `optimistic` config from the mutation options and collects every cache entry matching `key` (prefix match, same semantics as `queryCache.getEntries`).
|
|
200
|
+
2. For each entry it computes the optimistic data — via `updater` if provided, otherwise via the built-in updater for the action — takes a snapshot of the current data (deep clone, aware of arrays, plain objects, `Date`, `Map`, `Set`), and writes the optimistic data with `setQueryData`.
|
|
201
|
+
3. On success, queries under `key` are invalidated (unless `skipInvalidation`), so active queries refetch and optimistic data is replaced with server state.
|
|
202
|
+
4. On error, snapshots are restored and queries under `key` are invalidated to resynchronize with the server.
|
|
203
|
+
|
|
204
|
+
## API
|
|
205
|
+
|
|
206
|
+
### `createOptimisticPlugin(options?): PiniaColadaPlugin`
|
|
207
|
+
|
|
208
|
+
| Option | Type | Default | Description |
|
|
209
|
+
| ------------- | -------- | ------- | -------------------------------------------------- |
|
|
210
|
+
| `identityKey` | `string` | `'id'` | Field used by built-in updaters to match records |
|
|
211
|
+
|
|
212
|
+
### The `optimistic` mutation option
|
|
213
|
+
|
|
214
|
+
The package augments `UseMutationOptions`, so once it is imported anywhere in the project, `optimistic` is available and typed on every `useMutation` call.
|
|
215
|
+
|
|
216
|
+
| Field | Type | Required | Description |
|
|
217
|
+
| ------------------ | ------------------------------------------- | -------------- | ----------------------------------------------------------------- |
|
|
218
|
+
| `action` | `OptimisticAction.create \| update \| delete` | yes | Selects the built-in updater |
|
|
219
|
+
| `key` | `EntryKey` | yes | Query key prefix; all matching entries are updated |
|
|
220
|
+
| `updater` | `(cachedData, vars) => unknown` | for `create` | Custom per-entry transform, overrides the built-in updater |
|
|
221
|
+
| `skipInvalidation` | `boolean` | no | Skip query invalidation after a successful mutation |
|
|
222
|
+
|
|
223
|
+
### Standalone updaters
|
|
224
|
+
|
|
225
|
+
`applyUpdate(data, input, identityKey)`, `applyDelete(data, input, identityKey)` and `detectShape(data)` are exported for reuse inside custom updaters and in tests.
|
|
226
|
+
|
|
227
|
+
### Optimistic IDs
|
|
228
|
+
|
|
229
|
+
`createOptimisticId()`, `isOptimisticId(id)`, `OPTIMISTIC_PREFIX` — helpers for placeholder records in `create` flows.
|
|
230
|
+
|
|
231
|
+
## Behavior notes
|
|
232
|
+
|
|
233
|
+
- Built-in updaters require the mutation variables to be a plain object carrying the identity key, e.g. `{ id, ...patch }`. With non-object variables and no `updater`, the mutation runs without an optimistic write.
|
|
234
|
+
- `update` shallow-merges the full variables object into matched records. Variables that are not record fields (flags, nested filter params) will show up in the cached record until invalidation refetches it; use a custom `updater` in that case.
|
|
235
|
+
- Rollback restores per-entry snapshots taken when the mutation started. If several mutations touch the same entries concurrently, a rollback can briefly restore data from before a neighboring mutation — the invalidation that follows brings the entries back to server state.
|
|
236
|
+
- Mutations without an `optimistic` config are ignored by the plugin.
|
|
237
|
+
|
|
238
|
+
## Development
|
|
239
|
+
|
|
240
|
+
```sh
|
|
241
|
+
npm install
|
|
242
|
+
npm test # vitest
|
|
243
|
+
npm run typecheck
|
|
244
|
+
npm run build # tsup -> dist/
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
## License
|
|
248
|
+
|
|
249
|
+
[MIT](./LICENSE)
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { EntryKey, PiniaColadaPlugin } from '@pinia/colada';
|
|
2
|
+
|
|
3
|
+
declare enum OptimisticAction {
|
|
4
|
+
create = "create",
|
|
5
|
+
update = "update",
|
|
6
|
+
delete = "delete"
|
|
7
|
+
}
|
|
8
|
+
interface OptimisticConfig<TVars = unknown> {
|
|
9
|
+
action: OptimisticAction;
|
|
10
|
+
key: EntryKey;
|
|
11
|
+
updater?(cachedData: unknown, input: TVars): unknown;
|
|
12
|
+
skipInvalidation?: boolean;
|
|
13
|
+
}
|
|
14
|
+
interface OptimisticPluginOptions {
|
|
15
|
+
/** Field name used to identify records. Default: 'id' */
|
|
16
|
+
identityKey?: string;
|
|
17
|
+
}
|
|
18
|
+
declare module '@pinia/colada' {
|
|
19
|
+
interface UseMutationOptions<TData, TVars, TError, TContext> {
|
|
20
|
+
readonly __optimisticTypeParameters?: [TData, TError, TContext] extends [never, never, never] ? never : never;
|
|
21
|
+
optimistic?: OptimisticConfig<TVars>;
|
|
22
|
+
}
|
|
23
|
+
interface UseMutationOptionsGlobal {
|
|
24
|
+
optimistic?: OptimisticConfig;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
declare function createOptimisticPlugin(options?: OptimisticPluginOptions): PiniaColadaPlugin;
|
|
29
|
+
|
|
30
|
+
declare enum DataShape {
|
|
31
|
+
array = "array",
|
|
32
|
+
paginated = "paginated",
|
|
33
|
+
single = "single",
|
|
34
|
+
unknown = "unknown"
|
|
35
|
+
}
|
|
36
|
+
declare function detectShape(data: unknown): DataShape;
|
|
37
|
+
declare function applyUpdate(data: unknown, input: Record<string, unknown>, identityKey: string): unknown;
|
|
38
|
+
declare function applyDelete(data: unknown, input: Record<string, unknown>, identityKey: string): unknown;
|
|
39
|
+
|
|
40
|
+
declare const OPTIMISTIC_PREFIX = "__optimistic_";
|
|
41
|
+
declare function createOptimisticId(): string;
|
|
42
|
+
declare function isOptimisticId(id: string): boolean;
|
|
43
|
+
|
|
44
|
+
export { DataShape, OPTIMISTIC_PREFIX, OptimisticAction, type OptimisticConfig, type OptimisticPluginOptions, applyDelete, applyUpdate, createOptimisticId, createOptimisticPlugin, detectShape, isOptimisticId };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
// src/plugin.ts
|
|
2
|
+
import { useMutationCache } from "@pinia/colada";
|
|
3
|
+
import { toRaw } from "vue";
|
|
4
|
+
|
|
5
|
+
// src/types.ts
|
|
6
|
+
var OptimisticAction = /* @__PURE__ */ ((OptimisticAction2) => {
|
|
7
|
+
OptimisticAction2["create"] = "create";
|
|
8
|
+
OptimisticAction2["update"] = "update";
|
|
9
|
+
OptimisticAction2["delete"] = "delete";
|
|
10
|
+
return OptimisticAction2;
|
|
11
|
+
})(OptimisticAction || {});
|
|
12
|
+
|
|
13
|
+
// src/updaters.ts
|
|
14
|
+
var DataShape = /* @__PURE__ */ ((DataShape2) => {
|
|
15
|
+
DataShape2["array"] = "array";
|
|
16
|
+
DataShape2["paginated"] = "paginated";
|
|
17
|
+
DataShape2["single"] = "single";
|
|
18
|
+
DataShape2["unknown"] = "unknown";
|
|
19
|
+
return DataShape2;
|
|
20
|
+
})(DataShape || {});
|
|
21
|
+
function isOptimisticObject(data) {
|
|
22
|
+
return typeof data === "object" && data !== null && !Array.isArray(data);
|
|
23
|
+
}
|
|
24
|
+
function isOptimisticObjectArray(data) {
|
|
25
|
+
return Array.isArray(data) && data.every(isOptimisticObject);
|
|
26
|
+
}
|
|
27
|
+
function isPaginatedOptimisticData(data) {
|
|
28
|
+
return isOptimisticObject(data) && "items" in data && isOptimisticObjectArray(data.items);
|
|
29
|
+
}
|
|
30
|
+
function detectShape(data) {
|
|
31
|
+
if (isOptimisticObjectArray(data)) return "array" /* array */;
|
|
32
|
+
if (isPaginatedOptimisticData(data)) return "paginated" /* paginated */;
|
|
33
|
+
if (isOptimisticObject(data) && "id" in data) return "single" /* single */;
|
|
34
|
+
return "unknown" /* unknown */;
|
|
35
|
+
}
|
|
36
|
+
function applyUpdate(data, input, identityKey) {
|
|
37
|
+
const shape = detectShape(data);
|
|
38
|
+
const inputId = input[identityKey];
|
|
39
|
+
switch (shape) {
|
|
40
|
+
case "array" /* array */: {
|
|
41
|
+
if (!isOptimisticObjectArray(data)) return void 0;
|
|
42
|
+
const items = data;
|
|
43
|
+
let changed = false;
|
|
44
|
+
const mapped = items.map((item) => {
|
|
45
|
+
if (item[identityKey] !== inputId) return item;
|
|
46
|
+
changed = true;
|
|
47
|
+
return { ...item, ...input };
|
|
48
|
+
});
|
|
49
|
+
return changed ? mapped : data;
|
|
50
|
+
}
|
|
51
|
+
case "single" /* single */: {
|
|
52
|
+
if (!isOptimisticObject(data)) return void 0;
|
|
53
|
+
const object = data;
|
|
54
|
+
return object[identityKey] === inputId ? { ...object, ...input } : data;
|
|
55
|
+
}
|
|
56
|
+
case "paginated" /* paginated */: {
|
|
57
|
+
if (!isPaginatedOptimisticData(data)) return void 0;
|
|
58
|
+
const paged = data;
|
|
59
|
+
let changed = false;
|
|
60
|
+
const mapped = paged.items.map((item) => {
|
|
61
|
+
if (item[identityKey] !== inputId) return item;
|
|
62
|
+
changed = true;
|
|
63
|
+
return { ...item, ...input };
|
|
64
|
+
});
|
|
65
|
+
return changed ? { ...paged, items: mapped } : data;
|
|
66
|
+
}
|
|
67
|
+
case "unknown" /* unknown */: {
|
|
68
|
+
return void 0;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function applyDelete(data, input, identityKey) {
|
|
73
|
+
const shape = detectShape(data);
|
|
74
|
+
const inputId = input[identityKey];
|
|
75
|
+
switch (shape) {
|
|
76
|
+
case "array" /* array */: {
|
|
77
|
+
if (!isOptimisticObjectArray(data)) return void 0;
|
|
78
|
+
const items = data;
|
|
79
|
+
const filtered = items.filter((item) => item[identityKey] !== inputId);
|
|
80
|
+
return filtered.length === items.length ? data : filtered;
|
|
81
|
+
}
|
|
82
|
+
case "single" /* single */: {
|
|
83
|
+
return void 0;
|
|
84
|
+
}
|
|
85
|
+
case "paginated" /* paginated */: {
|
|
86
|
+
if (!isPaginatedOptimisticData(data)) return void 0;
|
|
87
|
+
const paged = data;
|
|
88
|
+
const filtered = paged.items.filter((item) => item[identityKey] !== inputId);
|
|
89
|
+
return {
|
|
90
|
+
...paged,
|
|
91
|
+
items: filtered,
|
|
92
|
+
...typeof paged.total === "number" ? { total: paged.total - (paged.items.length - filtered.length) } : {}
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
case "unknown" /* unknown */: {
|
|
96
|
+
return void 0;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// src/plugin.ts
|
|
102
|
+
var SKIP = /* @__PURE__ */ Symbol("skip");
|
|
103
|
+
function isPlainRecord(value) {
|
|
104
|
+
if (!value || typeof value !== "object") return false;
|
|
105
|
+
const prototype = Object.getPrototypeOf(value);
|
|
106
|
+
return prototype === Object.prototype || prototype === null;
|
|
107
|
+
}
|
|
108
|
+
function isOptimisticMutationEntry(value) {
|
|
109
|
+
return isPlainRecord(value) && isPlainRecord(value.options);
|
|
110
|
+
}
|
|
111
|
+
function cloneSnapshot(value) {
|
|
112
|
+
const raw = toRaw(value);
|
|
113
|
+
if (Array.isArray(raw)) {
|
|
114
|
+
return raw.map((item) => cloneSnapshot(item));
|
|
115
|
+
}
|
|
116
|
+
if (raw instanceof Date) {
|
|
117
|
+
return new Date(raw);
|
|
118
|
+
}
|
|
119
|
+
if (raw instanceof Map) {
|
|
120
|
+
return new Map(
|
|
121
|
+
[...raw.entries()].map(([key, mapValue]) => [cloneSnapshot(key), cloneSnapshot(mapValue)])
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
if (raw instanceof Set) {
|
|
125
|
+
return new Set([...raw.values()].map((item) => cloneSnapshot(item)));
|
|
126
|
+
}
|
|
127
|
+
if (isPlainRecord(raw)) {
|
|
128
|
+
return Object.fromEntries(
|
|
129
|
+
Object.entries(raw).map(([key, objectValue]) => [key, cloneSnapshot(objectValue)])
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
return raw;
|
|
133
|
+
}
|
|
134
|
+
function computeOptimisticData(config, currentData, variables, identityKey) {
|
|
135
|
+
if (config.updater) {
|
|
136
|
+
return config.updater(currentData, variables);
|
|
137
|
+
}
|
|
138
|
+
switch (config.action) {
|
|
139
|
+
case "create" /* create */: {
|
|
140
|
+
console.warn(
|
|
141
|
+
'[optimistic-colada] action "create" requires a custom updater. Skipping optimistic update.'
|
|
142
|
+
);
|
|
143
|
+
return SKIP;
|
|
144
|
+
}
|
|
145
|
+
case "update" /* update */: {
|
|
146
|
+
return applyUpdate(currentData, variables ?? {}, identityKey);
|
|
147
|
+
}
|
|
148
|
+
case "delete" /* delete */: {
|
|
149
|
+
return applyDelete(currentData, variables ?? {}, identityKey);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
function warnUnknownShape(currentData, queryKey) {
|
|
154
|
+
if (!import.meta.env?.DEV) return;
|
|
155
|
+
const shape = detectShape(currentData);
|
|
156
|
+
if (shape === "unknown" /* unknown */) {
|
|
157
|
+
console.warn(
|
|
158
|
+
`[optimistic-colada] unknown data shape for query key ${JSON.stringify(queryKey)}. Skipping.`
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
function applyOptimisticEntries(queryCache, config, variables, identityKey) {
|
|
163
|
+
const entries = queryCache.getEntries({ key: config.key });
|
|
164
|
+
const snapshots = /* @__PURE__ */ new Map();
|
|
165
|
+
for (const queryEntry of entries) {
|
|
166
|
+
const currentData = queryEntry.state.value.data;
|
|
167
|
+
if (currentData === void 0) continue;
|
|
168
|
+
const updatedData = computeOptimisticData(config, currentData, variables, identityKey);
|
|
169
|
+
if (updatedData === SKIP) continue;
|
|
170
|
+
if (updatedData === void 0 && !config.updater) {
|
|
171
|
+
warnUnknownShape(currentData, queryEntry.key);
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
if (updatedData !== void 0 && updatedData !== currentData) {
|
|
175
|
+
snapshots.set(queryEntry.key, cloneSnapshot(currentData));
|
|
176
|
+
queryCache.setQueryData(queryEntry.key, updatedData);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return snapshots;
|
|
180
|
+
}
|
|
181
|
+
function createOptimisticPlugin(options) {
|
|
182
|
+
const identityKey = options?.identityKey ?? "id";
|
|
183
|
+
return ({ queryCache, pinia }) => {
|
|
184
|
+
const mutationCache = useMutationCache(pinia);
|
|
185
|
+
mutationCache.$onAction(({ name, args, after, onError }) => {
|
|
186
|
+
if (name !== "mutate") return;
|
|
187
|
+
const entry = args[0];
|
|
188
|
+
if (!isOptimisticMutationEntry(entry)) return;
|
|
189
|
+
const config = entry.options.optimistic;
|
|
190
|
+
if (!config) return;
|
|
191
|
+
const variables = isPlainRecord(entry.vars) ? entry.vars : void 0;
|
|
192
|
+
if (!variables && !config.updater) return;
|
|
193
|
+
const snapshots = applyOptimisticEntries(queryCache, config, variables, identityKey);
|
|
194
|
+
after(() => {
|
|
195
|
+
if (!config.skipInvalidation) {
|
|
196
|
+
queryCache.invalidateQueries({ key: config.key });
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
onError(() => {
|
|
200
|
+
for (const [key, data] of snapshots) {
|
|
201
|
+
queryCache.setQueryData(key, data);
|
|
202
|
+
}
|
|
203
|
+
queryCache.invalidateQueries({ key: config.key });
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// src/helpers.ts
|
|
210
|
+
var OPTIMISTIC_PREFIX = "__optimistic_";
|
|
211
|
+
function createOptimisticId() {
|
|
212
|
+
return `${OPTIMISTIC_PREFIX}${crypto.randomUUID()}`;
|
|
213
|
+
}
|
|
214
|
+
function isOptimisticId(id) {
|
|
215
|
+
return typeof id === "string" && id.startsWith(OPTIMISTIC_PREFIX);
|
|
216
|
+
}
|
|
217
|
+
export {
|
|
218
|
+
DataShape,
|
|
219
|
+
OPTIMISTIC_PREFIX,
|
|
220
|
+
OptimisticAction,
|
|
221
|
+
applyDelete,
|
|
222
|
+
applyUpdate,
|
|
223
|
+
createOptimisticId,
|
|
224
|
+
createOptimisticPlugin,
|
|
225
|
+
detectShape,
|
|
226
|
+
isOptimisticId
|
|
227
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "optimistic-colada",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Declarative optimistic updates plugin for Pinia Colada",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Prains",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/Prains/optimistic-colada.git"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/Prains/optimistic-colada#readme",
|
|
13
|
+
"bugs": "https://github.com/Prains/optimistic-colada/issues",
|
|
14
|
+
"keywords": [
|
|
15
|
+
"vue",
|
|
16
|
+
"pinia",
|
|
17
|
+
"pinia-colada",
|
|
18
|
+
"optimistic-updates",
|
|
19
|
+
"optimistic-ui",
|
|
20
|
+
"mutations",
|
|
21
|
+
"cache"
|
|
22
|
+
],
|
|
23
|
+
"exports": {
|
|
24
|
+
".": {
|
|
25
|
+
"types": "./dist/index.d.ts",
|
|
26
|
+
"default": "./dist/index.js"
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"main": "./dist/index.js",
|
|
30
|
+
"types": "./dist/index.d.ts",
|
|
31
|
+
"files": [
|
|
32
|
+
"dist",
|
|
33
|
+
"src"
|
|
34
|
+
],
|
|
35
|
+
"sideEffects": false,
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build": "tsup src/index.ts --format esm --dts --clean",
|
|
38
|
+
"test": "vitest run",
|
|
39
|
+
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
40
|
+
"prepack": "npm run build",
|
|
41
|
+
"prepare": "npm run build"
|
|
42
|
+
},
|
|
43
|
+
"peerDependencies": {
|
|
44
|
+
"@pinia/colada": "^1.3.0",
|
|
45
|
+
"vue": "^3.5.0"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@pinia/colada": "^1.3.1",
|
|
49
|
+
"pinia": "^3.0.0",
|
|
50
|
+
"tsup": "^8.5.0",
|
|
51
|
+
"typescript": "^5.9.3",
|
|
52
|
+
"vitest": "^4.1.0",
|
|
53
|
+
"vue": "^3.5.0"
|
|
54
|
+
}
|
|
55
|
+
}
|
package/src/helpers.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export const OPTIMISTIC_PREFIX = '__optimistic_'
|
|
2
|
+
|
|
3
|
+
export function createOptimisticId(): string {
|
|
4
|
+
return `${OPTIMISTIC_PREFIX}${crypto.randomUUID()}`
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function isOptimisticId(id: string): boolean {
|
|
8
|
+
return typeof id === 'string' && id.startsWith(OPTIMISTIC_PREFIX)
|
|
9
|
+
}
|
package/src/index.ts
ADDED
package/src/plugin.ts
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { useMutationCache } from '@pinia/colada'
|
|
2
|
+
import type { PiniaColadaPlugin, EntryKey } from '@pinia/colada'
|
|
3
|
+
import { toRaw } from 'vue'
|
|
4
|
+
import { OptimisticAction } from './types'
|
|
5
|
+
import type { OptimisticConfig, OptimisticPluginOptions } from './types'
|
|
6
|
+
import { applyUpdate, applyDelete, detectShape, DataShape } from './updaters'
|
|
7
|
+
|
|
8
|
+
const SKIP = Symbol('skip')
|
|
9
|
+
|
|
10
|
+
type OptimisticMutationEntry = {
|
|
11
|
+
options: {
|
|
12
|
+
optimistic?: OptimisticConfig
|
|
13
|
+
}
|
|
14
|
+
vars?: unknown
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
|
18
|
+
if (!value || typeof value !== 'object') return false
|
|
19
|
+
const prototype = Object.getPrototypeOf(value)
|
|
20
|
+
return prototype === Object.prototype || prototype === null
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function isOptimisticMutationEntry(value: unknown): value is OptimisticMutationEntry {
|
|
24
|
+
return isPlainRecord(value) && isPlainRecord(value.options)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function cloneSnapshot(value: unknown): unknown {
|
|
28
|
+
const raw = toRaw(value)
|
|
29
|
+
|
|
30
|
+
if (Array.isArray(raw)) {
|
|
31
|
+
return raw.map((item) => cloneSnapshot(item))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (raw instanceof Date) {
|
|
35
|
+
return new Date(raw)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (raw instanceof Map) {
|
|
39
|
+
return new Map(
|
|
40
|
+
[...raw.entries()].map(([key, mapValue]) => [cloneSnapshot(key), cloneSnapshot(mapValue)])
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (raw instanceof Set) {
|
|
45
|
+
return new Set([...raw.values()].map((item) => cloneSnapshot(item)))
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (isPlainRecord(raw)) {
|
|
49
|
+
return Object.fromEntries(
|
|
50
|
+
Object.entries(raw).map(([key, objectValue]) => [key, cloneSnapshot(objectValue)])
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return raw
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Compute the optimistic data for a single query entry.
|
|
59
|
+
* Returns SKIP sentinel when entry should be skipped entirely.
|
|
60
|
+
*/
|
|
61
|
+
function computeOptimisticData(
|
|
62
|
+
config: OptimisticConfig,
|
|
63
|
+
currentData: unknown,
|
|
64
|
+
variables: Record<string, unknown> | undefined,
|
|
65
|
+
identityKey: string
|
|
66
|
+
): unknown | typeof SKIP {
|
|
67
|
+
if (config.updater) {
|
|
68
|
+
return config.updater(currentData, variables)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
switch (config.action) {
|
|
72
|
+
case OptimisticAction.create: {
|
|
73
|
+
console.warn(
|
|
74
|
+
'[optimistic-colada] action "create" requires a custom updater. Skipping optimistic update.'
|
|
75
|
+
)
|
|
76
|
+
return SKIP
|
|
77
|
+
}
|
|
78
|
+
case OptimisticAction.update: {
|
|
79
|
+
return applyUpdate(currentData, variables ?? {}, identityKey)
|
|
80
|
+
}
|
|
81
|
+
case OptimisticAction.delete: {
|
|
82
|
+
return applyDelete(currentData, variables ?? {}, identityKey)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function warnUnknownShape(currentData: unknown, queryKey: unknown) {
|
|
88
|
+
if (!import.meta.env?.DEV) return
|
|
89
|
+
const shape = detectShape(currentData)
|
|
90
|
+
if (shape === DataShape.unknown) {
|
|
91
|
+
console.warn(
|
|
92
|
+
`[optimistic-colada] unknown data shape for query key ${JSON.stringify(queryKey)}. Skipping.`
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function applyOptimisticEntries(
|
|
98
|
+
queryCache: Parameters<PiniaColadaPlugin>[0]['queryCache'],
|
|
99
|
+
config: OptimisticConfig,
|
|
100
|
+
variables: Record<string, unknown> | undefined,
|
|
101
|
+
identityKey: string
|
|
102
|
+
): Map<EntryKey, unknown> {
|
|
103
|
+
const entries = queryCache.getEntries({ key: config.key })
|
|
104
|
+
const snapshots = new Map<EntryKey, unknown>()
|
|
105
|
+
|
|
106
|
+
for (const queryEntry of entries) {
|
|
107
|
+
const currentData = queryEntry.state.value.data
|
|
108
|
+
if (currentData === undefined) continue
|
|
109
|
+
|
|
110
|
+
const updatedData = computeOptimisticData(config, currentData, variables, identityKey)
|
|
111
|
+
|
|
112
|
+
if (updatedData === SKIP) continue
|
|
113
|
+
|
|
114
|
+
if (updatedData === undefined && !config.updater) {
|
|
115
|
+
warnUnknownShape(currentData, queryEntry.key)
|
|
116
|
+
continue
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (updatedData !== undefined && updatedData !== currentData) {
|
|
120
|
+
snapshots.set(queryEntry.key, cloneSnapshot(currentData))
|
|
121
|
+
queryCache.setQueryData(queryEntry.key, updatedData)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return snapshots
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function createOptimisticPlugin(options?: OptimisticPluginOptions): PiniaColadaPlugin {
|
|
129
|
+
const identityKey = options?.identityKey ?? 'id'
|
|
130
|
+
|
|
131
|
+
return ({ queryCache, pinia }) => {
|
|
132
|
+
const mutationCache = useMutationCache(pinia)
|
|
133
|
+
|
|
134
|
+
mutationCache.$onAction(({ name, args, after, onError }) => {
|
|
135
|
+
if (name !== 'mutate') return
|
|
136
|
+
|
|
137
|
+
const entry = args[0]
|
|
138
|
+
if (!isOptimisticMutationEntry(entry)) return
|
|
139
|
+
const config = entry.options.optimistic
|
|
140
|
+
if (!config) return
|
|
141
|
+
|
|
142
|
+
const variables = isPlainRecord(entry.vars) ? entry.vars : undefined
|
|
143
|
+
if (!variables && !config.updater) return
|
|
144
|
+
|
|
145
|
+
const snapshots = applyOptimisticEntries(queryCache, config, variables, identityKey)
|
|
146
|
+
|
|
147
|
+
after(() => {
|
|
148
|
+
if (!config.skipInvalidation) {
|
|
149
|
+
queryCache.invalidateQueries({ key: config.key })
|
|
150
|
+
}
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
onError(() => {
|
|
154
|
+
for (const [key, data] of snapshots) {
|
|
155
|
+
queryCache.setQueryData(key, data)
|
|
156
|
+
}
|
|
157
|
+
queryCache.invalidateQueries({ key: config.key })
|
|
158
|
+
})
|
|
159
|
+
})
|
|
160
|
+
}
|
|
161
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { EntryKey } from '@pinia/colada'
|
|
2
|
+
|
|
3
|
+
export enum OptimisticAction {
|
|
4
|
+
create = 'create',
|
|
5
|
+
update = 'update',
|
|
6
|
+
delete = 'delete'
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface OptimisticConfig<TVars = unknown> {
|
|
10
|
+
action: OptimisticAction
|
|
11
|
+
key: EntryKey
|
|
12
|
+
// Method shorthand: bivariant input check. Mutation TVars and the
|
|
13
|
+
// updater's narrow input often differ structurally (e.g. Prisma's
|
|
14
|
+
// ProjectWhereUniqueInput vs. a hand-written `{ where: { id: number } }`).
|
|
15
|
+
// Updaters runtime-narrow via guards, so bivariance is safe in practice.
|
|
16
|
+
updater?(cachedData: unknown, input: TVars): unknown
|
|
17
|
+
skipInvalidation?: boolean
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface OptimisticPluginOptions {
|
|
21
|
+
/** Field name used to identify records. Default: 'id' */
|
|
22
|
+
identityKey?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
declare module '@pinia/colada' {
|
|
26
|
+
interface UseMutationOptions<TData, TVars, TError, TContext> {
|
|
27
|
+
readonly __optimisticTypeParameters?: [TData, TError, TContext] extends [never, never, never] ?
|
|
28
|
+
never
|
|
29
|
+
: never
|
|
30
|
+
optimistic?: OptimisticConfig<TVars>
|
|
31
|
+
}
|
|
32
|
+
interface UseMutationOptionsGlobal {
|
|
33
|
+
optimistic?: OptimisticConfig
|
|
34
|
+
}
|
|
35
|
+
}
|
package/src/updaters.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
export enum DataShape {
|
|
2
|
+
array = 'array',
|
|
3
|
+
paginated = 'paginated',
|
|
4
|
+
single = 'single',
|
|
5
|
+
unknown = 'unknown'
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
type OptimisticObject = Record<string, unknown>
|
|
9
|
+
type PaginatedOptimisticData = OptimisticObject & {
|
|
10
|
+
items: OptimisticObject[]
|
|
11
|
+
total?: number
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function isOptimisticObject(data: unknown): data is OptimisticObject {
|
|
15
|
+
return typeof data === 'object' && data !== null && !Array.isArray(data)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function isOptimisticObjectArray(data: unknown): data is OptimisticObject[] {
|
|
19
|
+
return Array.isArray(data) && data.every(isOptimisticObject)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function isPaginatedOptimisticData(data: unknown): data is PaginatedOptimisticData {
|
|
23
|
+
return isOptimisticObject(data) && 'items' in data && isOptimisticObjectArray(data.items)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// DataShape.unknown is a deliberate safe fallback: applyUpdate/applyDelete
|
|
27
|
+
// need object records to map/filter over, so a non-object payload (scalar,
|
|
28
|
+
// mixed array) cannot be processed partially. The plugin logs a dev-only
|
|
29
|
+
// warning and skips the optimistic write in that case (see plugin.ts).
|
|
30
|
+
export function detectShape(data: unknown): DataShape {
|
|
31
|
+
if (isOptimisticObjectArray(data)) return DataShape.array
|
|
32
|
+
if (isPaginatedOptimisticData(data)) return DataShape.paginated
|
|
33
|
+
if (isOptimisticObject(data) && 'id' in data) return DataShape.single
|
|
34
|
+
return DataShape.unknown
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function applyUpdate(
|
|
38
|
+
data: unknown,
|
|
39
|
+
input: Record<string, unknown>,
|
|
40
|
+
identityKey: string
|
|
41
|
+
): unknown {
|
|
42
|
+
const shape = detectShape(data)
|
|
43
|
+
const inputId = input[identityKey]
|
|
44
|
+
|
|
45
|
+
switch (shape) {
|
|
46
|
+
case DataShape.array: {
|
|
47
|
+
if (!isOptimisticObjectArray(data)) return undefined
|
|
48
|
+
const items = data
|
|
49
|
+
let changed = false
|
|
50
|
+
const mapped = items.map((item) => {
|
|
51
|
+
if (item[identityKey] !== inputId) return item
|
|
52
|
+
changed = true
|
|
53
|
+
return { ...item, ...input }
|
|
54
|
+
})
|
|
55
|
+
return changed ? mapped : data
|
|
56
|
+
}
|
|
57
|
+
case DataShape.single: {
|
|
58
|
+
if (!isOptimisticObject(data)) return undefined
|
|
59
|
+
const object = data
|
|
60
|
+
return object[identityKey] === inputId ? { ...object, ...input } : data
|
|
61
|
+
}
|
|
62
|
+
case DataShape.paginated: {
|
|
63
|
+
if (!isPaginatedOptimisticData(data)) return undefined
|
|
64
|
+
const paged = data
|
|
65
|
+
let changed = false
|
|
66
|
+
const mapped = paged.items.map((item) => {
|
|
67
|
+
if (item[identityKey] !== inputId) return item
|
|
68
|
+
changed = true
|
|
69
|
+
return { ...item, ...input }
|
|
70
|
+
})
|
|
71
|
+
return changed ? { ...paged, items: mapped } : data
|
|
72
|
+
}
|
|
73
|
+
case DataShape.unknown: {
|
|
74
|
+
return undefined
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function applyDelete(
|
|
80
|
+
data: unknown,
|
|
81
|
+
input: Record<string, unknown>,
|
|
82
|
+
identityKey: string
|
|
83
|
+
): unknown {
|
|
84
|
+
const shape = detectShape(data)
|
|
85
|
+
const inputId = input[identityKey]
|
|
86
|
+
|
|
87
|
+
switch (shape) {
|
|
88
|
+
case DataShape.array: {
|
|
89
|
+
if (!isOptimisticObjectArray(data)) return undefined
|
|
90
|
+
const items = data
|
|
91
|
+
const filtered = items.filter((item) => item[identityKey] !== inputId)
|
|
92
|
+
return filtered.length === items.length ? data : filtered
|
|
93
|
+
}
|
|
94
|
+
case DataShape.single: {
|
|
95
|
+
return undefined
|
|
96
|
+
}
|
|
97
|
+
case DataShape.paginated: {
|
|
98
|
+
if (!isPaginatedOptimisticData(data)) return undefined
|
|
99
|
+
const paged = data
|
|
100
|
+
const filtered = paged.items.filter((item) => item[identityKey] !== inputId)
|
|
101
|
+
return {
|
|
102
|
+
...paged,
|
|
103
|
+
items: filtered,
|
|
104
|
+
...(typeof paged.total === 'number' ?
|
|
105
|
+
{ total: paged.total - (paged.items.length - filtered.length) }
|
|
106
|
+
: {})
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
case DataShape.unknown: {
|
|
110
|
+
return undefined
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|