@kernhq/module-inventory 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +7 -5
  2. package/dist/contract/models.d.ts +24 -1
  3. package/dist/contract/models.d.ts.map +1 -1
  4. package/dist/contract/models.js +35 -3
  5. package/dist/contract/models.js.map +1 -1
  6. package/dist/contract/router.d.ts +58 -2
  7. package/dist/contract/router.d.ts.map +1 -1
  8. package/dist/contract/router.js +29 -1
  9. package/dist/contract/router.js.map +1 -1
  10. package/dist/server/router.d.ts +64 -393
  11. package/dist/server/router.d.ts.map +1 -1
  12. package/dist/server/router.js +23 -1
  13. package/dist/server/router.js.map +1 -1
  14. package/dist/server/schema.d.ts.map +1 -1
  15. package/dist/server/schema.js +11 -0
  16. package/dist/server/schema.js.map +1 -1
  17. package/dist/server/services/categories.d.ts +110 -10
  18. package/dist/server/services/categories.d.ts.map +1 -1
  19. package/dist/server/services/categories.js +198 -13
  20. package/dist/server/services/categories.js.map +1 -1
  21. package/migrations/0008_category_order_unique.sql +71 -0
  22. package/migrations/meta/_journal.json +7 -0
  23. package/package.json +4 -3
  24. package/src/client/errors.test.ts +30 -0
  25. package/src/client/errors.ts +31 -3
  26. package/src/client/messages.ts +82 -19
  27. package/src/client/mock.test.ts +71 -1
  28. package/src/client/mock.ts +51 -12
  29. package/src/client/module.ts +19 -1
  30. package/src/client/reorder.test.ts +100 -0
  31. package/src/client/reorder.ts +79 -0
  32. package/src/client/sequence.test.ts +248 -0
  33. package/src/client/sequence.ts +185 -0
  34. package/src/client/settings/CategoriesSettings.svelte +430 -105
  35. package/src/contract/models.ts +36 -3
  36. package/src/contract/router.ts +29 -0
  37. package/src/module.test.ts +23 -0
  38. package/src/server/inventory.int.test.ts +545 -10
  39. package/src/server/migrations.test.ts +140 -2
  40. package/src/server/router.ts +25 -1
  41. package/src/server/schema.ts +11 -0
  42. package/src/server/services/categories.ts +221 -20
