vue-write-behind 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 +199 -0
- package/dist/vueWriteBehind.d.ts +172 -0
- package/dist/vueWriteBehind.min.cjs +2 -0
- package/dist/vueWriteBehind.min.cjs.map +1 -0
- package/dist/vueWriteBehind.min.d.cts +172 -0
- package/dist/vueWriteBehind.min.js +2 -0
- package/dist/vueWriteBehind.min.js.map +1 -0
- package/package.json +72 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ozgur Seyidoglu
|
|
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,199 @@
|
|
|
1
|
+
# vue-write-behind
|
|
2
|
+
|
|
3
|
+
**The write never comes back.** Local state stays authoritative and the network is a background
|
|
4
|
+
chore. Edit a cell ten times and one request goes out carrying the tenth value. The server's reply
|
|
5
|
+
is *discarded on purpose* — it can never overwrite the cell the user is still typing in. A failed
|
|
6
|
+
save rolls nothing back; the key stays dirty and goes out again next tick, carrying whatever has
|
|
7
|
+
been typed since.
|
|
8
|
+
|
|
9
|
+
It is a **state outbox, not an operation log**: keys are independent and last-write-wins.
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install vue-write-behind
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
```vue
|
|
16
|
+
<script setup lang="ts">
|
|
17
|
+
import { reactive } from 'vue'
|
|
18
|
+
import { useWriteBehind } from 'vue-write-behind'
|
|
19
|
+
|
|
20
|
+
const cells = reactive<Record<string, string>>({ A1: 'foo' })
|
|
21
|
+
const outbox = useWriteBehind(cells, (value, key) => api.put(`/cell/${key}`, value))
|
|
22
|
+
|
|
23
|
+
cells.A1 = 'bar' // that is the whole API
|
|
24
|
+
</script>
|
|
25
|
+
|
|
26
|
+
<template>
|
|
27
|
+
<input v-model="cells.A1" />
|
|
28
|
+
<span v-if="outbox.isSyncing">saving…</span>
|
|
29
|
+
<span v-else-if="outbox.pending.length">{{ outbox.pending.length }} unsaved</span>
|
|
30
|
+
</template>
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
No wrapper component, no query client, no schema. The record is yours; the composable watches it.
|
|
34
|
+
|
|
35
|
+
## What the defaults do
|
|
36
|
+
|
|
37
|
+
The bare form above is the configuration this library recommends — options exist to opt *out*.
|
|
38
|
+
|
|
39
|
+
| | Default | Why |
|
|
40
|
+
|---|---|---|
|
|
41
|
+
| Cadence | flush every **1000 ms**, and the timer only runs while something is queued | a burst of keystrokes is one request; an idle app holds no timer |
|
|
42
|
+
| Coalescing | last-write-wins per key | the pending map is bounded by the number of keys, not edits |
|
|
43
|
+
| Parallelism | `Promise.allSettled` over the due keys | keys are independent, so one slow key never holds up another |
|
|
44
|
+
| The response | **ignored** — there is no opt-in | applying it is the "cell jumps while you type" bug |
|
|
45
|
+
| Failure | retry forever, per-key backoff 1 → 2 → 4 → 8 → 16 → 30 s (capped), always re-sending the **current** value | silently dropping a user's edit is the one unacceptable outcome |
|
|
46
|
+
| Batch size | unlimited | |
|
|
47
|
+
| Leaving the page | flush on `visibilitychange → hidden` | best-effort — see below |
|
|
48
|
+
| Lifetime | stops on scope dispose, never starts on the server | |
|
|
49
|
+
|
|
50
|
+
## The store it returns
|
|
51
|
+
|
|
52
|
+
Reactive and already assembled — read it straight in a template.
|
|
53
|
+
|
|
54
|
+
| | |
|
|
55
|
+
|---|---|
|
|
56
|
+
| `pending` | every key with an unsaved change, in-flight ones included. The "you have unsaved work" number |
|
|
57
|
+
| `inFlight` | the subset currently on the wire |
|
|
58
|
+
| `failed` | `{ key, error, attempts, retryAt }` per failing key — latest error only |
|
|
59
|
+
| `isSyncing` | `true` while anything is in flight |
|
|
60
|
+
| `set(key, value)` | write local state **and** queue it, unconditionally |
|
|
61
|
+
| `flush()` | send everything pending now, ignoring the debounce and backoff clocks |
|
|
62
|
+
| `retry(key?)` | clear the backoff and the recorded failure for one key, or all |
|
|
63
|
+
| `discard(key)` | drop a pending write. **The only operation here that loses one** |
|
|
64
|
+
|
|
65
|
+
## Options
|
|
66
|
+
|
|
67
|
+
Every one is an opt-out. Pass them instead of the bare writer:
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
const outbox = useWriteBehind(cells, {
|
|
71
|
+
write: (value, key) => api.put(`/cell/${key}`, value),
|
|
72
|
+
interval: 1000,
|
|
73
|
+
debounce: 0,
|
|
74
|
+
retry: { initialDelay: 1000, maxDelay: 30000, factor: 2 }, // or `false`
|
|
75
|
+
flushOnHidden: true,
|
|
76
|
+
keys: ['A1', 'A2'], // or (key) => key.startsWith('draft:')
|
|
77
|
+
equals: (a, b) => a === b,
|
|
78
|
+
})
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
- **`interval`** — flush cadence in ms. It is a fixed window, not a debounce: it never restarts
|
|
82
|
+
under a fast typist.
|
|
83
|
+
- **`debounce`** — per-key quiet period before a key becomes eligible. Off by default because the
|
|
84
|
+
interval already coalesces a burst. An edit never *shortens* an active backoff.
|
|
85
|
+
- **`retry: false`** — stop retrying after a failure. The key is **not** dropped: it stays in
|
|
86
|
+
`pending`, stays listed in `failed` with `retryAt: undefined`, and goes out again on the next edit
|
|
87
|
+
or on `retry(key)`.
|
|
88
|
+
- **`keys`** — narrows what the source watcher picks up. `set()` is explicit and ignores it.
|
|
89
|
+
- **`equals`** — change detection, `Object.is` by default. See the precondition below.
|
|
90
|
+
|
|
91
|
+
### A batch endpoint
|
|
92
|
+
|
|
93
|
+
The other shape worth first-class support. One call per tick, every due key in it:
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
useWriteBehind(cells, {
|
|
97
|
+
flush: (entries) => api.patch('/cells', Object.fromEntries(entries)),
|
|
98
|
+
})
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Throw or reject and the **whole batch** stays pending. Resolve with `{ failed: ['A1'] }` to fail
|
|
102
|
+
part of it — everything else in the batch is treated as written. The version guard is applied per
|
|
103
|
+
key inside the batch, exactly as it is per request.
|
|
104
|
+
|
|
105
|
+
## The rules that matter
|
|
106
|
+
|
|
107
|
+
**A key edited while its own write is in flight.** This is the case a boolean dirty flag gets
|
|
108
|
+
wrong: the response clears a flag a newer edit set, and that edit is gone — silently, with a
|
|
109
|
+
correct-looking value still on screen. Here each key carries a monotonic version, a write records
|
|
110
|
+
the version it was sent at, and a success clears the key **only if** the version has not moved.
|
|
111
|
+
Otherwise the key stays dirty and the newer value goes out next tick.
|
|
112
|
+
|
|
113
|
+
**A retry sends the current value, not the one that failed.** The value is read out of the map at
|
|
114
|
+
send time, never captured when the edit happened. So a save that failed at 09:00 and retries at
|
|
115
|
+
09:01 carries what is on screen at 09:01.
|
|
116
|
+
|
|
117
|
+
**One request per key at a time.** A key already in flight is skipped even when it is dirty, so two
|
|
118
|
+
requests for one cell can never race and land out of order.
|
|
119
|
+
|
|
120
|
+
**Failure never rolls back.** Not local state, not the queue. `discard(key)` is the only way to
|
|
121
|
+
lose a write, and you have to call it.
|
|
122
|
+
|
|
123
|
+
## Preconditions — read these, they are not assumptions
|
|
124
|
+
|
|
125
|
+
- **Keys must be independent.** Writes go out in parallel, in no particular order, last-write-wins
|
|
126
|
+
per key. If key `b` is only valid once key `a` has landed, this is the wrong tool.
|
|
127
|
+
- **`equals` defaults to `Object.is`, so an object value mutated *in place* is not an edit.**
|
|
128
|
+
Replace the object (`cells.A1 = { ...cells.A1, text }`), pass your own `equals`, or call
|
|
129
|
+
`outbox.set('A1', value)` — which always queues.
|
|
130
|
+
- **The flush on tab-hidden is best-effort.** `visibilitychange → hidden` is used rather than
|
|
131
|
+
`beforeunload`, which mobile browsers routinely skip — but the page can still be frozen before
|
|
132
|
+
the request leaves. `pending` is exposed so your app can *warn* instead of failing silently.
|
|
133
|
+
- **A key deleted from the source keeps its queued write.** Losing it silently is exactly what this
|
|
134
|
+
library refuses to do. Call `discard(key)` if you mean it.
|
|
135
|
+
- **Outside an effect scope there is no cleanup.** Called in `setup()` (or any `effectScope`) the
|
|
136
|
+
timer and the listener are released on dispose. Called at module top level, they are not.
|
|
137
|
+
|
|
138
|
+
## What it will not do
|
|
139
|
+
|
|
140
|
+
Each of these is a step towards RxDB / Replicache / TanStack DB, where a single small package loses
|
|
141
|
+
on day one:
|
|
142
|
+
|
|
143
|
+
- **No persistence / IndexedDB.** Hook `pending` yourself:
|
|
144
|
+
```ts
|
|
145
|
+
watch(() => outbox.pending, (keys) => {
|
|
146
|
+
localStorage.setItem('drafts', JSON.stringify(Object.fromEntries(keys.map((k) => [k, cells[k]]))))
|
|
147
|
+
})
|
|
148
|
+
```
|
|
149
|
+
- **No offline detection.** Offline is not a special case, it is a failing flush — the retry
|
|
150
|
+
behaviour already covers it.
|
|
151
|
+
- **No conflict resolution or merge.** That needs a CRDT.
|
|
152
|
+
- **No reading.** It is write-only; nothing here fetches, caches or invalidates.
|
|
153
|
+
- **No ordered operation log and no cross-key transactions.**
|
|
154
|
+
- **No HTTP client, transport or `sendBeacon`.** You pass a function; what it does is your business.
|
|
155
|
+
- **No schema and no collections.**
|
|
156
|
+
|
|
157
|
+
## Types
|
|
158
|
+
|
|
159
|
+
Everything is exported by name — nothing to recreate:
|
|
160
|
+
|
|
161
|
+
```ts
|
|
162
|
+
import type {
|
|
163
|
+
WriteBehind,
|
|
164
|
+
WriteBehindBaseOptions,
|
|
165
|
+
WriteBehindBatchOutcome,
|
|
166
|
+
WriteBehindBatchWriter,
|
|
167
|
+
WriteBehindFailure,
|
|
168
|
+
WriteBehindKey,
|
|
169
|
+
WriteBehindOptions,
|
|
170
|
+
WriteBehindRetryOptions,
|
|
171
|
+
WriteBehindSource,
|
|
172
|
+
WriteBehindWriter,
|
|
173
|
+
} from 'vue-write-behind'
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
`T` is inferred from the record, so `useWriteBehind(reactive<Record<string, number>>({}), write)`
|
|
177
|
+
gives you a `write` whose value is a `number`.
|
|
178
|
+
|
|
179
|
+
## Development
|
|
180
|
+
|
|
181
|
+
```bash
|
|
182
|
+
npm test # vitest: the state machine, the clock, the adapters, the composable
|
|
183
|
+
# (jsdom on Vue 3.5 and 3.3, plus an SSR project in node)
|
|
184
|
+
npm run typecheck # tsc over source and tests
|
|
185
|
+
npm run build # tsup → dist/*.min.js + .cjs + .d.ts
|
|
186
|
+
npm run check:dist # drive the BUILT artifact on real timers — dist goes stale silently
|
|
187
|
+
npm run check:browser # headless Chrome: type into a real input while a slow server answers,
|
|
188
|
+
# and read the value back out of the live DOM
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
`playground.html` is that browser check's page — serve the package directory and open it to try the
|
|
192
|
+
three cards by hand (`npm run check:browser` builds, serves and drives it for you). The claim it
|
|
193
|
+
exists to prove is the one no unit test can make: **the cell does not jump.**
|
|
194
|
+
|
|
195
|
+
`ARCHITECTURE.md` has the module map and the one invariant the split protects.
|
|
196
|
+
|
|
197
|
+
## License
|
|
198
|
+
|
|
199
|
+
MIT © Ozgur Seyidoglu
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { Ref } from 'vue';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Public types.
|
|
5
|
+
*
|
|
6
|
+
* Leaf module: imports nothing at runtime (the single `import type { Ref }`
|
|
7
|
+
* is erased at compile time), so it can be copied on its own.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Outbox keys are plain strings — the keys of the reactive record you pass in. */
|
|
11
|
+
type WriteBehindKey = string;
|
|
12
|
+
/**
|
|
13
|
+
* The record whose keys are written back. Either a `reactive()` object or a
|
|
14
|
+
* `ref()` holding a plain object.
|
|
15
|
+
*
|
|
16
|
+
* **Precondition — keys are independent.** Everything in this library relies on
|
|
17
|
+
* it: writes go out in parallel, in no particular order, last-write-wins per
|
|
18
|
+
* key. If key `b` is only valid once key `a` has landed, this is the wrong tool
|
|
19
|
+
* (that needs an ordered operation log — see the README's refuse list).
|
|
20
|
+
*/
|
|
21
|
+
type WriteBehindSource<T> = Record<WriteBehindKey, T> | Ref<Record<WriteBehindKey, T>>;
|
|
22
|
+
/**
|
|
23
|
+
* Per-key writer — the common form.
|
|
24
|
+
*
|
|
25
|
+
* Called with the value read out of the outbox **at send time**, never a value
|
|
26
|
+
* captured when the edit happened. Reject (or throw) to fail the key; the
|
|
27
|
+
* return value is otherwise ignored on purpose — the server's reply never
|
|
28
|
+
* touches local state.
|
|
29
|
+
*/
|
|
30
|
+
type WriteBehindWriter<T> = (value: T, key: WriteBehindKey) => unknown;
|
|
31
|
+
/** What a batch writer may resolve to in order to fail part of the batch. */
|
|
32
|
+
interface WriteBehindBatchOutcome {
|
|
33
|
+
/** Keys the server did not accept. Everything else in the batch is treated as written. */
|
|
34
|
+
failed?: readonly WriteBehindKey[];
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Batch writer — one call for every due key.
|
|
38
|
+
*
|
|
39
|
+
* Throw/reject and the **whole batch** stays pending. Resolve with
|
|
40
|
+
* `{ failed: [...] }` to fail part of it. Resolve with anything else (including
|
|
41
|
+
* `undefined`) and the whole batch is treated as written.
|
|
42
|
+
*/
|
|
43
|
+
type WriteBehindBatchWriter<T> = (entries: [WriteBehindKey, T][]) => WriteBehindBatchOutcome | void | Promise<WriteBehindBatchOutcome | void>;
|
|
44
|
+
/** Per-key exponential backoff. Defaults produce 1s → 2 → 4 → 8 → 16 → 30s, capped. */
|
|
45
|
+
interface WriteBehindRetryOptions {
|
|
46
|
+
/** Delay after the first failure, in ms. Default `1000`. */
|
|
47
|
+
initialDelay?: number;
|
|
48
|
+
/** Ceiling for the delay, in ms. Default `30000`. */
|
|
49
|
+
maxDelay?: number;
|
|
50
|
+
/** Multiplier applied per consecutive failure. Default `2`. */
|
|
51
|
+
factor?: number;
|
|
52
|
+
}
|
|
53
|
+
/** One key's latest failure. Only the newest error per key is kept. */
|
|
54
|
+
interface WriteBehindFailure {
|
|
55
|
+
key: WriteBehindKey;
|
|
56
|
+
/** Whatever the writer rejected with. */
|
|
57
|
+
error: unknown;
|
|
58
|
+
/** Consecutive failures — resets on success, on `retry()`, and on `discard()`. */
|
|
59
|
+
attempts: number;
|
|
60
|
+
/**
|
|
61
|
+
* Epoch ms of the next automatic attempt, or `undefined` when no automatic
|
|
62
|
+
* attempt is scheduled (`retry: false`) — that key needs an edit or an
|
|
63
|
+
* explicit `retry(key)`.
|
|
64
|
+
*/
|
|
65
|
+
retryAt: number | undefined;
|
|
66
|
+
}
|
|
67
|
+
/** Options shared by both writer shapes. Every one of them is an opt-*out*. */
|
|
68
|
+
interface WriteBehindBaseOptions<T> {
|
|
69
|
+
/** Flush cadence in ms. Default `1000`. The timer only runs while work is queued. */
|
|
70
|
+
interval?: number;
|
|
71
|
+
/**
|
|
72
|
+
* Per-key quiet period in ms before a key becomes eligible. Default `0`
|
|
73
|
+
* (the `interval` already coalesces a burst of edits into one write).
|
|
74
|
+
* An edit never *shortens* an active backoff.
|
|
75
|
+
*/
|
|
76
|
+
debounce?: number;
|
|
77
|
+
/**
|
|
78
|
+
* Retry policy, or `false` to stop retrying a key after a failure. Retrying
|
|
79
|
+
* is the default because dropping a user's edit is the one unacceptable
|
|
80
|
+
* outcome. With `false` the key stays pending and listed in `failed` — it is
|
|
81
|
+
* never discarded — until the next edit or an explicit `retry(key)`.
|
|
82
|
+
*/
|
|
83
|
+
retry?: WriteBehindRetryOptions | false;
|
|
84
|
+
/**
|
|
85
|
+
* Flush when the tab is hidden (`visibilitychange`). Default `true`.
|
|
86
|
+
* Best-effort only: the browser may kill the page before the request leaves.
|
|
87
|
+
*/
|
|
88
|
+
flushOnHidden?: boolean;
|
|
89
|
+
/**
|
|
90
|
+
* Narrows what the source watcher picks up. An allow-list or a predicate.
|
|
91
|
+
* Default: every key. `set()` is explicit and ignores this filter.
|
|
92
|
+
*/
|
|
93
|
+
keys?: readonly WriteBehindKey[] | ((key: WriteBehindKey) => boolean);
|
|
94
|
+
/**
|
|
95
|
+
* Change detection for a key's value. Default `Object.is`.
|
|
96
|
+
*
|
|
97
|
+
* With the default, mutating an object value **in place** is not an edit —
|
|
98
|
+
* replace the object, or call `set(key, value)`.
|
|
99
|
+
*/
|
|
100
|
+
equals?: (a: T, b: T) => boolean;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Options for `useWriteBehind`. Exactly one writer: `write` (per key) or
|
|
104
|
+
* `flush` (batched).
|
|
105
|
+
*/
|
|
106
|
+
type WriteBehindOptions<T> = WriteBehindBaseOptions<T> & ({
|
|
107
|
+
write: WriteBehindWriter<T>;
|
|
108
|
+
flush?: undefined;
|
|
109
|
+
} | {
|
|
110
|
+
write?: undefined;
|
|
111
|
+
flush: WriteBehindBatchWriter<T>;
|
|
112
|
+
});
|
|
113
|
+
/**
|
|
114
|
+
* The reactive store `useWriteBehind` returns. Read the fields straight in a
|
|
115
|
+
* template; they are recomputed on every state change.
|
|
116
|
+
*/
|
|
117
|
+
interface WriteBehind<T> {
|
|
118
|
+
/**
|
|
119
|
+
* Every key with an unsaved change, **including** the ones currently on the
|
|
120
|
+
* wire. This is the "you have unsaved work" number.
|
|
121
|
+
*/
|
|
122
|
+
readonly pending: readonly WriteBehindKey[];
|
|
123
|
+
/** The subset of `pending` currently in flight. */
|
|
124
|
+
readonly inFlight: readonly WriteBehindKey[];
|
|
125
|
+
/** Latest failure per failing key. */
|
|
126
|
+
readonly failed: readonly WriteBehindFailure[];
|
|
127
|
+
/** `true` while anything is in flight. */
|
|
128
|
+
readonly isSyncing: boolean;
|
|
129
|
+
/**
|
|
130
|
+
* Write a value into the source **and** queue it. Always queues, even when
|
|
131
|
+
* the value is unchanged — the escape hatch for values `equals` cannot see
|
|
132
|
+
* (an object mutated in place) and for keys excluded by `keys`.
|
|
133
|
+
*/
|
|
134
|
+
set: (key: WriteBehindKey, value: T) => void;
|
|
135
|
+
/**
|
|
136
|
+
* Send every pending key that is not already in flight, ignoring the
|
|
137
|
+
* `debounce` and backoff clocks. Resolves when the requests it started have
|
|
138
|
+
* settled — keys edited *during* that flight are still pending afterwards.
|
|
139
|
+
*/
|
|
140
|
+
flush: () => Promise<void>;
|
|
141
|
+
/**
|
|
142
|
+
* Clear the backoff (and the recorded failure) for one key, or all of them,
|
|
143
|
+
* so they go out on the next tick. The only way to revive a key that failed
|
|
144
|
+
* under `retry: false`.
|
|
145
|
+
*/
|
|
146
|
+
retry: (key?: WriteBehindKey) => void;
|
|
147
|
+
/**
|
|
148
|
+
* Drop a key's pending write. **The only operation in this library that
|
|
149
|
+
* loses a write** — nothing else ever discards one.
|
|
150
|
+
*/
|
|
151
|
+
discard: (key: WriteBehindKey) => void;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Write-behind cache for a reactive record. Local state stays authoritative;
|
|
156
|
+
* the writer's result is discarded on purpose.
|
|
157
|
+
*
|
|
158
|
+
* @example
|
|
159
|
+
* ```ts
|
|
160
|
+
* const cells = reactive<Record<string, string>>({ A1: 'foo' })
|
|
161
|
+
* const outbox = useWriteBehind(cells, (value, key) => api.put(`/cell/${key}`, value))
|
|
162
|
+
* cells.A1 = 'bar' // that is the whole API
|
|
163
|
+
* ```
|
|
164
|
+
*
|
|
165
|
+
* @param source A `reactive()` record (or a `ref()` holding one). Its keys must
|
|
166
|
+
* be independent of each other — writes go out in parallel, last-write-wins.
|
|
167
|
+
* @param writerOrOptions The per-key writer, or an options object carrying
|
|
168
|
+
* either `write` (per key) or `flush` (batched).
|
|
169
|
+
*/
|
|
170
|
+
declare function useWriteBehind<T>(source: WriteBehindSource<T>, writerOrOptions: WriteBehindWriter<T> | WriteBehindOptions<T>): WriteBehind<T>;
|
|
171
|
+
|
|
172
|
+
export { type WriteBehind, type WriteBehindBaseOptions, type WriteBehindBatchOutcome, type WriteBehindBatchWriter, type WriteBehindFailure, type WriteBehindKey, type WriteBehindOptions, type WriteBehindRetryOptions, type WriteBehindSource, type WriteBehindWriter, useWriteBehind };
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
"use strict";var g=Object.defineProperty;var R=Object.getOwnPropertyDescriptor;var I=Object.getOwnPropertyNames;var C=Object.prototype.hasOwnProperty;var _=(n,r)=>{for(var e in r)g(n,e,{get:r[e],enumerable:!0})},M=(n,r,e,f)=>{if(r&&typeof r=="object"||typeof r=="function")for(let s of I(r))!C.call(n,s)&&s!==e&&g(n,s,{get:()=>r[s],enumerable:!(f=R(r,s))||f.enumerable});return n};var P=n=>M(g({},"__esModule",{value:!0}),n);var Y={};_(Y,{useWriteBehind:()=>A});module.exports=P(Y);var b=require("vue");var U=n=>n?n.failed??[]:[];function E({outbox:n,writer:r,now:e}){let f=new Set,s=l=>{f.add(l),l.then(()=>f.delete(l))},B=async(l,o)=>{try{await l(o.value,o.key),n.settle(o.key,o.sentVersion)}catch(p){n.fail(o.key,o.sentVersion,p,e())}},T=async(l,o)=>{let p=o.map(h=>[h.key,h.value]);try{let h=new Set(U(await l(p)));for(let a of o)h.has(a.key)?n.fail(a.key,a.sentVersion,new Error(`vue-write-behind: batch flush reported "${a.key}" as failed`),e()):n.settle(a.key,a.sentVersion)}catch(h){for(let a of o)n.fail(a.key,a.sentVersion,h,e())}},W=(l=!1)=>{let o=n.take(l?Number.POSITIVE_INFINITY:e());if(o.length!==0)if(r.flush)s(T(r.flush,o));else for(let p of o)s(B(r.write,p))};return{dispatch:W,flush:async()=>{W(!0),await Promise.all([...f])}}}var x=require("vue"),K=()=>typeof window>"u"||typeof document>"u";function S(n){if(K())return()=>{};let r=()=>{document.visibilityState==="hidden"&&n()};return document.addEventListener("visibilitychange",r),()=>document.removeEventListener("visibilitychange",r)}function w(n){(0,x.getCurrentScope)()&&(0,x.onScopeDispose)(n)}function D({retryDelay:n,onChange:r}){let e=new Map,f=0,s=()=>r?.();return{set:(i,d,t=0)=>{f+=1;let u=e.get(i);u?(u.value=d,u.version=f,u.blocked=!1,u.dueAt=Math.max(u.dueAt,t)):e.set(i,{value:d,version:f,sentVersion:void 0,attempts:0,error:void 0,dueAt:t,blocked:!1}),s()},take:i=>{let d=[];for(let[t,u]of e)u.sentVersion===void 0&&(u.blocked||u.dueAt>i||(u.sentVersion=u.version,d.push({key:t,value:u.value,sentVersion:u.version})));return d.length>0&&s(),d},settle:(i,d)=>{let t=e.get(i);!t||t.sentVersion!==d||(t.version===d?e.delete(i):(t.sentVersion=void 0,t.attempts=0,t.error=void 0,t.dueAt=0,t.blocked=!1),s())},fail:(i,d,t,u)=>{let y=e.get(i);if(!y||y.sentVersion!==d)return;y.sentVersion=void 0,y.attempts+=1,y.error=t;let k=n(y.attempts);k===void 0?y.blocked=!0:y.dueAt=u+k,s()},discard:i=>{e.delete(i)&&s()},clearBackoff:i=>{let d=i===void 0?e.values():[e.get(i)];for(let t of d)t&&(t.attempts=0,t.error=void 0,t.dueAt=0,t.blocked=!1);s()},pendingKeys:()=>[...e.keys()],inFlightKeys:()=>[...e].filter(([,i])=>i.sentVersion!==void 0).map(([i])=>i),failures:()=>{let i=[];for(let[d,t]of e)t.attempts!==0&&i.push({key:d,error:t.error,attempts:t.attempts,retryAt:t.blocked?void 0:t.dueAt});return i},isEmpty:()=>e.size===0,hasScheduledWork:()=>{for(let i of e.values())if(i.sentVersion===void 0&&!i.blocked)return!0;return!1}}}function V(n){if(n===!1)return()=>{};let{initialDelay:r=1e3,maxDelay:e=3e4,factor:f=2}=n??{};return s=>Math.min(r*f**(s-1),e)}function L({interval:n,onTick:r}){let e;return{start:()=>{e===void 0&&(e=setInterval(r,n))},stop:()=>{e!==void 0&&(clearInterval(e),e=void 0)},isRunning:()=>e!==void 0}}var N=1e3;function A(n,r){let e=typeof r=="function"?{write:r}:r,f=e.flush?{flush:e.flush}:{write:e.write},s=e.interval??N,B=e.debounce??0,T=e.equals??Object.is,W=e.keys,l=W===void 0?()=>!0:typeof W=="function"?W:c=>W.includes(c),o=D({retryDelay:V(e.retry),onChange:()=>y()}),p=E({outbox:o,writer:f,now:Date.now}),h=L({interval:s,onTick:()=>p.dispatch()}),a=()=>(0,b.isRef)(n)?n.value:n,i=new Map;for(let[c,m]of Object.entries(a()))l(c)&&i.set(c,{value:m});let d=()=>{let c=a(),m=B>0?Date.now()+B:0;for(let[v,O]of Object.entries(c)){if(!l(v))continue;let F=i.get(v);F&&T(F.value,O)||(i.set(v,{value:O}),o.set(v,O,m))}for(let v of i.keys())v in c||i.delete(v)},t=(0,b.shallowReactive)({pending:[],inFlight:[],failed:[],isSyncing:!1,set:(c,m)=>{a()[c]=m,i.set(c,{value:m}),o.set(c,m,B>0?Date.now()+B:0)},flush:()=>(d(),p.flush()),retry:c=>o.clearBackoff(c),discard:c=>o.discard(c)}),u=()=>{let c=o.inFlightKeys();t.pending=o.pendingKeys(),t.inFlight=c,t.failed=o.failures(),t.isSyncing=c.length>0};function y(){u(),!K()&&(o.hasScheduledWork()?h.start():h.stop())}(0,b.watch)(a,d,{deep:!0});let k=e.flushOnHidden===!1?()=>{}:S(()=>{t.flush()});return w(()=>{h.stop(),k()}),t}0&&(module.exports={useWriteBehind});
|
|
2
|
+
//# sourceMappingURL=vueWriteBehind.min.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../vueWriteBehind.ts","../src/useWriteBehind.ts","../src/flush.ts","../src/lifecycle.ts","../src/outbox.ts","../src/scheduler.ts"],"sourcesContent":["/**\n * Build entry point — re-exports the public surface from `src/`.\n *\n * The split keeps each concern in a single-purpose module (types / outbox /\n * scheduler / flush / lifecycle / the composable) without changing the bundle:\n * tsup follows this entry and emits the same minified files. See\n * ARCHITECTURE.md for the module map and the invariant it protects.\n */\nexport { useWriteBehind } from './src'\nexport type {\n WriteBehind,\n WriteBehindBaseOptions,\n WriteBehindBatchOutcome,\n WriteBehindBatchWriter,\n WriteBehindFailure,\n WriteBehindKey,\n WriteBehindOptions,\n WriteBehindRetryOptions,\n WriteBehindSource,\n WriteBehindWriter,\n} from './src'\n","/**\n * The Vue surface — watches the source record, mirrors the outbox into a\n * reactive store, and owns when the clock runs.\n *\n * It reads local state and never writes it back: nothing here applies a server\n * response to the source, because the whole point is that the cell the user is\n * typing in cannot be overwritten from the network.\n */\nimport { isRef, shallowReactive, watch } from 'vue'\nimport { createFlusher, type ResolvedWriter } from './flush'\nimport { isServer, onDispose, onTabHidden } from './lifecycle'\nimport { createOutbox } from './outbox'\nimport { createBackoff, createScheduler } from './scheduler'\nimport type {\n WriteBehind,\n WriteBehindKey,\n WriteBehindOptions,\n WriteBehindSource,\n WriteBehindWriter,\n} from './types'\n\nconst DEFAULT_INTERVAL = 1000\n\n/** The store as this module holds it: same shape, writable. */\ntype MutableWriteBehind<T> = { -readonly [K in keyof WriteBehind<T>]: WriteBehind<T>[K] }\n\n/**\n * Write-behind cache for a reactive record. Local state stays authoritative;\n * the writer's result is discarded on purpose.\n *\n * @example\n * ```ts\n * const cells = reactive<Record<string, string>>({ A1: 'foo' })\n * const outbox = useWriteBehind(cells, (value, key) => api.put(`/cell/${key}`, value))\n * cells.A1 = 'bar' // that is the whole API\n * ```\n *\n * @param source A `reactive()` record (or a `ref()` holding one). Its keys must\n * be independent of each other — writes go out in parallel, last-write-wins.\n * @param writerOrOptions The per-key writer, or an options object carrying\n * either `write` (per key) or `flush` (batched).\n */\nexport function useWriteBehind<T>(\n source: WriteBehindSource<T>,\n writerOrOptions: WriteBehindWriter<T> | WriteBehindOptions<T>,\n): WriteBehind<T> {\n const options: WriteBehindOptions<T> =\n typeof writerOrOptions === 'function' ? { write: writerOrOptions } : writerOrOptions\n\n const writer: ResolvedWriter<T> = options.flush\n ? { flush: options.flush }\n : { write: options.write }\n\n const interval = options.interval ?? DEFAULT_INTERVAL\n const debounce = options.debounce ?? 0\n const equals = options.equals ?? Object.is\n const keys = options.keys\n const tracked: (key: WriteBehindKey) => boolean =\n keys === undefined\n ? () => true\n : typeof keys === 'function'\n ? keys\n : (key) => keys.includes(key)\n\n const outbox = createOutbox<T>({\n retryDelay: createBackoff(options.retry),\n onChange: () => onOutboxChange(),\n })\n const flusher = createFlusher<T>({ outbox, writer, now: Date.now })\n const scheduler = createScheduler({ interval, onTick: () => flusher.dispatch() })\n\n const readSource = (): Record<WriteBehindKey, T> => (isRef(source) ? source.value : source)\n\n // Last value seen per key. Boxed so `T` may legitimately be `undefined`\n // without `has`/`get` disagreeing.\n const shadow = new Map<WriteBehindKey, { value: T }>()\n for (const [key, value] of Object.entries(readSource())) {\n if (!tracked(key)) continue\n // Seeded, NOT queued: whatever the record starts with came from the server.\n shadow.set(key, { value })\n }\n\n const syncFromSource = (): void => {\n const current = readSource()\n const dueAt = debounce > 0 ? Date.now() + debounce : 0\n for (const [key, value] of Object.entries(current)) {\n if (!tracked(key)) continue\n const seen = shadow.get(key)\n if (seen && equals(seen.value, value)) continue\n shadow.set(key, { value })\n outbox.set(key, value, dueAt)\n }\n // A key deleted from the source stops being watched, but its queued write\n // survives — losing it silently is the one outcome this library refuses.\n // `discard(key)` is how a consumer drops it on purpose.\n for (const key of shadow.keys()) {\n if (!(key in current)) shadow.delete(key)\n }\n }\n\n const store = shallowReactive<MutableWriteBehind<T>>({\n pending: [],\n inFlight: [],\n failed: [],\n isSyncing: false,\n set: (key, value) => {\n readSource()[key] = value\n shadow.set(key, { value })\n // Unconditional: `set` is the escape hatch for a change `equals` cannot\n // see (an object mutated in place) and for keys `keys` filters out.\n outbox.set(key, value, debounce > 0 ? Date.now() + debounce : 0)\n },\n flush: () => {\n // The source watcher is a `pre` watcher, so an edit made in this tick has\n // not been picked up yet — read it now or a save button next to an input\n // would miss the last keystroke.\n syncFromSource()\n return flusher.flush()\n },\n retry: (key) => outbox.clearBackoff(key),\n discard: (key) => outbox.discard(key),\n })\n\n const syncStore = (): void => {\n const inFlight = outbox.inFlightKeys()\n store.pending = outbox.pendingKeys()\n store.inFlight = inFlight\n store.failed = outbox.failures()\n store.isSyncing = inFlight.length > 0\n }\n\n function onOutboxChange(): void {\n syncStore()\n // Never start a timer on the server, and never leave one running with\n // nothing to send — an idle app must not hold the event loop open.\n if (isServer()) return\n if (outbox.hasScheduledWork()) scheduler.start()\n else scheduler.stop()\n }\n\n watch(readSource, syncFromSource, { deep: true })\n\n const stopHiddenListener =\n options.flushOnHidden === false ? () => {} : onTabHidden(() => void store.flush())\n\n onDispose(() => {\n scheduler.stop()\n stopHiddenListener()\n })\n\n return store\n}\n","/**\n * The writer adapters — the only place a request is made.\n *\n * Two shapes, one rule: whatever the network does, the outcome is reported back\n * to the outbox and nothing else. This module never clears a key itself, never\n * looks at what a writer resolved with, and never touches local state. The\n * server's reply is discarded on purpose.\n *\n * Per-key writes are independent, so they run in parallel and each key settles\n * the moment its own request returns (`Promise.allSettled` semantics — one\n * rejection can neither block nor fail a sibling). That is safe only because\n * key independence is a stated precondition of the library.\n */\nimport type { Outbox, OutboxEntry } from './outbox'\nimport type {\n WriteBehindBatchOutcome,\n WriteBehindBatchWriter,\n WriteBehindKey,\n WriteBehindWriter,\n} from './types'\n\n/** Exactly one writer, already narrowed from the options. */\nexport type ResolvedWriter<T> =\n | { write: WriteBehindWriter<T>; flush?: undefined }\n | { write?: undefined; flush: WriteBehindBatchWriter<T> }\n\nexport interface FlusherConfig<T> {\n outbox: Outbox<T>\n writer: ResolvedWriter<T>\n /** Injected so the flusher owns no clock of its own. */\n now: () => number\n}\n\nexport interface Flusher {\n /** Send everything due. `force` ignores the debounce and backoff clocks. */\n dispatch: (force?: boolean) => void\n /** Force a dispatch and wait for every request currently in the air. */\n flush: () => Promise<void>\n}\n\nconst failedKeysOf = (outcome: WriteBehindBatchOutcome | void): readonly WriteBehindKey[] => {\n if (!outcome) return []\n return outcome.failed ?? []\n}\n\nexport function createFlusher<T>({ outbox, writer, now }: FlusherConfig<T>): Flusher {\n const inAir = new Set<Promise<void>>()\n\n const track = (flight: Promise<void>): void => {\n inAir.add(flight)\n void flight.then(() => inAir.delete(flight))\n }\n\n const runPerKey = async (write: WriteBehindWriter<T>, entry: OutboxEntry<T>): Promise<void> => {\n try {\n await write(entry.value, entry.key)\n outbox.settle(entry.key, entry.sentVersion)\n } catch (error) {\n outbox.fail(entry.key, entry.sentVersion, error, now())\n }\n }\n\n const runBatch = async (\n flushAll: WriteBehindBatchWriter<T>,\n batch: OutboxEntry<T>[],\n ): Promise<void> => {\n const entries = batch.map((entry): [WriteBehindKey, T] => [entry.key, entry.value])\n try {\n const failed = new Set(failedKeysOf(await flushAll(entries)))\n for (const entry of batch) {\n if (failed.has(entry.key)) {\n outbox.fail(\n entry.key,\n entry.sentVersion,\n new Error(`vue-write-behind: batch flush reported \"${entry.key}\" as failed`),\n now(),\n )\n } else {\n outbox.settle(entry.key, entry.sentVersion)\n }\n }\n } catch (error) {\n // One rejection means the transport failed, so nothing in the batch is\n // known to have landed — every key stays pending.\n for (const entry of batch) outbox.fail(entry.key, entry.sentVersion, error, now())\n }\n }\n\n const dispatch = (force = false): void => {\n const batch = outbox.take(force ? Number.POSITIVE_INFINITY : now())\n if (batch.length === 0) return\n // The union guarantees exactly one of the two is present.\n if (writer.flush) track(runBatch(writer.flush, batch))\n else for (const entry of batch) track(runPerKey(writer.write, entry))\n }\n\n return {\n dispatch,\n flush: async () => {\n dispatch(true)\n await Promise.all([...inAir])\n },\n }\n}\n","/**\n * Everything with a lifetime outside the outbox: the environment check, the\n * tab-hidden hook and scope disposal.\n *\n * `visibilitychange` rather than `beforeunload`: mobile browsers routinely\n * discard a page without ever firing `beforeunload`, and Safari fires\n * `pagehide` instead. `visibilitychange → hidden` is the one signal that fires\n * on every platform — and it is still only best-effort, because the page can be\n * frozen before the request leaves. `pending` is exposed so an app can warn.\n */\nimport { getCurrentScope, onScopeDispose } from 'vue'\n\n/** True when there is no DOM — SSR, or a worker. */\nexport const isServer = (): boolean =>\n typeof window === 'undefined' || typeof document === 'undefined'\n\n/** Subscribe to the tab going hidden. Returns the unsubscribe; a no-op on the server. */\nexport function onTabHidden(handler: () => void): () => void {\n if (isServer()) return () => {}\n const listener = (): void => {\n if (document.visibilityState === 'hidden') handler()\n }\n document.addEventListener('visibilitychange', listener)\n return () => document.removeEventListener('visibilitychange', listener)\n}\n\n/**\n * Register cleanup with the surrounding effect scope, if there is one. Called\n * outside `setup()` (imperative code, tests) there is nothing to hook, and Vue\n * would warn — so guard rather than warn. `getCurrentScope()` exists in 3.0;\n * `onScopeDispose`'s `failSilently` argument only arrived in 3.5.\n */\nexport function onDispose(cleanup: () => void): void {\n if (getCurrentScope()) onScopeDispose(cleanup)\n}\n","/**\n * The outbox: the pure key/version/dirty state machine.\n *\n * No Vue, no timers, no I/O — every clock reading arrives as an argument. This\n * is where the whole library's correctness lives, which is why it is testable\n * without mounting anything.\n *\n * **Invariant: this module is the only place a dirty key is ever cleared.**\n * Everything else (scheduler, flush, the composable) can only ask.\n *\n * The rule that makes it correct: a key carries a **monotonic version**, and a\n * write records the version it was sent at. A success clears the key only when\n * the version has not moved since. A boolean dirty flag cannot express that —\n * the response would clear a flag a newer edit had set, and that edit would be\n * gone with nothing on screen to say so.\n */\nimport type { WriteBehindFailure, WriteBehindKey } from './types'\n\n/** One entry handed to a writer. `value` is read out of the map at take time. */\nexport interface OutboxEntry<T> {\n key: WriteBehindKey\n value: T\n /**\n * The version this send is for. Hand it back to `settle`/`fail` — it is the\n * flight's identity, so a response from a superseded flight (the key was\n * discarded and re-queued, or `retry()` re-armed it) is ignored instead of\n * clearing the wrong write.\n */\n sentVersion: number\n}\n\ninterface Entry<T> {\n value: T\n /** Bumped on every edit. Unique across the whole outbox, never reused. */\n version: number\n /** The version currently on the wire, or `undefined` when nothing is. */\n sentVersion: number | undefined\n /** Consecutive failures. */\n attempts: number\n error: unknown\n /** Epoch ms before which the key is not eligible. `0` = eligible now. */\n dueAt: number\n /** Failed with retries disabled: needs a fresh edit or an explicit retry. */\n blocked: boolean\n}\n\nexport interface OutboxConfig {\n /**\n * Backoff for the n-th consecutive failure, in ms. Returning `undefined`\n * blocks the key instead of scheduling an attempt (`retry: false`).\n */\n retryDelay: (attempts: number) => number | undefined\n /** Called after every state transition. */\n onChange?: () => void\n}\n\nexport interface Outbox<T> {\n /**\n * Queue a value. `dueAt` (epoch ms) holds the key back — used for `debounce`.\n * It can only ever push the key further out, never pull an active backoff in.\n */\n set: (key: WriteBehindKey, value: T, dueAt?: number) => void\n /**\n * Claim every key that is due at `now`, not in flight and not blocked,\n * marking each in flight. Pass `Infinity` to ignore the clocks (`flush()`).\n */\n take: (now: number) => OutboxEntry<T>[]\n /** The write landed. Clears the key **only if** its version has not moved. */\n settle: (key: WriteBehindKey, sentVersion: number) => void\n /** The write failed. Never clears the key. */\n fail: (key: WriteBehindKey, sentVersion: number, error: unknown, now: number) => void\n /** Forget a key's pending write entirely — the one operation that loses one. */\n discard: (key: WriteBehindKey) => void\n /** Make a key (or all of them) eligible again and forget its failure. */\n clearBackoff: (key?: WriteBehindKey) => void\n /** Every unconfirmed key, in-flight ones included. */\n pendingKeys: () => WriteBehindKey[]\n inFlightKeys: () => WriteBehindKey[]\n failures: () => WriteBehindFailure[]\n isEmpty: () => boolean\n /** True while some key could still become eligible — i.e. the clock is worth running. */\n hasScheduledWork: () => boolean\n}\n\nexport function createOutbox<T>({ retryDelay, onChange }: OutboxConfig): Outbox<T> {\n const entries = new Map<WriteBehindKey, Entry<T>>()\n // One counter for the whole outbox rather than one per key: versions are then\n // never reused, so a response from a discarded flight can never be mistaken\n // for the current one on a key that was queued again in the meantime.\n let version = 0\n\n const notify = () => onChange?.()\n\n const set = (key: WriteBehindKey, value: T, dueAt = 0): void => {\n version += 1\n const entry = entries.get(key)\n if (entry) {\n entry.value = value\n entry.version = version\n // A new value is a new write, not a retry — it re-arms a blocked key…\n entry.blocked = false\n // …but it must not shorten an active backoff, or a user typing into a\n // failing endpoint would fire one request per keystroke.\n entry.dueAt = Math.max(entry.dueAt, dueAt)\n } else {\n entries.set(key, {\n value,\n version,\n sentVersion: undefined,\n attempts: 0,\n error: undefined,\n dueAt,\n blocked: false,\n })\n }\n notify()\n }\n\n const take = (now: number): OutboxEntry<T>[] => {\n const batch: OutboxEntry<T>[] = []\n for (const [key, entry] of entries) {\n if (entry.sentVersion !== undefined) continue // already on the wire (H5)\n if (entry.blocked) continue\n if (entry.dueAt > now) continue\n entry.sentVersion = entry.version\n batch.push({ key, value: entry.value, sentVersion: entry.version })\n }\n if (batch.length > 0) notify()\n return batch\n }\n\n const settle = (key: WriteBehindKey, sentVersion: number): void => {\n const entry = entries.get(key)\n if (!entry || entry.sentVersion !== sentVersion) return\n if (entry.version === sentVersion) {\n // Nothing was typed while this was in flight: the key is saved.\n entries.delete(key)\n } else {\n // It was. Keep it dirty and send the newer value next tick; the server is\n // evidently healthy, so drop the backoff.\n entry.sentVersion = undefined\n entry.attempts = 0\n entry.error = undefined\n entry.dueAt = 0\n entry.blocked = false\n }\n notify()\n }\n\n const fail = (key: WriteBehindKey, sentVersion: number, error: unknown, now: number): void => {\n const entry = entries.get(key)\n if (!entry || entry.sentVersion !== sentVersion) return\n entry.sentVersion = undefined\n entry.attempts += 1\n entry.error = error\n const delay = retryDelay(entry.attempts)\n if (delay === undefined) entry.blocked = true\n else entry.dueAt = now + delay\n notify()\n }\n\n const discard = (key: WriteBehindKey): void => {\n if (!entries.delete(key)) return\n notify()\n }\n\n const clearBackoff = (key?: WriteBehindKey): void => {\n const targets = key === undefined ? entries.values() : [entries.get(key)]\n for (const entry of targets) {\n if (!entry) continue\n entry.attempts = 0\n entry.error = undefined\n entry.dueAt = 0\n entry.blocked = false\n }\n notify()\n }\n\n const failures = (): WriteBehindFailure[] => {\n const list: WriteBehindFailure[] = []\n for (const [key, entry] of entries) {\n if (entry.attempts === 0) continue\n list.push({\n key,\n error: entry.error,\n attempts: entry.attempts,\n retryAt: entry.blocked ? undefined : entry.dueAt,\n })\n }\n return list\n }\n\n const hasScheduledWork = (): boolean => {\n for (const entry of entries.values()) {\n if (entry.sentVersion === undefined && !entry.blocked) return true\n }\n return false\n }\n\n return {\n set,\n take,\n settle,\n fail,\n discard,\n clearBackoff,\n pendingKeys: () => [...entries.keys()],\n inFlightKeys: () =>\n [...entries].filter(([, entry]) => entry.sentVersion !== undefined).map(([key]) => key),\n failures,\n isEmpty: () => entries.size === 0,\n hasScheduledWork,\n }\n}\n","/**\n * The clock — the flush interval and the per-key backoff curve.\n *\n * Deliberately ignorant of writes and keys: it only knows \"call this every N\n * ms\" and \"how long after the n-th failure\". The decision to run at all belongs\n * to `useWriteBehind`, which starts it when work is queued and stops it when\n * there is none — an idle app must not hold a timer open.\n */\nimport type { WriteBehindRetryOptions } from './types'\n\nconst DEFAULT_INITIAL_DELAY = 1000\nconst DEFAULT_MAX_DELAY = 30000\nconst DEFAULT_FACTOR = 2\n\n/**\n * Backoff for the n-th consecutive failure (1-based), in ms — or `undefined`\n * when retries are off, which parks the key until it is edited or retried.\n *\n * The cap matters: `factor ** attempts` reaches Infinity within a couple of\n * dozen failures, and a key parked at `Infinity` would never be retried, which\n * is exactly the silent write loss this library exists to prevent.\n */\nexport function createBackoff(\n retry: WriteBehindRetryOptions | false | undefined,\n): (attempts: number) => number | undefined {\n if (retry === false) return () => undefined\n const {\n initialDelay = DEFAULT_INITIAL_DELAY,\n maxDelay = DEFAULT_MAX_DELAY,\n factor = DEFAULT_FACTOR,\n } = retry ?? {}\n return (attempts) => Math.min(initialDelay * factor ** (attempts - 1), maxDelay)\n}\n\nexport interface SchedulerConfig {\n interval: number\n onTick: () => void\n}\n\nexport interface Scheduler {\n /** Idempotent: calling it while running keeps the current phase. */\n start: () => void\n stop: () => void\n isRunning: () => boolean\n}\n\nexport function createScheduler({ interval, onTick }: SchedulerConfig): Scheduler {\n let timer: ReturnType<typeof setInterval> | undefined\n\n return {\n start: () => {\n // Restarting would reset the phase, so a fast typist could push the flush\n // out indefinitely — the interval has to be a fixed window, not a debounce.\n if (timer !== undefined) return\n timer = setInterval(onTick, interval)\n },\n stop: () => {\n if (timer === undefined) return\n clearInterval(timer)\n timer = undefined\n },\n isRunning: () => timer !== undefined,\n }\n}\n"],"mappings":"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","names":["vueWriteBehind_exports","__export","useWriteBehind","__toCommonJS","import_vue","failedKeysOf","outcome","createFlusher","outbox","writer","now","inAir","track","flight","runPerKey","write","entry","error","runBatch","flushAll","batch","entries","failed","dispatch","force","import_vue","isServer","onTabHidden","handler","listener","onDispose","cleanup","createOutbox","retryDelay","onChange","entries","version","notify","key","value","dueAt","entry","now","batch","sentVersion","error","delay","targets","list","createBackoff","retry","initialDelay","maxDelay","factor","attempts","createScheduler","interval","onTick","timer","DEFAULT_INTERVAL","useWriteBehind","source","writerOrOptions","options","writer","interval","debounce","equals","keys","tracked","key","outbox","createOutbox","createBackoff","onOutboxChange","flusher","createFlusher","scheduler","createScheduler","readSource","shadow","value","syncFromSource","current","dueAt","seen","store","syncStore","inFlight","isServer","stopHiddenListener","onTabHidden","onDispose"]}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { Ref } from 'vue';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Public types.
|
|
5
|
+
*
|
|
6
|
+
* Leaf module: imports nothing at runtime (the single `import type { Ref }`
|
|
7
|
+
* is erased at compile time), so it can be copied on its own.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Outbox keys are plain strings — the keys of the reactive record you pass in. */
|
|
11
|
+
type WriteBehindKey = string;
|
|
12
|
+
/**
|
|
13
|
+
* The record whose keys are written back. Either a `reactive()` object or a
|
|
14
|
+
* `ref()` holding a plain object.
|
|
15
|
+
*
|
|
16
|
+
* **Precondition — keys are independent.** Everything in this library relies on
|
|
17
|
+
* it: writes go out in parallel, in no particular order, last-write-wins per
|
|
18
|
+
* key. If key `b` is only valid once key `a` has landed, this is the wrong tool
|
|
19
|
+
* (that needs an ordered operation log — see the README's refuse list).
|
|
20
|
+
*/
|
|
21
|
+
type WriteBehindSource<T> = Record<WriteBehindKey, T> | Ref<Record<WriteBehindKey, T>>;
|
|
22
|
+
/**
|
|
23
|
+
* Per-key writer — the common form.
|
|
24
|
+
*
|
|
25
|
+
* Called with the value read out of the outbox **at send time**, never a value
|
|
26
|
+
* captured when the edit happened. Reject (or throw) to fail the key; the
|
|
27
|
+
* return value is otherwise ignored on purpose — the server's reply never
|
|
28
|
+
* touches local state.
|
|
29
|
+
*/
|
|
30
|
+
type WriteBehindWriter<T> = (value: T, key: WriteBehindKey) => unknown;
|
|
31
|
+
/** What a batch writer may resolve to in order to fail part of the batch. */
|
|
32
|
+
interface WriteBehindBatchOutcome {
|
|
33
|
+
/** Keys the server did not accept. Everything else in the batch is treated as written. */
|
|
34
|
+
failed?: readonly WriteBehindKey[];
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Batch writer — one call for every due key.
|
|
38
|
+
*
|
|
39
|
+
* Throw/reject and the **whole batch** stays pending. Resolve with
|
|
40
|
+
* `{ failed: [...] }` to fail part of it. Resolve with anything else (including
|
|
41
|
+
* `undefined`) and the whole batch is treated as written.
|
|
42
|
+
*/
|
|
43
|
+
type WriteBehindBatchWriter<T> = (entries: [WriteBehindKey, T][]) => WriteBehindBatchOutcome | void | Promise<WriteBehindBatchOutcome | void>;
|
|
44
|
+
/** Per-key exponential backoff. Defaults produce 1s → 2 → 4 → 8 → 16 → 30s, capped. */
|
|
45
|
+
interface WriteBehindRetryOptions {
|
|
46
|
+
/** Delay after the first failure, in ms. Default `1000`. */
|
|
47
|
+
initialDelay?: number;
|
|
48
|
+
/** Ceiling for the delay, in ms. Default `30000`. */
|
|
49
|
+
maxDelay?: number;
|
|
50
|
+
/** Multiplier applied per consecutive failure. Default `2`. */
|
|
51
|
+
factor?: number;
|
|
52
|
+
}
|
|
53
|
+
/** One key's latest failure. Only the newest error per key is kept. */
|
|
54
|
+
interface WriteBehindFailure {
|
|
55
|
+
key: WriteBehindKey;
|
|
56
|
+
/** Whatever the writer rejected with. */
|
|
57
|
+
error: unknown;
|
|
58
|
+
/** Consecutive failures — resets on success, on `retry()`, and on `discard()`. */
|
|
59
|
+
attempts: number;
|
|
60
|
+
/**
|
|
61
|
+
* Epoch ms of the next automatic attempt, or `undefined` when no automatic
|
|
62
|
+
* attempt is scheduled (`retry: false`) — that key needs an edit or an
|
|
63
|
+
* explicit `retry(key)`.
|
|
64
|
+
*/
|
|
65
|
+
retryAt: number | undefined;
|
|
66
|
+
}
|
|
67
|
+
/** Options shared by both writer shapes. Every one of them is an opt-*out*. */
|
|
68
|
+
interface WriteBehindBaseOptions<T> {
|
|
69
|
+
/** Flush cadence in ms. Default `1000`. The timer only runs while work is queued. */
|
|
70
|
+
interval?: number;
|
|
71
|
+
/**
|
|
72
|
+
* Per-key quiet period in ms before a key becomes eligible. Default `0`
|
|
73
|
+
* (the `interval` already coalesces a burst of edits into one write).
|
|
74
|
+
* An edit never *shortens* an active backoff.
|
|
75
|
+
*/
|
|
76
|
+
debounce?: number;
|
|
77
|
+
/**
|
|
78
|
+
* Retry policy, or `false` to stop retrying a key after a failure. Retrying
|
|
79
|
+
* is the default because dropping a user's edit is the one unacceptable
|
|
80
|
+
* outcome. With `false` the key stays pending and listed in `failed` — it is
|
|
81
|
+
* never discarded — until the next edit or an explicit `retry(key)`.
|
|
82
|
+
*/
|
|
83
|
+
retry?: WriteBehindRetryOptions | false;
|
|
84
|
+
/**
|
|
85
|
+
* Flush when the tab is hidden (`visibilitychange`). Default `true`.
|
|
86
|
+
* Best-effort only: the browser may kill the page before the request leaves.
|
|
87
|
+
*/
|
|
88
|
+
flushOnHidden?: boolean;
|
|
89
|
+
/**
|
|
90
|
+
* Narrows what the source watcher picks up. An allow-list or a predicate.
|
|
91
|
+
* Default: every key. `set()` is explicit and ignores this filter.
|
|
92
|
+
*/
|
|
93
|
+
keys?: readonly WriteBehindKey[] | ((key: WriteBehindKey) => boolean);
|
|
94
|
+
/**
|
|
95
|
+
* Change detection for a key's value. Default `Object.is`.
|
|
96
|
+
*
|
|
97
|
+
* With the default, mutating an object value **in place** is not an edit —
|
|
98
|
+
* replace the object, or call `set(key, value)`.
|
|
99
|
+
*/
|
|
100
|
+
equals?: (a: T, b: T) => boolean;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Options for `useWriteBehind`. Exactly one writer: `write` (per key) or
|
|
104
|
+
* `flush` (batched).
|
|
105
|
+
*/
|
|
106
|
+
type WriteBehindOptions<T> = WriteBehindBaseOptions<T> & ({
|
|
107
|
+
write: WriteBehindWriter<T>;
|
|
108
|
+
flush?: undefined;
|
|
109
|
+
} | {
|
|
110
|
+
write?: undefined;
|
|
111
|
+
flush: WriteBehindBatchWriter<T>;
|
|
112
|
+
});
|
|
113
|
+
/**
|
|
114
|
+
* The reactive store `useWriteBehind` returns. Read the fields straight in a
|
|
115
|
+
* template; they are recomputed on every state change.
|
|
116
|
+
*/
|
|
117
|
+
interface WriteBehind<T> {
|
|
118
|
+
/**
|
|
119
|
+
* Every key with an unsaved change, **including** the ones currently on the
|
|
120
|
+
* wire. This is the "you have unsaved work" number.
|
|
121
|
+
*/
|
|
122
|
+
readonly pending: readonly WriteBehindKey[];
|
|
123
|
+
/** The subset of `pending` currently in flight. */
|
|
124
|
+
readonly inFlight: readonly WriteBehindKey[];
|
|
125
|
+
/** Latest failure per failing key. */
|
|
126
|
+
readonly failed: readonly WriteBehindFailure[];
|
|
127
|
+
/** `true` while anything is in flight. */
|
|
128
|
+
readonly isSyncing: boolean;
|
|
129
|
+
/**
|
|
130
|
+
* Write a value into the source **and** queue it. Always queues, even when
|
|
131
|
+
* the value is unchanged — the escape hatch for values `equals` cannot see
|
|
132
|
+
* (an object mutated in place) and for keys excluded by `keys`.
|
|
133
|
+
*/
|
|
134
|
+
set: (key: WriteBehindKey, value: T) => void;
|
|
135
|
+
/**
|
|
136
|
+
* Send every pending key that is not already in flight, ignoring the
|
|
137
|
+
* `debounce` and backoff clocks. Resolves when the requests it started have
|
|
138
|
+
* settled — keys edited *during* that flight are still pending afterwards.
|
|
139
|
+
*/
|
|
140
|
+
flush: () => Promise<void>;
|
|
141
|
+
/**
|
|
142
|
+
* Clear the backoff (and the recorded failure) for one key, or all of them,
|
|
143
|
+
* so they go out on the next tick. The only way to revive a key that failed
|
|
144
|
+
* under `retry: false`.
|
|
145
|
+
*/
|
|
146
|
+
retry: (key?: WriteBehindKey) => void;
|
|
147
|
+
/**
|
|
148
|
+
* Drop a key's pending write. **The only operation in this library that
|
|
149
|
+
* loses a write** — nothing else ever discards one.
|
|
150
|
+
*/
|
|
151
|
+
discard: (key: WriteBehindKey) => void;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Write-behind cache for a reactive record. Local state stays authoritative;
|
|
156
|
+
* the writer's result is discarded on purpose.
|
|
157
|
+
*
|
|
158
|
+
* @example
|
|
159
|
+
* ```ts
|
|
160
|
+
* const cells = reactive<Record<string, string>>({ A1: 'foo' })
|
|
161
|
+
* const outbox = useWriteBehind(cells, (value, key) => api.put(`/cell/${key}`, value))
|
|
162
|
+
* cells.A1 = 'bar' // that is the whole API
|
|
163
|
+
* ```
|
|
164
|
+
*
|
|
165
|
+
* @param source A `reactive()` record (or a `ref()` holding one). Its keys must
|
|
166
|
+
* be independent of each other — writes go out in parallel, last-write-wins.
|
|
167
|
+
* @param writerOrOptions The per-key writer, or an options object carrying
|
|
168
|
+
* either `write` (per key) or `flush` (batched).
|
|
169
|
+
*/
|
|
170
|
+
declare function useWriteBehind<T>(source: WriteBehindSource<T>, writerOrOptions: WriteBehindWriter<T> | WriteBehindOptions<T>): WriteBehind<T>;
|
|
171
|
+
|
|
172
|
+
export { type WriteBehind, type WriteBehindBaseOptions, type WriteBehindBatchOutcome, type WriteBehindBatchWriter, type WriteBehindFailure, type WriteBehindKey, type WriteBehindOptions, type WriteBehindRetryOptions, type WriteBehindSource, type WriteBehindWriter, useWriteBehind };
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import{isRef as R,shallowReactive as I,watch as C}from"vue";var D=r=>r?r.failed??[]:[];function g({outbox:r,writer:u,now:e}){let l=new Set,a=f=>{l.add(f),f.then(()=>l.delete(f))},B=async(f,i)=>{try{await f(i.value,i.key),r.settle(i.key,i.sentVersion)}catch(p){r.fail(i.key,i.sentVersion,p,e())}},b=async(f,i)=>{let p=i.map(h=>[h.key,h.value]);try{let h=new Set(D(await f(p)));for(let c of i)h.has(c.key)?r.fail(c.key,c.sentVersion,new Error(`vue-write-behind: batch flush reported "${c.key}" as failed`),e()):r.settle(c.key,c.sentVersion)}catch(h){for(let c of i)r.fail(c.key,c.sentVersion,h,e())}},W=(f=!1)=>{let i=r.take(f?Number.POSITIVE_INFINITY:e());if(i.length!==0)if(u.flush)a(b(u.flush,i));else for(let p of i)a(B(u.write,p))};return{dispatch:W,flush:async()=>{W(!0),await Promise.all([...l])}}}import{getCurrentScope as V,onScopeDispose as L}from"vue";var x=()=>typeof window>"u"||typeof document>"u";function K(r){if(x())return()=>{};let u=()=>{document.visibilityState==="hidden"&&r()};return document.addEventListener("visibilitychange",u),()=>document.removeEventListener("visibilitychange",u)}function A(r){V()&&L(r)}function F({retryDelay:r,onChange:u}){let e=new Map,l=0,a=()=>u?.();return{set:(n,o,t=0)=>{l+=1;let s=e.get(n);s?(s.value=o,s.version=l,s.blocked=!1,s.dueAt=Math.max(s.dueAt,t)):e.set(n,{value:o,version:l,sentVersion:void 0,attempts:0,error:void 0,dueAt:t,blocked:!1}),a()},take:n=>{let o=[];for(let[t,s]of e)s.sentVersion===void 0&&(s.blocked||s.dueAt>n||(s.sentVersion=s.version,o.push({key:t,value:s.value,sentVersion:s.version})));return o.length>0&&a(),o},settle:(n,o)=>{let t=e.get(n);!t||t.sentVersion!==o||(t.version===o?e.delete(n):(t.sentVersion=void 0,t.attempts=0,t.error=void 0,t.dueAt=0,t.blocked=!1),a())},fail:(n,o,t,s)=>{let y=e.get(n);if(!y||y.sentVersion!==o)return;y.sentVersion=void 0,y.attempts+=1,y.error=t;let T=r(y.attempts);T===void 0?y.blocked=!0:y.dueAt=s+T,a()},discard:n=>{e.delete(n)&&a()},clearBackoff:n=>{let o=n===void 0?e.values():[e.get(n)];for(let t of o)t&&(t.attempts=0,t.error=void 0,t.dueAt=0,t.blocked=!1);a()},pendingKeys:()=>[...e.keys()],inFlightKeys:()=>[...e].filter(([,n])=>n.sentVersion!==void 0).map(([n])=>n),failures:()=>{let n=[];for(let[o,t]of e)t.attempts!==0&&n.push({key:o,error:t.error,attempts:t.attempts,retryAt:t.blocked?void 0:t.dueAt});return n},isEmpty:()=>e.size===0,hasScheduledWork:()=>{for(let n of e.values())if(n.sentVersion===void 0&&!n.blocked)return!0;return!1}}}function E(r){if(r===!1)return()=>{};let{initialDelay:u=1e3,maxDelay:e=3e4,factor:l=2}=r??{};return a=>Math.min(u*l**(a-1),e)}function S({interval:r,onTick:u}){let e;return{start:()=>{e===void 0&&(e=setInterval(u,r))},stop:()=>{e!==void 0&&(clearInterval(e),e=void 0)},isRunning:()=>e!==void 0}}var _=1e3;function w(r,u){let e=typeof u=="function"?{write:u}:u,l=e.flush?{flush:e.flush}:{write:e.write},a=e.interval??_,B=e.debounce??0,b=e.equals??Object.is,W=e.keys,f=W===void 0?()=>!0:typeof W=="function"?W:d=>W.includes(d),i=F({retryDelay:E(e.retry),onChange:()=>y()}),p=g({outbox:i,writer:l,now:Date.now}),h=S({interval:a,onTick:()=>p.dispatch()}),c=()=>R(r)?r.value:r,n=new Map;for(let[d,m]of Object.entries(c()))f(d)&&n.set(d,{value:m});let o=()=>{let d=c(),m=B>0?Date.now()+B:0;for(let[v,k]of Object.entries(d)){if(!f(v))continue;let O=n.get(v);O&&b(O.value,k)||(n.set(v,{value:k}),i.set(v,k,m))}for(let v of n.keys())v in d||n.delete(v)},t=I({pending:[],inFlight:[],failed:[],isSyncing:!1,set:(d,m)=>{c()[d]=m,n.set(d,{value:m}),i.set(d,m,B>0?Date.now()+B:0)},flush:()=>(o(),p.flush()),retry:d=>i.clearBackoff(d),discard:d=>i.discard(d)}),s=()=>{let d=i.inFlightKeys();t.pending=i.pendingKeys(),t.inFlight=d,t.failed=i.failures(),t.isSyncing=d.length>0};function y(){s(),!x()&&(i.hasScheduledWork()?h.start():h.stop())}C(c,o,{deep:!0});let T=e.flushOnHidden===!1?()=>{}:K(()=>{t.flush()});return A(()=>{h.stop(),T()}),t}export{w as useWriteBehind};
|
|
2
|
+
//# sourceMappingURL=vueWriteBehind.min.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/useWriteBehind.ts","../src/flush.ts","../src/lifecycle.ts","../src/outbox.ts","../src/scheduler.ts"],"sourcesContent":["/**\n * The Vue surface — watches the source record, mirrors the outbox into a\n * reactive store, and owns when the clock runs.\n *\n * It reads local state and never writes it back: nothing here applies a server\n * response to the source, because the whole point is that the cell the user is\n * typing in cannot be overwritten from the network.\n */\nimport { isRef, shallowReactive, watch } from 'vue'\nimport { createFlusher, type ResolvedWriter } from './flush'\nimport { isServer, onDispose, onTabHidden } from './lifecycle'\nimport { createOutbox } from './outbox'\nimport { createBackoff, createScheduler } from './scheduler'\nimport type {\n WriteBehind,\n WriteBehindKey,\n WriteBehindOptions,\n WriteBehindSource,\n WriteBehindWriter,\n} from './types'\n\nconst DEFAULT_INTERVAL = 1000\n\n/** The store as this module holds it: same shape, writable. */\ntype MutableWriteBehind<T> = { -readonly [K in keyof WriteBehind<T>]: WriteBehind<T>[K] }\n\n/**\n * Write-behind cache for a reactive record. Local state stays authoritative;\n * the writer's result is discarded on purpose.\n *\n * @example\n * ```ts\n * const cells = reactive<Record<string, string>>({ A1: 'foo' })\n * const outbox = useWriteBehind(cells, (value, key) => api.put(`/cell/${key}`, value))\n * cells.A1 = 'bar' // that is the whole API\n * ```\n *\n * @param source A `reactive()` record (or a `ref()` holding one). Its keys must\n * be independent of each other — writes go out in parallel, last-write-wins.\n * @param writerOrOptions The per-key writer, or an options object carrying\n * either `write` (per key) or `flush` (batched).\n */\nexport function useWriteBehind<T>(\n source: WriteBehindSource<T>,\n writerOrOptions: WriteBehindWriter<T> | WriteBehindOptions<T>,\n): WriteBehind<T> {\n const options: WriteBehindOptions<T> =\n typeof writerOrOptions === 'function' ? { write: writerOrOptions } : writerOrOptions\n\n const writer: ResolvedWriter<T> = options.flush\n ? { flush: options.flush }\n : { write: options.write }\n\n const interval = options.interval ?? DEFAULT_INTERVAL\n const debounce = options.debounce ?? 0\n const equals = options.equals ?? Object.is\n const keys = options.keys\n const tracked: (key: WriteBehindKey) => boolean =\n keys === undefined\n ? () => true\n : typeof keys === 'function'\n ? keys\n : (key) => keys.includes(key)\n\n const outbox = createOutbox<T>({\n retryDelay: createBackoff(options.retry),\n onChange: () => onOutboxChange(),\n })\n const flusher = createFlusher<T>({ outbox, writer, now: Date.now })\n const scheduler = createScheduler({ interval, onTick: () => flusher.dispatch() })\n\n const readSource = (): Record<WriteBehindKey, T> => (isRef(source) ? source.value : source)\n\n // Last value seen per key. Boxed so `T` may legitimately be `undefined`\n // without `has`/`get` disagreeing.\n const shadow = new Map<WriteBehindKey, { value: T }>()\n for (const [key, value] of Object.entries(readSource())) {\n if (!tracked(key)) continue\n // Seeded, NOT queued: whatever the record starts with came from the server.\n shadow.set(key, { value })\n }\n\n const syncFromSource = (): void => {\n const current = readSource()\n const dueAt = debounce > 0 ? Date.now() + debounce : 0\n for (const [key, value] of Object.entries(current)) {\n if (!tracked(key)) continue\n const seen = shadow.get(key)\n if (seen && equals(seen.value, value)) continue\n shadow.set(key, { value })\n outbox.set(key, value, dueAt)\n }\n // A key deleted from the source stops being watched, but its queued write\n // survives — losing it silently is the one outcome this library refuses.\n // `discard(key)` is how a consumer drops it on purpose.\n for (const key of shadow.keys()) {\n if (!(key in current)) shadow.delete(key)\n }\n }\n\n const store = shallowReactive<MutableWriteBehind<T>>({\n pending: [],\n inFlight: [],\n failed: [],\n isSyncing: false,\n set: (key, value) => {\n readSource()[key] = value\n shadow.set(key, { value })\n // Unconditional: `set` is the escape hatch for a change `equals` cannot\n // see (an object mutated in place) and for keys `keys` filters out.\n outbox.set(key, value, debounce > 0 ? Date.now() + debounce : 0)\n },\n flush: () => {\n // The source watcher is a `pre` watcher, so an edit made in this tick has\n // not been picked up yet — read it now or a save button next to an input\n // would miss the last keystroke.\n syncFromSource()\n return flusher.flush()\n },\n retry: (key) => outbox.clearBackoff(key),\n discard: (key) => outbox.discard(key),\n })\n\n const syncStore = (): void => {\n const inFlight = outbox.inFlightKeys()\n store.pending = outbox.pendingKeys()\n store.inFlight = inFlight\n store.failed = outbox.failures()\n store.isSyncing = inFlight.length > 0\n }\n\n function onOutboxChange(): void {\n syncStore()\n // Never start a timer on the server, and never leave one running with\n // nothing to send — an idle app must not hold the event loop open.\n if (isServer()) return\n if (outbox.hasScheduledWork()) scheduler.start()\n else scheduler.stop()\n }\n\n watch(readSource, syncFromSource, { deep: true })\n\n const stopHiddenListener =\n options.flushOnHidden === false ? () => {} : onTabHidden(() => void store.flush())\n\n onDispose(() => {\n scheduler.stop()\n stopHiddenListener()\n })\n\n return store\n}\n","/**\n * The writer adapters — the only place a request is made.\n *\n * Two shapes, one rule: whatever the network does, the outcome is reported back\n * to the outbox and nothing else. This module never clears a key itself, never\n * looks at what a writer resolved with, and never touches local state. The\n * server's reply is discarded on purpose.\n *\n * Per-key writes are independent, so they run in parallel and each key settles\n * the moment its own request returns (`Promise.allSettled` semantics — one\n * rejection can neither block nor fail a sibling). That is safe only because\n * key independence is a stated precondition of the library.\n */\nimport type { Outbox, OutboxEntry } from './outbox'\nimport type {\n WriteBehindBatchOutcome,\n WriteBehindBatchWriter,\n WriteBehindKey,\n WriteBehindWriter,\n} from './types'\n\n/** Exactly one writer, already narrowed from the options. */\nexport type ResolvedWriter<T> =\n | { write: WriteBehindWriter<T>; flush?: undefined }\n | { write?: undefined; flush: WriteBehindBatchWriter<T> }\n\nexport interface FlusherConfig<T> {\n outbox: Outbox<T>\n writer: ResolvedWriter<T>\n /** Injected so the flusher owns no clock of its own. */\n now: () => number\n}\n\nexport interface Flusher {\n /** Send everything due. `force` ignores the debounce and backoff clocks. */\n dispatch: (force?: boolean) => void\n /** Force a dispatch and wait for every request currently in the air. */\n flush: () => Promise<void>\n}\n\nconst failedKeysOf = (outcome: WriteBehindBatchOutcome | void): readonly WriteBehindKey[] => {\n if (!outcome) return []\n return outcome.failed ?? []\n}\n\nexport function createFlusher<T>({ outbox, writer, now }: FlusherConfig<T>): Flusher {\n const inAir = new Set<Promise<void>>()\n\n const track = (flight: Promise<void>): void => {\n inAir.add(flight)\n void flight.then(() => inAir.delete(flight))\n }\n\n const runPerKey = async (write: WriteBehindWriter<T>, entry: OutboxEntry<T>): Promise<void> => {\n try {\n await write(entry.value, entry.key)\n outbox.settle(entry.key, entry.sentVersion)\n } catch (error) {\n outbox.fail(entry.key, entry.sentVersion, error, now())\n }\n }\n\n const runBatch = async (\n flushAll: WriteBehindBatchWriter<T>,\n batch: OutboxEntry<T>[],\n ): Promise<void> => {\n const entries = batch.map((entry): [WriteBehindKey, T] => [entry.key, entry.value])\n try {\n const failed = new Set(failedKeysOf(await flushAll(entries)))\n for (const entry of batch) {\n if (failed.has(entry.key)) {\n outbox.fail(\n entry.key,\n entry.sentVersion,\n new Error(`vue-write-behind: batch flush reported \"${entry.key}\" as failed`),\n now(),\n )\n } else {\n outbox.settle(entry.key, entry.sentVersion)\n }\n }\n } catch (error) {\n // One rejection means the transport failed, so nothing in the batch is\n // known to have landed — every key stays pending.\n for (const entry of batch) outbox.fail(entry.key, entry.sentVersion, error, now())\n }\n }\n\n const dispatch = (force = false): void => {\n const batch = outbox.take(force ? Number.POSITIVE_INFINITY : now())\n if (batch.length === 0) return\n // The union guarantees exactly one of the two is present.\n if (writer.flush) track(runBatch(writer.flush, batch))\n else for (const entry of batch) track(runPerKey(writer.write, entry))\n }\n\n return {\n dispatch,\n flush: async () => {\n dispatch(true)\n await Promise.all([...inAir])\n },\n }\n}\n","/**\n * Everything with a lifetime outside the outbox: the environment check, the\n * tab-hidden hook and scope disposal.\n *\n * `visibilitychange` rather than `beforeunload`: mobile browsers routinely\n * discard a page without ever firing `beforeunload`, and Safari fires\n * `pagehide` instead. `visibilitychange → hidden` is the one signal that fires\n * on every platform — and it is still only best-effort, because the page can be\n * frozen before the request leaves. `pending` is exposed so an app can warn.\n */\nimport { getCurrentScope, onScopeDispose } from 'vue'\n\n/** True when there is no DOM — SSR, or a worker. */\nexport const isServer = (): boolean =>\n typeof window === 'undefined' || typeof document === 'undefined'\n\n/** Subscribe to the tab going hidden. Returns the unsubscribe; a no-op on the server. */\nexport function onTabHidden(handler: () => void): () => void {\n if (isServer()) return () => {}\n const listener = (): void => {\n if (document.visibilityState === 'hidden') handler()\n }\n document.addEventListener('visibilitychange', listener)\n return () => document.removeEventListener('visibilitychange', listener)\n}\n\n/**\n * Register cleanup with the surrounding effect scope, if there is one. Called\n * outside `setup()` (imperative code, tests) there is nothing to hook, and Vue\n * would warn — so guard rather than warn. `getCurrentScope()` exists in 3.0;\n * `onScopeDispose`'s `failSilently` argument only arrived in 3.5.\n */\nexport function onDispose(cleanup: () => void): void {\n if (getCurrentScope()) onScopeDispose(cleanup)\n}\n","/**\n * The outbox: the pure key/version/dirty state machine.\n *\n * No Vue, no timers, no I/O — every clock reading arrives as an argument. This\n * is where the whole library's correctness lives, which is why it is testable\n * without mounting anything.\n *\n * **Invariant: this module is the only place a dirty key is ever cleared.**\n * Everything else (scheduler, flush, the composable) can only ask.\n *\n * The rule that makes it correct: a key carries a **monotonic version**, and a\n * write records the version it was sent at. A success clears the key only when\n * the version has not moved since. A boolean dirty flag cannot express that —\n * the response would clear a flag a newer edit had set, and that edit would be\n * gone with nothing on screen to say so.\n */\nimport type { WriteBehindFailure, WriteBehindKey } from './types'\n\n/** One entry handed to a writer. `value` is read out of the map at take time. */\nexport interface OutboxEntry<T> {\n key: WriteBehindKey\n value: T\n /**\n * The version this send is for. Hand it back to `settle`/`fail` — it is the\n * flight's identity, so a response from a superseded flight (the key was\n * discarded and re-queued, or `retry()` re-armed it) is ignored instead of\n * clearing the wrong write.\n */\n sentVersion: number\n}\n\ninterface Entry<T> {\n value: T\n /** Bumped on every edit. Unique across the whole outbox, never reused. */\n version: number\n /** The version currently on the wire, or `undefined` when nothing is. */\n sentVersion: number | undefined\n /** Consecutive failures. */\n attempts: number\n error: unknown\n /** Epoch ms before which the key is not eligible. `0` = eligible now. */\n dueAt: number\n /** Failed with retries disabled: needs a fresh edit or an explicit retry. */\n blocked: boolean\n}\n\nexport interface OutboxConfig {\n /**\n * Backoff for the n-th consecutive failure, in ms. Returning `undefined`\n * blocks the key instead of scheduling an attempt (`retry: false`).\n */\n retryDelay: (attempts: number) => number | undefined\n /** Called after every state transition. */\n onChange?: () => void\n}\n\nexport interface Outbox<T> {\n /**\n * Queue a value. `dueAt` (epoch ms) holds the key back — used for `debounce`.\n * It can only ever push the key further out, never pull an active backoff in.\n */\n set: (key: WriteBehindKey, value: T, dueAt?: number) => void\n /**\n * Claim every key that is due at `now`, not in flight and not blocked,\n * marking each in flight. Pass `Infinity` to ignore the clocks (`flush()`).\n */\n take: (now: number) => OutboxEntry<T>[]\n /** The write landed. Clears the key **only if** its version has not moved. */\n settle: (key: WriteBehindKey, sentVersion: number) => void\n /** The write failed. Never clears the key. */\n fail: (key: WriteBehindKey, sentVersion: number, error: unknown, now: number) => void\n /** Forget a key's pending write entirely — the one operation that loses one. */\n discard: (key: WriteBehindKey) => void\n /** Make a key (or all of them) eligible again and forget its failure. */\n clearBackoff: (key?: WriteBehindKey) => void\n /** Every unconfirmed key, in-flight ones included. */\n pendingKeys: () => WriteBehindKey[]\n inFlightKeys: () => WriteBehindKey[]\n failures: () => WriteBehindFailure[]\n isEmpty: () => boolean\n /** True while some key could still become eligible — i.e. the clock is worth running. */\n hasScheduledWork: () => boolean\n}\n\nexport function createOutbox<T>({ retryDelay, onChange }: OutboxConfig): Outbox<T> {\n const entries = new Map<WriteBehindKey, Entry<T>>()\n // One counter for the whole outbox rather than one per key: versions are then\n // never reused, so a response from a discarded flight can never be mistaken\n // for the current one on a key that was queued again in the meantime.\n let version = 0\n\n const notify = () => onChange?.()\n\n const set = (key: WriteBehindKey, value: T, dueAt = 0): void => {\n version += 1\n const entry = entries.get(key)\n if (entry) {\n entry.value = value\n entry.version = version\n // A new value is a new write, not a retry — it re-arms a blocked key…\n entry.blocked = false\n // …but it must not shorten an active backoff, or a user typing into a\n // failing endpoint would fire one request per keystroke.\n entry.dueAt = Math.max(entry.dueAt, dueAt)\n } else {\n entries.set(key, {\n value,\n version,\n sentVersion: undefined,\n attempts: 0,\n error: undefined,\n dueAt,\n blocked: false,\n })\n }\n notify()\n }\n\n const take = (now: number): OutboxEntry<T>[] => {\n const batch: OutboxEntry<T>[] = []\n for (const [key, entry] of entries) {\n if (entry.sentVersion !== undefined) continue // already on the wire (H5)\n if (entry.blocked) continue\n if (entry.dueAt > now) continue\n entry.sentVersion = entry.version\n batch.push({ key, value: entry.value, sentVersion: entry.version })\n }\n if (batch.length > 0) notify()\n return batch\n }\n\n const settle = (key: WriteBehindKey, sentVersion: number): void => {\n const entry = entries.get(key)\n if (!entry || entry.sentVersion !== sentVersion) return\n if (entry.version === sentVersion) {\n // Nothing was typed while this was in flight: the key is saved.\n entries.delete(key)\n } else {\n // It was. Keep it dirty and send the newer value next tick; the server is\n // evidently healthy, so drop the backoff.\n entry.sentVersion = undefined\n entry.attempts = 0\n entry.error = undefined\n entry.dueAt = 0\n entry.blocked = false\n }\n notify()\n }\n\n const fail = (key: WriteBehindKey, sentVersion: number, error: unknown, now: number): void => {\n const entry = entries.get(key)\n if (!entry || entry.sentVersion !== sentVersion) return\n entry.sentVersion = undefined\n entry.attempts += 1\n entry.error = error\n const delay = retryDelay(entry.attempts)\n if (delay === undefined) entry.blocked = true\n else entry.dueAt = now + delay\n notify()\n }\n\n const discard = (key: WriteBehindKey): void => {\n if (!entries.delete(key)) return\n notify()\n }\n\n const clearBackoff = (key?: WriteBehindKey): void => {\n const targets = key === undefined ? entries.values() : [entries.get(key)]\n for (const entry of targets) {\n if (!entry) continue\n entry.attempts = 0\n entry.error = undefined\n entry.dueAt = 0\n entry.blocked = false\n }\n notify()\n }\n\n const failures = (): WriteBehindFailure[] => {\n const list: WriteBehindFailure[] = []\n for (const [key, entry] of entries) {\n if (entry.attempts === 0) continue\n list.push({\n key,\n error: entry.error,\n attempts: entry.attempts,\n retryAt: entry.blocked ? undefined : entry.dueAt,\n })\n }\n return list\n }\n\n const hasScheduledWork = (): boolean => {\n for (const entry of entries.values()) {\n if (entry.sentVersion === undefined && !entry.blocked) return true\n }\n return false\n }\n\n return {\n set,\n take,\n settle,\n fail,\n discard,\n clearBackoff,\n pendingKeys: () => [...entries.keys()],\n inFlightKeys: () =>\n [...entries].filter(([, entry]) => entry.sentVersion !== undefined).map(([key]) => key),\n failures,\n isEmpty: () => entries.size === 0,\n hasScheduledWork,\n }\n}\n","/**\n * The clock — the flush interval and the per-key backoff curve.\n *\n * Deliberately ignorant of writes and keys: it only knows \"call this every N\n * ms\" and \"how long after the n-th failure\". The decision to run at all belongs\n * to `useWriteBehind`, which starts it when work is queued and stops it when\n * there is none — an idle app must not hold a timer open.\n */\nimport type { WriteBehindRetryOptions } from './types'\n\nconst DEFAULT_INITIAL_DELAY = 1000\nconst DEFAULT_MAX_DELAY = 30000\nconst DEFAULT_FACTOR = 2\n\n/**\n * Backoff for the n-th consecutive failure (1-based), in ms — or `undefined`\n * when retries are off, which parks the key until it is edited or retried.\n *\n * The cap matters: `factor ** attempts` reaches Infinity within a couple of\n * dozen failures, and a key parked at `Infinity` would never be retried, which\n * is exactly the silent write loss this library exists to prevent.\n */\nexport function createBackoff(\n retry: WriteBehindRetryOptions | false | undefined,\n): (attempts: number) => number | undefined {\n if (retry === false) return () => undefined\n const {\n initialDelay = DEFAULT_INITIAL_DELAY,\n maxDelay = DEFAULT_MAX_DELAY,\n factor = DEFAULT_FACTOR,\n } = retry ?? {}\n return (attempts) => Math.min(initialDelay * factor ** (attempts - 1), maxDelay)\n}\n\nexport interface SchedulerConfig {\n interval: number\n onTick: () => void\n}\n\nexport interface Scheduler {\n /** Idempotent: calling it while running keeps the current phase. */\n start: () => void\n stop: () => void\n isRunning: () => boolean\n}\n\nexport function createScheduler({ interval, onTick }: SchedulerConfig): Scheduler {\n let timer: ReturnType<typeof setInterval> | undefined\n\n return {\n start: () => {\n // Restarting would reset the phase, so a fast typist could push the flush\n // out indefinitely — the interval has to be a fixed window, not a debounce.\n if (timer !== undefined) return\n timer = setInterval(onTick, interval)\n },\n stop: () => {\n if (timer === undefined) return\n clearInterval(timer)\n timer = undefined\n },\n isRunning: () => timer !== undefined,\n }\n}\n"],"mappings":"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","names":["isRef","shallowReactive","watch","failedKeysOf","outcome","createFlusher","outbox","writer","now","inAir","track","flight","runPerKey","write","entry","error","runBatch","flushAll","batch","entries","failed","dispatch","force","getCurrentScope","onScopeDispose","isServer","onTabHidden","handler","listener","onDispose","cleanup","createOutbox","retryDelay","onChange","entries","version","notify","key","value","dueAt","entry","now","batch","sentVersion","error","delay","targets","list","createBackoff","retry","initialDelay","maxDelay","factor","attempts","createScheduler","interval","onTick","timer","DEFAULT_INTERVAL","useWriteBehind","source","writerOrOptions","options","writer","interval","debounce","equals","keys","tracked","key","outbox","createOutbox","createBackoff","onOutboxChange","flusher","createFlusher","scheduler","createScheduler","readSource","isRef","shadow","value","syncFromSource","current","dueAt","seen","store","shallowReactive","syncStore","inFlight","isServer","watch","stopHiddenListener","onTabHidden","onDispose"]}
|
package/package.json
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "vue-write-behind",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Write-behind cache for Vue 3 \u2014 local state stays authoritative, the server's reply is discarded on purpose, and a failed save never rolls back or drops what the user typed",
|
|
5
|
+
"author": "Ozgur Seyidoglu",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/ozJSey/vue-write-behind.git"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/ozJSey/vue-write-behind#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/ozJSey/vue-write-behind/issues"
|
|
14
|
+
},
|
|
15
|
+
"type": "module",
|
|
16
|
+
"sideEffects": false,
|
|
17
|
+
"main": "dist/vueWriteBehind.min.cjs",
|
|
18
|
+
"module": "dist/vueWriteBehind.min.js",
|
|
19
|
+
"types": "dist/vueWriteBehind.d.ts",
|
|
20
|
+
"exports": {
|
|
21
|
+
".": {
|
|
22
|
+
"import": {
|
|
23
|
+
"types": "./dist/vueWriteBehind.d.ts",
|
|
24
|
+
"default": "./dist/vueWriteBehind.min.js"
|
|
25
|
+
},
|
|
26
|
+
"require": {
|
|
27
|
+
"types": "./dist/vueWriteBehind.min.d.cts",
|
|
28
|
+
"default": "./dist/vueWriteBehind.min.cjs"
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"dist"
|
|
34
|
+
],
|
|
35
|
+
"scripts": {
|
|
36
|
+
"build": "tsup vueWriteBehind.ts --format esm,cjs --minify --sourcemap --dts --out-dir dist --clean && mv dist/vueWriteBehind.js dist/vueWriteBehind.min.js && mv dist/vueWriteBehind.js.map dist/vueWriteBehind.min.js.map && mv dist/vueWriteBehind.cjs dist/vueWriteBehind.min.cjs && mv dist/vueWriteBehind.cjs.map dist/vueWriteBehind.min.cjs.map && mv dist/vueWriteBehind.d.cts dist/vueWriteBehind.min.d.cts && node -e \"const fs=require('fs');for(const f of ['vueWriteBehind.min.js','vueWriteBehind.min.cjs']){const p='dist/'+f;let s=fs.readFileSync(p,'utf8');s=s.replace(/\\/\\/# sourceMappingURL=vueWriteBehind\\.(js|cjs)\\.map/, '//# sourceMappingURL='+f+'.map');fs.writeFileSync(p,s);}\"",
|
|
37
|
+
"test": "vitest run",
|
|
38
|
+
"check:dist": "npm run build && node dist-check.mjs",
|
|
39
|
+
"check:browser": "npm run build && node scripts/browser-check.mjs",
|
|
40
|
+
"typecheck": "tsc --noEmit -p tsconfig.test.json",
|
|
41
|
+
"prepublishOnly": "npm run build"
|
|
42
|
+
},
|
|
43
|
+
"keywords": [
|
|
44
|
+
"vue",
|
|
45
|
+
"vue3",
|
|
46
|
+
"write-behind",
|
|
47
|
+
"write-back",
|
|
48
|
+
"outbox",
|
|
49
|
+
"debounce-save",
|
|
50
|
+
"autosave",
|
|
51
|
+
"auto-save",
|
|
52
|
+
"batch-save",
|
|
53
|
+
"offline",
|
|
54
|
+
"retry",
|
|
55
|
+
"optimistic",
|
|
56
|
+
"local-first",
|
|
57
|
+
"composable",
|
|
58
|
+
"dirty-tracking",
|
|
59
|
+
"sync"
|
|
60
|
+
],
|
|
61
|
+
"peerDependencies": {
|
|
62
|
+
"vue": "^3.0.0"
|
|
63
|
+
},
|
|
64
|
+
"devDependencies": {
|
|
65
|
+
"jsdom": "^25.0.0",
|
|
66
|
+
"tsup": "^8.5.1",
|
|
67
|
+
"typescript": "^5.5.0",
|
|
68
|
+
"vitest": "^2.0.0",
|
|
69
|
+
"vue": "^3.5.0",
|
|
70
|
+
"vue3_3": "npm:vue@3.3.13"
|
|
71
|
+
}
|
|
72
|
+
}
|