@ultimabase/realtime 0.0.0-stage → 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/CHANGELOG.md +12 -0
- package/README.md +4 -2
- package/dist/affected.d.ts +29 -0
- package/dist/affected.d.ts.map +1 -0
- package/dist/affected.js +89 -0
- package/dist/changes.d.ts +22 -0
- package/dist/changes.d.ts.map +1 -0
- package/dist/changes.js +64 -0
- package/dist/checked.d.ts +10 -0
- package/dist/checked.d.ts.map +1 -0
- package/dist/checked.js +46 -0
- package/dist/computed.d.ts +34 -0
- package/dist/computed.d.ts.map +1 -0
- package/dist/computed.js +118 -0
- package/dist/engine.d.ts +13 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +99 -0
- package/dist/index.d.ts +40 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +52 -0
- package/dist/live.d.ts +37 -0
- package/dist/live.d.ts.map +1 -0
- package/dist/live.js +309 -0
- package/dist/messages.d.ts +42 -0
- package/dist/messages.d.ts.map +1 -0
- package/dist/messages.js +1 -0
- package/dist/reach.d.ts +57 -0
- package/dist/reach.d.ts.map +1 -0
- package/dist/reach.js +154 -0
- package/dist/reads.d.ts +21 -0
- package/dist/reads.d.ts.map +1 -0
- package/dist/reads.js +73 -0
- package/dist/shape.d.ts +16 -0
- package/dist/shape.d.ts.map +1 -0
- package/dist/shape.js +71 -0
- package/dist/stale.d.ts +5 -0
- package/dist/stale.d.ts.map +1 -0
- package/dist/stale.js +57 -0
- package/dist/tracking.d.ts +12 -0
- package/dist/tracking.d.ts.map +1 -0
- package/dist/tracking.js +48 -0
- package/dist/viewer.d.ts +63 -0
- package/dist/viewer.d.ts.map +1 -0
- package/dist/viewer.js +276 -0
- package/dist/window.d.ts +43 -0
- package/dist/window.d.ts.map +1 -0
- package/dist/window.js +125 -0
- package/package.json +42 -3
- package/src/affected.ts +118 -0
- package/src/changes.ts +107 -0
- package/src/checked.ts +59 -0
- package/src/computed.ts +141 -0
- package/src/engine.ts +122 -0
- package/src/index.ts +100 -0
- package/src/live.ts +384 -0
- package/src/messages.ts +39 -0
- package/src/reach.ts +251 -0
- package/src/reads.ts +103 -0
- package/src/shape.ts +86 -0
- package/src/stale.ts +68 -0
- package/src/tracking.ts +61 -0
- package/src/viewer.ts +389 -0
- package/src/window.ts +153 -0
package/src/viewer.ts
ADDED
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
// A viewer (D-154): one connection's caller and its live reads. The reads
|
|
2
|
+
// that built the caller and planned each live read are kept; a write that
|
|
3
|
+
// may change one rebuilds the caller, which resyncs the reads whose plan
|
|
4
|
+
// changed and closes them all when it is no longer the same user. A caller
|
|
5
|
+
// that stops holding at a time is built again then. A live function runs
|
|
6
|
+
// again as the rebuilt caller.
|
|
7
|
+
import type { Caller, ExposedFunction, ExposedModel, Logger } from 'ultimabase'
|
|
8
|
+
import type { Recorded } from 'ultimabase/internal'
|
|
9
|
+
import { livePlan, recording } from 'ultimabase/internal'
|
|
10
|
+
import type { Batch } from './changes.ts'
|
|
11
|
+
import { checked, invalid } from './checked.ts'
|
|
12
|
+
import { Computed } from './computed.ts'
|
|
13
|
+
import type { LiveMessage } from './messages.ts'
|
|
14
|
+
import { Live } from './live.ts'
|
|
15
|
+
import type { Loader } from './reads.ts'
|
|
16
|
+
import { liveArgs, shapeOf } from './shape.ts'
|
|
17
|
+
import { anyStale } from './stale.ts'
|
|
18
|
+
|
|
19
|
+
export type Settings = {
|
|
20
|
+
readonly models: readonly ExposedModel<{ readonly maxLive?: number }>[]
|
|
21
|
+
readonly functions: readonly ExposedFunction<{ readonly debounce?: number }>[]
|
|
22
|
+
readonly maxLive: number
|
|
23
|
+
readonly maxSubscriptions: number
|
|
24
|
+
readonly maxConnectionRows: number
|
|
25
|
+
readonly identify: (request: Request) => Promise<Caller>
|
|
26
|
+
readonly logger: Logger
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A live read's handle. */
|
|
30
|
+
export interface LiveHandle {
|
|
31
|
+
/** Shows `more` rows after the last; a list over its limit is refused. */
|
|
32
|
+
grow(this: void, more: number): Promise<void>
|
|
33
|
+
/** Sends the whole window again, as after messages were dropped. */
|
|
34
|
+
resend(this: void): Promise<void>
|
|
35
|
+
close(this: void): void
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
type Subscription = {
|
|
39
|
+
readonly model: string
|
|
40
|
+
readonly args: unknown
|
|
41
|
+
readonly row: boolean
|
|
42
|
+
readonly send: (message: LiveMessage) => void
|
|
43
|
+
/** The rows it follows, nested rows included. */
|
|
44
|
+
rows: number
|
|
45
|
+
reads: readonly Recorded[]
|
|
46
|
+
expires: Date | undefined
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
type Built<R> = Awaited<ReturnType<typeof recording<R>>>
|
|
50
|
+
|
|
51
|
+
/** The least wait before a rebuild, so a past expiry is no hot loop. */
|
|
52
|
+
const soonest = 1000
|
|
53
|
+
/** The longest wait `setTimeout` keeps. */
|
|
54
|
+
const latest = 2 ** 31 - 1
|
|
55
|
+
|
|
56
|
+
/** A function's name, with its plugin's for a plugin's (D-113). */
|
|
57
|
+
export const ownerOf = (fn: ExposedFunction): string =>
|
|
58
|
+
fn.plugin === undefined ? fn.name : `${fn.plugin}.${fn.name}`
|
|
59
|
+
|
|
60
|
+
/** How long a live function's wake waits for quiet, in milliseconds. */
|
|
61
|
+
const debounce = 50
|
|
62
|
+
|
|
63
|
+
/** Who `caller` is, which a rebuilt caller must keep. */
|
|
64
|
+
const whoOf = (caller: Caller): string => {
|
|
65
|
+
if (caller.kind !== 'user') return JSON.stringify(caller)
|
|
66
|
+
const id: unknown = Reflect.get(caller.user, 'id')
|
|
67
|
+
return JSON.stringify(['user', id])
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export class Viewer {
|
|
71
|
+
readonly #settings: Settings
|
|
72
|
+
#request: Request
|
|
73
|
+
readonly #onClose: () => void
|
|
74
|
+
readonly #onDue: () => void
|
|
75
|
+
readonly #live = new Map<Live, Subscription>()
|
|
76
|
+
readonly #computed = new Map<Computed, (message: LiveMessage) => void>()
|
|
77
|
+
#caller: Caller
|
|
78
|
+
#reads: readonly Recorded[]
|
|
79
|
+
#expires: Date | undefined
|
|
80
|
+
#due: NodeJS.Timeout | undefined
|
|
81
|
+
#closed = false
|
|
82
|
+
|
|
83
|
+
/** `onDue` runs once the caller stops holding, to rebuild it. */
|
|
84
|
+
constructor(
|
|
85
|
+
settings: Settings,
|
|
86
|
+
request: Request,
|
|
87
|
+
identified: Built<Caller>,
|
|
88
|
+
hooks: { readonly onClose: () => void; readonly onDue: () => void },
|
|
89
|
+
) {
|
|
90
|
+
this.#settings = settings
|
|
91
|
+
this.#request = request
|
|
92
|
+
this.#caller = identified.result
|
|
93
|
+
this.#reads = identified.reads
|
|
94
|
+
this.#expires = identified.expires
|
|
95
|
+
this.#onClose = hooks.onClose
|
|
96
|
+
this.#onDue = hooks.onDue
|
|
97
|
+
this.#reschedule()
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The caller the live reads run as, kept current by each rebuild. */
|
|
101
|
+
get caller(): Caller {
|
|
102
|
+
return this.#caller
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
get closed(): boolean {
|
|
106
|
+
return this.#closed
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** A live list of `model`'s rows, from the top of its order. */
|
|
110
|
+
async list(
|
|
111
|
+
model: string,
|
|
112
|
+
args: unknown,
|
|
113
|
+
send: (message: LiveMessage) => void,
|
|
114
|
+
): Promise<LiveHandle> {
|
|
115
|
+
return this.#follow(model, args, false, send)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** A live row of `model`, by a unique `where`. */
|
|
119
|
+
async row(
|
|
120
|
+
model: string,
|
|
121
|
+
args: unknown,
|
|
122
|
+
send: (message: LiveMessage) => void,
|
|
123
|
+
): Promise<LiveHandle> {
|
|
124
|
+
return this.#follow(model, args, true, send)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* A live function's result, `compute` as the caller, run again once a
|
|
129
|
+
* write may change what it read.
|
|
130
|
+
*/
|
|
131
|
+
async value(
|
|
132
|
+
name: string,
|
|
133
|
+
compute: (caller: Caller) => Promise<unknown>,
|
|
134
|
+
send: (message: LiveMessage) => void,
|
|
135
|
+
): Promise<LiveHandle> {
|
|
136
|
+
if (this.#closed) throw new Error('The viewer is closed.')
|
|
137
|
+
const exposed = this.#settings.functions.find((fn) => ownerOf(fn) === name)
|
|
138
|
+
if (!exposed?.used) throw invalid('function', `${name} is not live.`)
|
|
139
|
+
this.#holds()
|
|
140
|
+
const computed = new Computed(
|
|
141
|
+
{
|
|
142
|
+
compute,
|
|
143
|
+
send,
|
|
144
|
+
debounce: exposed.with?.debounce ?? debounce,
|
|
145
|
+
onRun: () => this.#reschedule(),
|
|
146
|
+
onFail: () => this.#computed.delete(computed),
|
|
147
|
+
},
|
|
148
|
+
this.#caller,
|
|
149
|
+
)
|
|
150
|
+
this.#computed.set(computed, send)
|
|
151
|
+
try {
|
|
152
|
+
await computed.start()
|
|
153
|
+
} catch (error) {
|
|
154
|
+
this.#computed.delete(computed)
|
|
155
|
+
throw error
|
|
156
|
+
}
|
|
157
|
+
return {
|
|
158
|
+
grow: async () => {
|
|
159
|
+
throw invalid('first', `${name} is no live list.`)
|
|
160
|
+
},
|
|
161
|
+
resend: async () => computed.resend(),
|
|
162
|
+
close: () => {
|
|
163
|
+
computed.close()
|
|
164
|
+
this.#computed.delete(computed)
|
|
165
|
+
},
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
close(): void {
|
|
170
|
+
if (this.#closed) return
|
|
171
|
+
this.#closed = true
|
|
172
|
+
clearTimeout(this.#due)
|
|
173
|
+
for (const live of this.#live.keys()) live.close()
|
|
174
|
+
this.#live.clear()
|
|
175
|
+
for (const computed of this.#computed.keys()) computed.close()
|
|
176
|
+
this.#computed.clear()
|
|
177
|
+
this.#onClose()
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Follows `batch`: rebuilds the caller first when a write may change it. */
|
|
181
|
+
async flush(batch: Batch, loader: Loader, missed: boolean): Promise<void> {
|
|
182
|
+
if (this.#closed) return
|
|
183
|
+
const reads = [
|
|
184
|
+
...this.#reads,
|
|
185
|
+
...[...this.#live.values()].flatMap((subscription) => subscription.reads),
|
|
186
|
+
]
|
|
187
|
+
if (missed || (await anyStale(reads, batch, loader))) {
|
|
188
|
+
await this.rebuild()
|
|
189
|
+
}
|
|
190
|
+
const lives = [...this.#live.keys(), ...this.#computed.keys()]
|
|
191
|
+
await Promise.all(
|
|
192
|
+
lives.map(async (live) => live.apply(batch, loader, missed)),
|
|
193
|
+
)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Follows `request`'s caller from now on, built again at once; whether
|
|
198
|
+
* it is the same user, as a closed viewer is not.
|
|
199
|
+
*/
|
|
200
|
+
async adopt(request: Request): Promise<boolean> {
|
|
201
|
+
this.#request = request
|
|
202
|
+
await this.rebuild()
|
|
203
|
+
return !this.#closed
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Builds the caller again, then each live read's plan. */
|
|
207
|
+
async rebuild(): Promise<void> {
|
|
208
|
+
if (this.#closed) return
|
|
209
|
+
let identified: Built<Caller>
|
|
210
|
+
try {
|
|
211
|
+
identified = await recording(async () =>
|
|
212
|
+
this.#settings.identify(this.#request),
|
|
213
|
+
)
|
|
214
|
+
} catch {
|
|
215
|
+
return this.end()
|
|
216
|
+
}
|
|
217
|
+
const caller = identified.result
|
|
218
|
+
if (whoOf(caller) !== whoOf(this.#caller)) return this.end()
|
|
219
|
+
this.#caller = caller
|
|
220
|
+
this.#reads = identified.reads
|
|
221
|
+
this.#expires = identified.expires
|
|
222
|
+
await Promise.all(
|
|
223
|
+
[...this.#live].map(async ([live, subscription]) => {
|
|
224
|
+
try {
|
|
225
|
+
const { model, args, row } = subscription
|
|
226
|
+
const planned = await recording(async () =>
|
|
227
|
+
livePlan(model, liveArgs(args, row), caller, row),
|
|
228
|
+
)
|
|
229
|
+
subscription.reads = planned.reads
|
|
230
|
+
subscription.expires = planned.expires
|
|
231
|
+
await live.replan(caller, planned.result)
|
|
232
|
+
} catch (error) {
|
|
233
|
+
this.#settings.logger.error(
|
|
234
|
+
{ err: error },
|
|
235
|
+
`A live read of ${subscription.model} cannot be planned: it closes.`,
|
|
236
|
+
)
|
|
237
|
+
this.#drop(live)
|
|
238
|
+
}
|
|
239
|
+
}),
|
|
240
|
+
)
|
|
241
|
+
await Promise.all(
|
|
242
|
+
[...this.#computed.keys()].map(async (computed) =>
|
|
243
|
+
computed.replan(caller),
|
|
244
|
+
),
|
|
245
|
+
)
|
|
246
|
+
this.#reschedule()
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Waits for the earliest time the caller or a live read stops holding. */
|
|
250
|
+
#reschedule(): void {
|
|
251
|
+
clearTimeout(this.#due)
|
|
252
|
+
this.#due = undefined
|
|
253
|
+
if (this.#closed) return
|
|
254
|
+
const subscriptions = [...this.#live.values(), ...this.#computed.keys()]
|
|
255
|
+
const times = [
|
|
256
|
+
this.#expires,
|
|
257
|
+
...subscriptions.map(({ expires }) => expires),
|
|
258
|
+
]
|
|
259
|
+
.filter((at) => at !== undefined)
|
|
260
|
+
.map((at) => at.getTime())
|
|
261
|
+
if (times.length === 0) return
|
|
262
|
+
const wait = Math.min(
|
|
263
|
+
Math.max(Math.min(...times) - Date.now(), soonest),
|
|
264
|
+
latest,
|
|
265
|
+
)
|
|
266
|
+
this.#due = setTimeout(this.#onDue, wait)
|
|
267
|
+
this.#due.unref()
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
#drop(live: Live): void {
|
|
271
|
+
const subscription = this.#live.get(live)
|
|
272
|
+
live.close()
|
|
273
|
+
this.#live.delete(live)
|
|
274
|
+
subscription?.send({ kind: 'closed' })
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Closes every live read, telling each. */
|
|
278
|
+
end(): void {
|
|
279
|
+
const sends = [
|
|
280
|
+
...[...this.#live.values()].map(({ send }) => send),
|
|
281
|
+
...this.#computed.values(),
|
|
282
|
+
]
|
|
283
|
+
this.close()
|
|
284
|
+
for (const send of sends) send({ kind: 'closed' })
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async #follow(
|
|
288
|
+
model: string,
|
|
289
|
+
args: unknown,
|
|
290
|
+
row: boolean,
|
|
291
|
+
send: (message: LiveMessage) => void,
|
|
292
|
+
): Promise<LiveHandle> {
|
|
293
|
+
if (this.#closed) throw new Error('The viewer is closed.')
|
|
294
|
+
const exposed = this.#settings.models.find(
|
|
295
|
+
({ schema }) => schema.name === model,
|
|
296
|
+
)
|
|
297
|
+
if (!exposed?.used) throw invalid('model', `${model} is not live.`)
|
|
298
|
+
this.#holds()
|
|
299
|
+
const { first, where, totalCount, shape } = shapeOf(args, row)
|
|
300
|
+
const caller = this.#caller
|
|
301
|
+
const planned = await recording(async () =>
|
|
302
|
+
livePlan(model, liveArgs(args, row), caller, row),
|
|
303
|
+
)
|
|
304
|
+
const limit = exposed.with?.maxLive ?? this.#settings.maxLive
|
|
305
|
+
const nested = checked(
|
|
306
|
+
this.#settings.models,
|
|
307
|
+
model,
|
|
308
|
+
planned.result,
|
|
309
|
+
'include',
|
|
310
|
+
)
|
|
311
|
+
if (first * (1 + nested) > limit) {
|
|
312
|
+
throw invalid(
|
|
313
|
+
'first',
|
|
314
|
+
`first is over ${limit}, the most a live list follows.`,
|
|
315
|
+
)
|
|
316
|
+
}
|
|
317
|
+
this.#fits(first * (1 + nested))
|
|
318
|
+
const live = new Live(
|
|
319
|
+
{
|
|
320
|
+
model,
|
|
321
|
+
queries: exposed.queries,
|
|
322
|
+
where,
|
|
323
|
+
shape,
|
|
324
|
+
totalCount,
|
|
325
|
+
row,
|
|
326
|
+
send,
|
|
327
|
+
logger: this.#settings.logger,
|
|
328
|
+
},
|
|
329
|
+
caller,
|
|
330
|
+
planned.result,
|
|
331
|
+
first,
|
|
332
|
+
)
|
|
333
|
+
const { reads, expires } = planned
|
|
334
|
+
const rows = first * (1 + nested)
|
|
335
|
+
const subscription = { model, args, row, send, rows, reads, expires }
|
|
336
|
+
this.#live.set(live, subscription)
|
|
337
|
+
try {
|
|
338
|
+
await live.start()
|
|
339
|
+
} catch (error) {
|
|
340
|
+
this.#live.delete(live)
|
|
341
|
+
throw error
|
|
342
|
+
}
|
|
343
|
+
this.#reschedule()
|
|
344
|
+
return {
|
|
345
|
+
grow: async (more) => {
|
|
346
|
+
if (!Number.isInteger(more) || more < 1) {
|
|
347
|
+
throw invalid('first', 'first must be a positive integer.')
|
|
348
|
+
}
|
|
349
|
+
if ((live.first + more) * (1 + nested) > limit) {
|
|
350
|
+
throw invalid(
|
|
351
|
+
'first',
|
|
352
|
+
`first is over ${limit}, the most a live list follows.`,
|
|
353
|
+
)
|
|
354
|
+
}
|
|
355
|
+
const grown = (live.first + more) * (1 + nested)
|
|
356
|
+
this.#fits(grown - subscription.rows)
|
|
357
|
+
subscription.rows = grown
|
|
358
|
+
return live.grow(more)
|
|
359
|
+
},
|
|
360
|
+
resend: async () => live.resend(),
|
|
361
|
+
close: () => {
|
|
362
|
+
live.close()
|
|
363
|
+
this.#live.delete(live)
|
|
364
|
+
},
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** Refuses one more live read past what the connection holds. */
|
|
369
|
+
#holds(): void {
|
|
370
|
+
const { maxSubscriptions } = this.#settings
|
|
371
|
+
if (this.#live.size + this.#computed.size < maxSubscriptions) return
|
|
372
|
+
throw invalid(
|
|
373
|
+
'subscriptions',
|
|
374
|
+
`A connection holds at most ${maxSubscriptions} live reads.`,
|
|
375
|
+
)
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** Refuses `more` rows past what the connection follows in all. */
|
|
379
|
+
#fits(more: number): void {
|
|
380
|
+
const { maxConnectionRows } = this.#settings
|
|
381
|
+
let rows = more
|
|
382
|
+
for (const subscription of this.#live.values()) rows += subscription.rows
|
|
383
|
+
if (rows <= maxConnectionRows) return
|
|
384
|
+
throw invalid(
|
|
385
|
+
'first',
|
|
386
|
+
`A connection follows at most ${maxConnectionRows} rows.`,
|
|
387
|
+
)
|
|
388
|
+
}
|
|
389
|
+
}
|
package/src/window.ts
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// A live list's window (D-154): the first rows of its order, each kept as
|
|
2
|
+
// its id and sort keys, never as the row it shows, and one row more, which
|
|
3
|
+
// says whether rows follow without a read. A row is placed by a binary
|
|
4
|
+
// search; one placed after the last row, when rows may follow, is outside.
|
|
5
|
+
// It notes the rows it changes since a mark, so what changed costs what was
|
|
6
|
+
// changed, not a pass over it.
|
|
7
|
+
import type { Order } from 'ultimabase'
|
|
8
|
+
import { compareKeys } from 'ultimabase/internal'
|
|
9
|
+
|
|
10
|
+
export type Entry = { readonly id: string; readonly keys: readonly unknown[] }
|
|
11
|
+
|
|
12
|
+
/** What changed between two windows, in the order a client applies it. */
|
|
13
|
+
export type Moves = {
|
|
14
|
+
readonly removed: readonly string[]
|
|
15
|
+
/** Each row placed again, after the row before it, `null` for the first. */
|
|
16
|
+
readonly placed: readonly {
|
|
17
|
+
readonly id: string
|
|
18
|
+
readonly after: string | null
|
|
19
|
+
}[]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class Window {
|
|
23
|
+
readonly #entries: Entry[] = []
|
|
24
|
+
readonly #ids = new Set<string>()
|
|
25
|
+
/** Rows may follow the last entry. */
|
|
26
|
+
rest = false
|
|
27
|
+
first: number
|
|
28
|
+
readonly #orderBy: readonly Order[]
|
|
29
|
+
/** The rows changed since the mark, each with its keys then, if it was in. */
|
|
30
|
+
#was: Map<string, readonly unknown[] | undefined> | undefined
|
|
31
|
+
/** The row past those shown at the mark. */
|
|
32
|
+
#extra: string | undefined
|
|
33
|
+
|
|
34
|
+
constructor(orderBy: readonly Order[], first: number) {
|
|
35
|
+
this.#orderBy = orderBy
|
|
36
|
+
this.first = first
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Starts noting changes: `moves` tells those since. */
|
|
40
|
+
mark(): void {
|
|
41
|
+
this.#was = new Map()
|
|
42
|
+
this.#extra = this.#entries[this.first]?.id
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
#note(id: string, keys: readonly unknown[] | undefined): void {
|
|
46
|
+
const was = this.#was
|
|
47
|
+
if (was !== undefined && !was.has(id)) was.set(id, keys)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The rows a client shows. */
|
|
51
|
+
get entries(): readonly Entry[] {
|
|
52
|
+
return this.#entries
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
shown(): readonly Entry[] {
|
|
56
|
+
return this.#entries.slice(0, this.first)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
hasMore(): boolean {
|
|
60
|
+
return this.#entries.length > this.first
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The ids of the entries, kept as they change. */
|
|
64
|
+
ids(): ReadonlySet<string> {
|
|
65
|
+
return this.#ids
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** How many rows a read after the last entry must bring. */
|
|
69
|
+
missing(): number {
|
|
70
|
+
return this.rest ? this.first + 1 - this.#entries.length : 0
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Adds the rows read after the last entry. */
|
|
74
|
+
fill(entries: readonly Entry[], rest: boolean): void {
|
|
75
|
+
this.#entries.push(...entries)
|
|
76
|
+
for (const { id } of entries) {
|
|
77
|
+
this.#note(id, undefined)
|
|
78
|
+
this.#ids.add(id)
|
|
79
|
+
}
|
|
80
|
+
this.rest = rest
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
remove(id: string): boolean {
|
|
84
|
+
if (!this.#ids.delete(id)) return false
|
|
85
|
+
const at = this.#entries.findIndex((entry) => entry.id === id)
|
|
86
|
+
this.#note(id, this.#entries[at]?.keys)
|
|
87
|
+
this.#entries.splice(at, 1)
|
|
88
|
+
return true
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Places a row that passes the filter, or leaves it outside. */
|
|
92
|
+
place(entry: Entry): void {
|
|
93
|
+
this.remove(entry.id)
|
|
94
|
+
const entries = this.#entries
|
|
95
|
+
let low = 0
|
|
96
|
+
let high = entries.length
|
|
97
|
+
while (low < high) {
|
|
98
|
+
const middle = (low + high) >> 1
|
|
99
|
+
const keys = entries[middle]?.keys ?? []
|
|
100
|
+
if (compareKeys(keys, entry.keys, this.#orderBy) < 0) low = middle + 1
|
|
101
|
+
else high = middle
|
|
102
|
+
}
|
|
103
|
+
if (low === entries.length && this.rest) return
|
|
104
|
+
this.#note(entry.id, undefined)
|
|
105
|
+
entries.splice(low, 0, entry)
|
|
106
|
+
this.#ids.add(entry.id)
|
|
107
|
+
const last = entries.length > this.first + 1 ? entries.pop() : undefined
|
|
108
|
+
if (last !== undefined) {
|
|
109
|
+
this.#note(last.id, last.keys)
|
|
110
|
+
this.#ids.delete(last.id)
|
|
111
|
+
this.rest = true
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* How the rows shown at the mark, none before the first, became those
|
|
117
|
+
* shown now. Only the rows changed since, and the one past the shown rows
|
|
118
|
+
* then and now, may have come or gone.
|
|
119
|
+
*/
|
|
120
|
+
moves(again: ReadonlySet<string>): Moves {
|
|
121
|
+
const entries = this.#entries
|
|
122
|
+
const after = (at: number) => ({
|
|
123
|
+
id: entries[at]?.id ?? '',
|
|
124
|
+
after: entries[at - 1]?.id ?? null,
|
|
125
|
+
})
|
|
126
|
+
const notes = this.#was
|
|
127
|
+
if (notes === undefined) {
|
|
128
|
+
return { removed: [], placed: this.shown().map((_, at) => after(at)) }
|
|
129
|
+
}
|
|
130
|
+
const extra = entries[this.first]?.id
|
|
131
|
+
const was = (id: string): boolean =>
|
|
132
|
+
id !== this.#extra &&
|
|
133
|
+
(notes.has(id) ? notes.get(id) !== undefined : this.#ids.has(id))
|
|
134
|
+
const shown = (id: string): boolean => id !== extra && this.#ids.has(id)
|
|
135
|
+
const ids = [
|
|
136
|
+
...new Set([...notes.keys(), ...again, this.#extra, extra]),
|
|
137
|
+
].filter((id) => id !== undefined)
|
|
138
|
+
const removed = ids.filter((id) => was(id) && !shown(id))
|
|
139
|
+
const placed = ids.flatMap((id) => {
|
|
140
|
+
if (!shown(id)) return []
|
|
141
|
+
const at = entries.findIndex((entry) => entry.id === id)
|
|
142
|
+
const old = notes.get(id)
|
|
143
|
+
// The rows whose keys stayed keep their order among themselves.
|
|
144
|
+
const still =
|
|
145
|
+
was(id) &&
|
|
146
|
+
!again.has(id) &&
|
|
147
|
+
(old === undefined ||
|
|
148
|
+
compareKeys(old, entries[at]?.keys ?? [], this.#orderBy) === 0)
|
|
149
|
+
return still ? [] : [at]
|
|
150
|
+
})
|
|
151
|
+
return { removed, placed: placed.toSorted((a, b) => a - b).map(after) }
|
|
152
|
+
}
|
|
153
|
+
}
|