@@ -0,0 +1,248 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ consider,
4
+ DRAG_STOPPED,
5
+ finalize,
6
+ move,
7
+ refused,
8
+ reseed,
9
+ type Sequence,
10
+ saved,
11
+ seed,
12
+ start,
13
+ } from './sequence.js'
14
+
15
+ /**
16
+ * The order of events on the categories screen — which is where every defect in it has been.
17
+ *
18
+ * The arithmetic lives in `reorder.ts` and is tested beside it. Nothing here recalculates a list; it
19
+ * asserts *when* a list is adopted, sent, rolled back or spoken, because the four inputs — a drag, a
20
+ * keypress, a server answer and a refetch — arrive in an order nobody controls, and each of the four
21
+ * cases below shipped as a silent wrong answer.
22
+ */
23
+ type Row = { id: string; name: string }
24
+ const list = (...ids: string[]): Row[] => ids.map((id) => ({ id, name: id.toUpperCase() }))
25
+ const ids = (items: readonly Row[]) => items.map((item) => item.id)
26
+
27
+ /** A pointer drag, start to finish, as the library fires it. */
28
+ function pointerDrag(seq: Sequence<Row>, to: readonly Row[], id: string) {
29
+ const considered = consider(seq, to, 'draggedOverIndex')
30
+ return finalize(considered.next, to, id)
31
+ }
32
+
33
+ describe('a keyboard drag ending', () => {
34
+ /**
35
+ * The proved defect: `svelte-dnd-action` ends a keyboard drag with a **`consider`**, not a
36
+ * `finalize`. Enter, Escape, a click elsewhere and the row disappearing all land on the same
37
+ * `handleDrop`, which dispatches `consider` with `dragStopped` and nothing after it.
38
+ *
39
+ * A handler that sets "dragging" on every consider and clears it only on finalize is therefore
40
+ * stuck true from the moment somebody uses the keyboard once — and the seeding effect below is
41
+ * skipped for the rest of the page's life, so the screen silently stops reflecting the database.
42
+ */
43
+ it('leaves nothing in progress, so the list goes on updating', () => {
44
+ let seq = start(list('a', 'b', 'c'))
45
+
46
+ // Enter on a row: the library fires `consider` with `dragStarted`.
47
+ seq = consider(seq, list('a', 'b', 'c'), 'dragStarted').next
48
+ expect(seq.dragging, 'a drag really is in progress now').toBe(true)
49
+
50
+ // One arrow key: `finalize`, per press.
51
+ const step = finalize(seq, list('b', 'a', 'c'), 'a')
52
+ seq = step.next
53
+ expect(step.save, 'and it is worth sending').toEqual(['b', 'a', 'c'])
54
+
55
+ // Enter again, or Escape, or a click elsewhere. This is the last event of the whole gesture.
56
+ seq = consider(seq, list('b', 'a', 'c'), DRAG_STOPPED).next
57
+ expect(seq.dragging, 'the gesture is over').toBe(false)
58
+
59
+ // Which is the thing that actually matters: a later refetch is adopted rather than skipped.
60
+ seq = saved(seq, list('b', 'a', 'c')).next
61
+ seq = seed(seq, list('b', 'a', 'c', 'd'))
62
+ expect(ids(seq.rows), 'somebody added a category in another tab and the screen shows it').toEqual([
63
+ 'b',
64
+ 'a',
65
+ 'c',
66
+ 'd',
67
+ ])
68
+ })
69
+
70
+ /** A drag picked up and put straight down: two considers, no finalize at all, nothing sent. */
71
+ it('sends nothing when it is picked up and put down again', () => {
72
+ let seq = start(list('a', 'b', 'c'))
73
+ seq = consider(seq, list('a', 'b', 'c'), 'dragStarted').next
74
+ const step = consider(seq, list('a', 'b', 'c'), DRAG_STOPPED)
75
+ expect(step.save).toBeNull()
76
+ expect(step.next.dragging).toBe(false)
77
+ expect(ids(step.next.rows)).toEqual(['a', 'b', 'c'])
78
+ expect(
79
+ seed(step.next, list('c', 'b', 'a')).rows.map((r) => r.id),
80
+ 'and seeding works',
81
+ ).toEqual(['c', 'b', 'a'])
82
+ })
83
+ })
84
+
85
+ describe('a refusal the reader can act on', () => {
86
+ /**
87
+ * The proved sequence, from the review:
88
+ *
89
+ * 3. refetch lands mid-save rows = abc live = abcd <- skipped
90
+ * 6. after the invalidation rows = abc live = abcd <- never re-runs
91
+ *
92
+ * The rollback restores the list the server has just refused, and the effect that would replace it
93
+ * is keyed on the query's data — which did not change between the skipped refetch and the one after
94
+ * the invalidation, because it was already the fresh value. So every retry sends the same stale list
95
+ * and earns the same refusal, under a message telling the reader to try again.
96
+ */
97
+ it('re-seeds from what the server has, so the next attempt is a different one', () => {
98
+ let seq = start(list('a', 'b', 'c'))
99
+
100
+ // Somebody drags b in front of a. The write goes.
101
+ const step = pointerDrag(seq, list('b', 'a', 'c'), 'b')
102
+ seq = step.next
103
+ expect(step.save).toEqual(['b', 'a', 'c'])
104
+
105
+ // A refetch lands mid-save carrying a category another tab added. Skipped — correctly, because
106
+ // adopting it would undo the move being written at that moment.
107
+ const live = list('a', 'b', 'c', 'd')
108
+ seq = seed(seq, live)
109
+ expect(ids(seq.rows), 'the optimistic order is still on screen').toEqual(['b', 'a', 'c'])
110
+
111
+ // The server refuses: the list did not name `d`. Rolling back alone leaves `abc`.
112
+ expect(ids(refused(seq).rows), 'which is exactly the list that was just refused').toEqual(['a', 'b', 'c'])
113
+
114
+ // Re-seeding is what makes the next attempt a different one.
115
+ seq = reseed(live)
116
+ expect(ids(seq.rows), 'the workspace as it actually is').toEqual(['a', 'b', 'c', 'd'])
117
+ expect(seq.saving).toBe(false)
118
+ expect(seq.dragging).toBe(false)
119
+ expect(seq.pending).toBeNull()
120
+
121
+ // And the retry now names every live category, which is what the server was asking for.
122
+ const retry = pointerDrag(seq, list('b', 'a', 'c', 'd'), 'b')
123
+ expect(retry.save).toEqual(['b', 'a', 'c', 'd'])
124
+ })
125
+
126
+ it('rolls back to the server, not to the screen, and forgets anything queued behind it', () => {
127
+ let seq = start(list('a', 'b', 'c'))
128
+ seq = move(seq, 'c', -1).next // acb, in flight
129
+ seq = move(seq, 'c', -1).next // cab, coalesced behind it
130
+ expect(ids(seq.rows)).toEqual(['c', 'a', 'b'])
131
+ expect(seq.pending).not.toBeNull()
132
+
133
+ const back = refused(seq)
134
+ expect(ids(back.rows), 'where the server is believed to be').toEqual(['a', 'b', 'c'])
135
+ expect(back.pending, 'the queued move was built on top of a write that never happened').toBeNull()
136
+ expect(back.saving).toBe(false)
137
+ })
138
+ })
139
+
140
+ describe('pressing an arrow key faster than the server answers', () => {
141
+ /**
142
+ * The proved defect: a second press while a save was in flight was **discarded** — the list snapped
143
+ * back to what was on screen and the row announced the position it had not left. Somebody pressing
144
+ * *move down* three times was told three times that nothing had moved, and two of the three presses
145
+ * vanished.
146
+ *
147
+ * Coalesced rather than queued: three presses are one extra request, not three, and a request per
148
+ * keypress would arrive out of order with each one describing a list the next contradicts.
149
+ */
150
+ it('applies every press and announces where the row really is', () => {
151
+ let seq = start(list('a', 'b', 'c', 'd'))
152
+
153
+ const first = move(seq, 'd', -1)
154
+ seq = first.next
155
+ expect(first.save, 'the first press goes at once').toEqual(['a', 'b', 'd', 'c'])
156
+ expect(ids(first.announce!.list), 'and is announced in the list it produced').toEqual([
157
+ 'a',
158
+ 'b',
159
+ 'd',
160
+ 'c',
161
+ ])
162
+
163
+ const second = move(seq, 'd', -1)
164
+ seq = second.next
165
+ expect(second.save, 'the second waits rather than racing the first').toBeNull()
166
+ expect(ids(seq.rows), 'but the row really moved on screen').toEqual(['a', 'd', 'b', 'c'])
167
+ expect(ids(second.announce!.list), 'so the sentence is true').toEqual(['a', 'd', 'b', 'c'])
168
+
169
+ const third = move(seq, 'd', -1)
170
+ seq = third.next
171
+ expect(third.save).toBeNull()
172
+ expect(ids(third.announce!.list)).toEqual(['d', 'a', 'b', 'c'])
173
+
174
+ // The first write answers. The three presses add up to one more request, not two.
175
+ const settled = saved(seq, list('a', 'b', 'd', 'c'))
176
+ seq = settled.next
177
+ expect(settled.save, 'everything pressed since, in one list').toEqual(['d', 'a', 'b', 'c'])
178
+ expect(seq.saving, 'still writing, so a fourth press coalesces too').toBe(true)
179
+ expect(ids(seq.rows), 'and the screen is never rewound past what was pressed').toEqual([
180
+ 'd',
181
+ 'a',
182
+ 'b',
183
+ 'c',
184
+ ])
185
+
186
+ const done = saved(seq, list('d', 'a', 'b', 'c'))
187
+ expect(done.save, 'nothing left over').toBeNull()
188
+ expect(done.next.saving).toBe(false)
189
+ expect(done.next.pending).toBeNull()
190
+ })
191
+
192
+ it('sends nothing more when the coalesced moves add up to where the server already is', () => {
193
+ let seq = start(list('a', 'b'))
194
+ seq = move(seq, 'a', 1).next // ba, in flight
195
+ seq = move(seq, 'a', -1).next // back to ab, coalesced
196
+ const settled = saved(seq, list('b', 'a'))
197
+ expect(settled.save, 'ab is not where the server is, so it does go').toEqual(['a', 'b'])
198
+
199
+ let other = start(list('a', 'b'))
200
+ other = move(other, 'a', 1).next
201
+ other = move(other, 'a', -1).next
202
+ other = move(other, 'a', 1).next
203
+ const same = saved(other, list('b', 'a'))
204
+ expect(same.save, 'and ba is exactly what was written, so nothing more goes').toBeNull()
205
+ expect(same.next.saving).toBe(false)
206
+ })
207
+
208
+ it('says where a row is when it cannot move, rather than saying nothing', () => {
209
+ const seq = start(list('a', 'b', 'c'))
210
+ const step = move(seq, 'a', -1)
211
+ expect(step.save, 'the first row cannot go up, and that is not a write').toBeNull()
212
+ expect(step.announce, 'silence reads as a broken button').not.toBeNull()
213
+ expect(step.announce?.id).toBe('a')
214
+ expect(ids(step.announce!.list)).toEqual(['a', 'b', 'c'])
215
+ })
216
+ })
217
+
218
+ describe('seeding from the query', () => {
219
+ it('adopts what a refetch delivers when nothing is in progress', () => {
220
+ const seq = seed(start(list('a', 'b')), list('a', 'b', 'c'))
221
+ expect(ids(seq.rows)).toEqual(['a', 'b', 'c'])
222
+ expect(ids(seq.settled)).toEqual(['a', 'b', 'c'])
223
+ expect(ids(seq.shown)).toEqual(['a', 'b', 'c'])
224
+ })
225
+
226
+ it('leaves the list alone under a drag, so it does not move under the pointer', () => {
227
+ const dragging = consider(start(list('a', 'b', 'c')), list('b', 'a', 'c'), 'draggedOverIndex').next
228
+ expect(ids(seed(dragging, list('a', 'b', 'c', 'd')).rows)).toEqual(['b', 'a', 'c'])
229
+ })
230
+
231
+ it('leaves it alone under a save, so the screen never disagrees with the write in flight', () => {
232
+ const saving = move(start(list('a', 'b', 'c')), 'c', -1).next
233
+ expect(ids(seed(saving, list('a', 'b', 'c')).rows)).toEqual(['a', 'c', 'b'])
234
+ })
235
+ })
236
+
237
+ describe('a drag that ends where it started', () => {
238
+ it('costs no request, because the library fires finalize either way', () => {
239
+ const seq = start(list('a', 'b', 'c'))
240
+ const step = pointerDrag(seq, list('a', 'b', 'c'), 'b')
241
+ expect(
242
+ step.save,
243
+ 'every open screen in the workspace would hear about a write that did nothing',
244
+ ).toBeNull()
245
+ expect(step.next.dragging).toBe(false)
246
+ expect(step.announce?.id, 'and the row still says where it is').toBe('b')
247
+ })
248
+ })
@@ -0,0 +1,185 @@
1
+ /**
2
+ * The categories screen's drag, its two arrow buttons and the one save behind both — as a value.
3
+ *
4
+ * In its own file for the reason `reorder.ts`, `custody.ts` and `price.ts` are: a `.svelte` file
5
+ * cannot be unit-tested here, so anything living inside one is only ever checked by reading it. This
6
+ * is not arithmetic, though. It is a small state machine with four inputs that arrive in an order
7
+ * nobody controls — a drag, a keypress, a server answer and a refetch — and every defect it has had
8
+ * was an *ordering*, not a calculation. Those are three assertions each rather than three careful
9
+ * reads.
10
+ *
11
+ * Three snapshots, and they are only equal when nothing is in flight:
12
+ *
13
+ * - `rows` is what the list renders.
14
+ * - `shown` is what the screen was showing before the gesture in progress, which while a save is
15
+ * in flight is the optimistic order and **not** what the server holds. A blocked move rolls back
16
+ * to this, so the move being written at that moment survives.
17
+ * - `settled` is what the **server** is believed to hold, and it is what a refusal rolls back to.
18
+ *
19
+ * Every transition is pure and returns the work the caller owes: a list to post, a row to speak
20
+ * about, or neither. The screen does the posting and the speaking; nothing here knows what a request
21
+ * or a live region is.
22
+ */
23
+
24
+ import { moveBy, type Ordered, sameOrder } from './reorder.js'
25
+
26
+ /**
27
+ * The trigger `svelte-dnd-action` puts on the `consider` it fires when a **keyboard** drag ends.
28
+ *
29
+ * The library's two routes do not have the same shape, and reading only the pointer one is what left
30
+ * this screen permanently stale. A pointer drag is `consider`…`consider`…`finalize`. A keyboard drag
31
+ * is `consider` (`dragStarted`), then one `finalize` per arrow key, and then — on Enter, on Escape,
32
+ * on a click elsewhere, on the row being removed — a **`consider`** carrying this trigger. So the
33
+ * last event of a keyboard drag is a consider, and a handler that sets "dragging" on every consider
34
+ * and clears it only on finalize is stuck true from that moment on. Whatever the flag then guards is
35
+ * guarded for ever: here it was the seeding effect, so the screen quietly stopped showing what the
36
+ * database held, with no error anywhere.
37
+ */
38
+ export const DRAG_STOPPED = 'dragStopped'
39
+
40
+ export interface Sequence<T extends Ordered> {
41
+ /** What the list renders. */
42
+ rows: readonly T[]
43
+ /** What the server is believed to hold — where a refusal rolls back to. */
44
+ settled: readonly T[]
45
+ /** What the screen showed before the gesture in progress. */
46
+ shown: readonly T[]
47
+ /** A drag is in progress, so a refetch must not pull the list out from under the pointer. */
48
+ dragging: boolean
49
+ /** A save is in flight. Set in the same tick as the gesture; a disabled button is a render late. */
50
+ saving: boolean
51
+ /** Moves made while that save was in flight, coalesced into the one list they add up to. */
52
+ pending: readonly T[] | null
53
+ }
54
+
55
+ /** A transition, and the work it leaves the screen to do. */
56
+ export interface Step<T extends Ordered> {
57
+ next: Sequence<T>
58
+ /** The ids to post, in order. Null when nothing needs writing. */
59
+ save: readonly string[] | null
60
+ /** The row to speak about and the list that now holds it. Null when there is nothing to say. */
61
+ announce: { id: string; list: readonly T[] } | null
62
+ }
63
+
64
+ export function start<T extends Ordered>(rows: readonly T[] = []): Sequence<T> {
65
+ return { rows, settled: rows, shown: rows, dragging: false, saving: false, pending: null }
66
+ }
67
+
68
+ /**
69
+ * Adopt what a query just delivered — unless a gesture or a write is in the middle of something.
70
+ *
71
+ * Seeding under a drag pulls the list out from under the pointer, and seeding under a save replaces
72
+ * the optimistic order with data the write has not reached yet, so the screen disagrees with the
73
+ * change it is at that moment making.
74
+ *
75
+ * **A skipped seed is dropped, not queued, and that is only safe because of `reseed`.** A refetch
76
+ * landing mid-save is skipped here; on success the server's own answer is fresher than anything the
77
+ * query could hold, and on a refusal `reseed` takes what the server actually has. Without that second
78
+ * half this is the whole defect: skip one refetch, refuse the save, and the screen is left holding a
79
+ * list the server has already rejected once, with no route back to a fresh one.
80
+ */
81
+ export function seed<T extends Ordered>(seq: Sequence<T>, live: readonly T[]): Sequence<T> {
82
+ if (seq.dragging || seq.saving) return seq
83
+ return { ...seq, rows: live, settled: live, shown: live }
84
+ }
85
+
86
+ /**
87
+ * Take what the server actually has, whatever this screen was in the middle of.
88
+ *
89
+ * What makes an `order_stale` refusal recoverable. The rollback alone is not enough and reads as
90
+ * though it were: it restores the same list the server has just refused, so every retry sends it
91
+ * again and is refused again, under a message telling the reader to try once more. The seeding effect
92
+ * cannot rescue it either — it is keyed on the query's data, and the refetch that follows the refusal
93
+ * returns the value it already skipped, which is not a change and so does not re-run anything.
94
+ *
95
+ * The same thing `start` does, and named separately because the call site is where it has to be
96
+ * obvious that the previous state is being thrown away on purpose rather than merged into.
97
+ */
98
+ export function reseed<T extends Ordered>(live: readonly T[]): Sequence<T> {
99
+ return start(live)
100
+ }
101
+
102
+ /**
103
+ * The drag library considering a position — or, on `dragStopped`, telling us the keyboard drag ended.
104
+ *
105
+ * The trigger is the whole point. See `DRAG_STOPPED`.
106
+ */
107
+ export function consider<T extends Ordered>(seq: Sequence<T>, items: readonly T[], trigger: string): Step<T> {
108
+ if (trigger === DRAG_STOPPED)
109
+ return { next: { ...seq, dragging: false, rows: seq.shown }, save: null, announce: null }
110
+ return { next: { ...seq, dragging: true, rows: items }, save: null, announce: null }
111
+ }
112
+
113
+ /** A drop, from either route: the arrangement is final, so decide whether it is worth sending. */
114
+ export function finalize<T extends Ordered>(seq: Sequence<T>, items: readonly T[], id: string): Step<T> {
115
+ return apply({ ...seq, dragging: false }, items, id)
116
+ }
117
+
118
+ /** *Move up* or *move down* on one row, which must produce exactly what the same drag would. */
119
+ export function move<T extends Ordered>(seq: Sequence<T>, id: string, delta: number): Step<T> {
120
+ return apply(seq, moveBy(seq.rows, id, delta), id)
121
+ }
122
+
123
+ /**
124
+ * Take an arrangement, decide whether it is worth sending, and say where the row ended up.
125
+ *
126
+ * One function for both routes, because a drag and a keypress that produce the same list must produce
127
+ * the same request, the same optimistic update and the same sentence. Three outcomes:
128
+ *
129
+ * - **nothing actually moved**: a drag that ended where it started fires `finalize` exactly as a
130
+ * real one does, and *move up* on the first row is a button somebody will press. Neither is a
131
+ * write. Both still get an answer, because silence reads as a broken button.
132
+ * - **a save already in flight**: the move is applied on screen and **coalesced** into `pending`,
133
+ * which is posted the moment the current write answers. It used to be discarded — the list
134
+ * snapped back to `shown` and the row announced the position it had not left, so somebody
135
+ * pressing *move down* three times was told three times that nothing had moved, while the second
136
+ * and third presses vanished. A write per keypress is the other wrong answer: they arrive out of
137
+ * order and each one describes a list the next one contradicts.
138
+ * - **a real move with nothing in flight**: optimistic, announced at once, posted, and rolled back
139
+ * by `refused` if the server says no.
140
+ *
141
+ * Every one of the three announces, and every announcement is now true — which is the point of
142
+ * coalescing rather than dropping.
143
+ */
144
+ function apply<T extends Ordered>(seq: Sequence<T>, next: readonly T[], id: string): Step<T> {
145
+ if (sameOrder(next, seq.shown))
146
+ return { next: { ...seq, rows: seq.shown }, save: null, announce: { id, list: seq.shown } }
147
+
148
+ const rows = [...next]
149
+ const moved = { ...seq, rows, shown: rows }
150
+ if (seq.saving) return { next: { ...moved, pending: rows }, save: null, announce: { id, list: rows } }
151
+ return { next: { ...moved, saving: true }, save: rows.map((row) => row.id), announce: { id, list: rows } }
152
+ }
153
+
154
+ /**
155
+ * The server wrote it, and answered the sequence it wrote.
156
+ *
157
+ * Nothing to guess and no flash: the optimistic list is replaced by the same list. When moves were
158
+ * coalesced while this one was in flight, they go now — and `rows` is deliberately *not* rewound to
159
+ * the server's answer first, because that answer is one move behind what the person is looking at.
160
+ * `saving` stays set across that hand-off, so a third move still coalesces rather than racing.
161
+ */
162
+ export function saved<T extends Ordered>(seq: Sequence<T>, server: readonly T[]): Step<T> {
163
+ const queued = seq.pending
164
+ if (queued && !sameOrder(queued, server))
165
+ return {
166
+ next: { ...seq, settled: server, pending: null },
167
+ save: queued.map((row) => row.id),
168
+ announce: null,
169
+ }
170
+ return {
171
+ next: { rows: server, settled: server, shown: server, dragging: false, saving: false, pending: null },
172
+ save: null,
173
+ announce: null,
174
+ }
175
+ }
176
+
177
+ /**
178
+ * The server refused it. Put the list back where the server is believed to be, and drop the queue.
179
+ *
180
+ * Anything coalesced behind the refused write was built on top of it, so it describes an arrangement
181
+ * that never existed. Sending it next would be refused for the same reason, or — worse — accepted.
182
+ */
183
+ export function refused<T extends Ordered>(seq: Sequence<T>): Sequence<T> {
184
+ return { ...seq, rows: seq.settled, shown: seq.settled, dragging: false, saving: false, pending: null }
185
+ }