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 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)
@@ -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
+ }
@@ -0,0 +1,3 @@
1
+ interface ImportMeta {
2
+ readonly env?: { readonly DEV?: boolean }
3
+ }
package/src/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ export { createOptimisticPlugin } from './plugin'
2
+ export * from './types'
3
+ export * from './updaters'
4
+ export * from './helpers'
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
+ }
@@ -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
+ }