@ultimabase/realtime 0.0.0-stage → 0.1.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 (63) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +4 -2
  3. package/dist/affected.d.ts +29 -0
  4. package/dist/affected.d.ts.map +1 -0
  5. package/dist/affected.js +89 -0
  6. package/dist/changes.d.ts +22 -0
  7. package/dist/changes.d.ts.map +1 -0
  8. package/dist/changes.js +64 -0
  9. package/dist/checked.d.ts +10 -0
  10. package/dist/checked.d.ts.map +1 -0
  11. package/dist/checked.js +46 -0
  12. package/dist/computed.d.ts +34 -0
  13. package/dist/computed.d.ts.map +1 -0
  14. package/dist/computed.js +118 -0
  15. package/dist/engine.d.ts +13 -0
  16. package/dist/engine.d.ts.map +1 -0
  17. package/dist/engine.js +99 -0
  18. package/dist/index.d.ts +40 -0
  19. package/dist/index.d.ts.map +1 -0
  20. package/dist/index.js +52 -0
  21. package/dist/live.d.ts +37 -0
  22. package/dist/live.d.ts.map +1 -0
  23. package/dist/live.js +309 -0
  24. package/dist/messages.d.ts +42 -0
  25. package/dist/messages.d.ts.map +1 -0
  26. package/dist/messages.js +1 -0
  27. package/dist/reach.d.ts +57 -0
  28. package/dist/reach.d.ts.map +1 -0
  29. package/dist/reach.js +154 -0
  30. package/dist/reads.d.ts +21 -0
  31. package/dist/reads.d.ts.map +1 -0
  32. package/dist/reads.js +73 -0
  33. package/dist/shape.d.ts +16 -0
  34. package/dist/shape.d.ts.map +1 -0
  35. package/dist/shape.js +71 -0
  36. package/dist/stale.d.ts +5 -0
  37. package/dist/stale.d.ts.map +1 -0
  38. package/dist/stale.js +57 -0
  39. package/dist/tracking.d.ts +12 -0
  40. package/dist/tracking.d.ts.map +1 -0
  41. package/dist/tracking.js +48 -0
  42. package/dist/viewer.d.ts +63 -0
  43. package/dist/viewer.d.ts.map +1 -0
  44. package/dist/viewer.js +276 -0
  45. package/dist/window.d.ts +43 -0
  46. package/dist/window.d.ts.map +1 -0
  47. package/dist/window.js +125 -0
  48. package/package.json +42 -3
  49. package/src/affected.ts +118 -0
  50. package/src/changes.ts +107 -0
  51. package/src/checked.ts +59 -0
  52. package/src/computed.ts +141 -0
  53. package/src/engine.ts +122 -0
  54. package/src/index.ts +100 -0
  55. package/src/live.ts +384 -0
  56. package/src/messages.ts +39 -0
  57. package/src/reach.ts +251 -0
  58. package/src/reads.ts +103 -0
  59. package/src/shape.ts +86 -0
  60. package/src/stale.ts +68 -0
  61. package/src/tracking.ts +61 -0
  62. package/src/viewer.ts +389 -0
  63. package/src/window.ts +153 -0
package/src/engine.ts ADDED
@@ -0,0 +1,122 @@
1
+ // The realtime engine (D-154): changes wait 50 ms, then one flush reads each
2
+ // changed row once and hands the batch to every viewer, a few at a time.
3
+ // Every viewer's caller is built again on a timer, and when it expires.
4
+ import type { Changed } from 'ultimabase'
5
+ import { liveSchema, recording } from 'ultimabase/internal'
6
+ import { batchOf } from './changes.ts'
7
+ import { Loader } from './reads.ts'
8
+ import type { Settings } from './viewer.ts'
9
+ import { Viewer } from './viewer.ts'
10
+
11
+ /** How long changes wait, so the writes close together share one message. */
12
+ const gather = 50
13
+ /** How many viewers follow a flush at once. */
14
+ const concurrency = 8
15
+
16
+ /** Runs `task` on each of `items`, `limit` at a time. */
17
+ async function bounded<T>(
18
+ items: readonly T[],
19
+ limit: number,
20
+ task: (item: T) => Promise<void>,
21
+ ): Promise<void> {
22
+ let next = 0
23
+ const worker = async (): Promise<void> => {
24
+ while (next < items.length) {
25
+ const item = items[next]
26
+ next += 1
27
+ if (item !== undefined) await task(item)
28
+ }
29
+ }
30
+ await Promise.all(Array.from({ length: limit }, worker))
31
+ }
32
+
33
+ export class Engine {
34
+ readonly #settings: Settings
35
+ readonly #viewers = new Set<Viewer>()
36
+ readonly #refresh: NodeJS.Timeout
37
+ #changes: Changed[] = []
38
+ #missed = false
39
+ #stopped = false
40
+ #timer: NodeJS.Timeout | undefined
41
+ #chain: Promise<void> = Promise.resolve()
42
+
43
+ constructor(settings: Settings, refresh: number) {
44
+ this.#settings = settings
45
+ this.#refresh = setInterval(() => {
46
+ this.#chain = this.#chain.then(async () => this.#rebuild())
47
+ }, refresh)
48
+ this.#refresh.unref()
49
+ }
50
+
51
+ /** A viewer for `request`'s caller, kept until it closes. */
52
+ async connect(request: Request): Promise<Viewer> {
53
+ const { identify } = this.#settings
54
+ const identified = await recording(async () => identify(request))
55
+ const viewer = new Viewer(this.#settings, request, identified, {
56
+ onClose: () => this.#viewers.delete(viewer),
57
+ onDue: () => {
58
+ this.#chain = this.#chain.then(async () =>
59
+ this.#guarded(viewer, async () => viewer.rebuild()),
60
+ )
61
+ },
62
+ })
63
+ this.#viewers.add(viewer)
64
+ return viewer
65
+ }
66
+
67
+ changed(changes: readonly Changed[]): void {
68
+ this.#changes.push(...changes)
69
+ this.#schedule()
70
+ }
71
+
72
+ missed(): void {
73
+ this.#missed = true
74
+ this.#schedule()
75
+ }
76
+
77
+ async stop(): Promise<void> {
78
+ this.#stopped = true
79
+ clearInterval(this.#refresh)
80
+ clearTimeout(this.#timer)
81
+ this.#timer = undefined
82
+ for (const viewer of this.#viewers) viewer.close()
83
+ await this.#chain
84
+ }
85
+
86
+ #schedule(): void {
87
+ // A change the adapter delivers once the application stopped is late.
88
+ if (this.#stopped || this.#timer !== undefined) return
89
+ this.#timer = setTimeout(() => {
90
+ this.#timer = undefined
91
+ this.#chain = this.#chain.then(async () => this.#flush())
92
+ }, gather)
93
+ }
94
+
95
+ async #flush(): Promise<void> {
96
+ const changes = this.#changes
97
+ const missed = this.#missed
98
+ this.#changes = []
99
+ this.#missed = false
100
+ const batch = batchOf(changes, liveSchema())
101
+ const loader = new Loader()
102
+ await bounded([...this.#viewers], concurrency, async (viewer) =>
103
+ this.#guarded(viewer, async () => viewer.flush(batch, loader, missed)),
104
+ )
105
+ }
106
+
107
+ async #rebuild(): Promise<void> {
108
+ await bounded([...this.#viewers], concurrency, async (viewer) =>
109
+ this.#guarded(viewer, async () => viewer.rebuild()),
110
+ )
111
+ }
112
+
113
+ /** A viewer that fails is logged and closed; the others go on. */
114
+ async #guarded(viewer: Viewer, task: () => Promise<void>): Promise<void> {
115
+ try {
116
+ await task()
117
+ } catch (error) {
118
+ this.#settings.logger.error({ err: error }, 'A live viewer failed.')
119
+ viewer.end()
120
+ }
121
+ }
122
+ }
package/src/index.ts ADDED
@@ -0,0 +1,100 @@
1
+ // `realtimePlugin()` and `realtime()` (D-154): live lists and live rows of
2
+ // the models that use it, which follow each commit's changes and send only
3
+ // what changed, under the caller's read rules; and the live functions, the
4
+ // queries whose `use` lists it, run again when what they read changes.
5
+ import type { PluginOf, PluginTypes, UseOf } from 'ultimabase'
6
+ import { definePlugin } from 'ultimabase'
7
+ import manifest from '../package.json' with { type: 'json' }
8
+ import { Engine } from './engine.ts'
9
+ import type { Viewer } from './viewer.ts'
10
+ import { ownerOf } from './viewer.ts'
11
+
12
+ export type { LiveMessage, Removal, Upsert } from './messages.ts'
13
+ export type { LiveHandle, Viewer } from './viewer.ts'
14
+
15
+ export type RealtimeOptions = {
16
+ /** The most rows a live list follows, nested rows included; 1000. */
17
+ readonly maxLive?: number
18
+ /** The most live reads one connection holds; 100. */
19
+ readonly maxSubscriptions?: number
20
+ /** The most rows one connection follows in all, nested rows included; 10 000. */
21
+ readonly maxConnectionRows?: number
22
+ /** How often, in milliseconds, each caller is built again; 5 minutes. */
23
+ readonly refresh?: number
24
+ }
25
+
26
+ /** What the realtime plugin offers transports. */
27
+ export interface RealtimeApi {
28
+ /** Follows `request`'s caller; its live reads close with it. */
29
+ connect(this: void, request: Request): Promise<Viewer>
30
+ /** Whether `model`'s `use` lists `realtime()`. */
31
+ live(this: void, model: string): boolean
32
+ /** Whether function `name`'s `use` lists `realtime()`, `plugin.name` for a plugin's. */
33
+ liveFunction(this: void, name: string): boolean
34
+ }
35
+
36
+ /** The realtime plugin's types (D-57). */
37
+ export interface Realtime extends PluginTypes {
38
+ readonly name: 'realtime'
39
+ readonly appOptions: RealtimeOptions
40
+ readonly with: { readonly maxLive?: number }
41
+ /** A live function's wait for quiet after a write, in milliseconds; 50. */
42
+ readonly functionWith: { readonly debounce?: number }
43
+ readonly queryOnly: true
44
+ readonly api: RealtimeApi
45
+ }
46
+
47
+ // Each running application's engine, by the context its plugin received.
48
+ const engines = new WeakMap<object, Engine>()
49
+
50
+ const positive = (name: string, value: number | undefined): void => {
51
+ if (value === undefined || (Number.isInteger(value) && value > 0)) return
52
+ throw new Error(`realtime's ${name} must be a positive integer.`)
53
+ }
54
+
55
+ const realtimeDefinition = definePlugin<Realtime>({
56
+ name: 'realtime',
57
+ manifest,
58
+ api: (context) => {
59
+ const { options, identify, logger, models, functions } = context
60
+ positive('maxLive', options.maxLive)
61
+ positive('maxSubscriptions', options.maxSubscriptions)
62
+ positive('maxConnectionRows', options.maxConnectionRows)
63
+ positive('refresh', options.refresh)
64
+ for (const model of models) positive('maxLive', model.with?.maxLive)
65
+ for (const fn of functions) {
66
+ positive('debounce', fn.with?.debounce)
67
+ if (fn.used && !fn.query) {
68
+ throw new Error(`${ownerOf(fn)} is a mutation: it cannot be live.`)
69
+ }
70
+ }
71
+ const engine = new Engine(
72
+ {
73
+ models,
74
+ functions,
75
+ identify,
76
+ logger,
77
+ maxLive: options.maxLive ?? 1000,
78
+ maxSubscriptions: options.maxSubscriptions ?? 100,
79
+ maxConnectionRows: options.maxConnectionRows ?? 10_000,
80
+ },
81
+ options.refresh ?? 300_000,
82
+ )
83
+ engines.set(context, engine)
84
+ const live = new Set(
85
+ models.filter(({ used }) => used).map(({ schema }) => schema.name),
86
+ )
87
+ const followed = new Set(functions.filter(({ used }) => used).map(ownerOf))
88
+ return {
89
+ connect: async (request) => engine.connect(request),
90
+ live: (model) => live.has(model),
91
+ liveFunction: (name) => followed.has(name),
92
+ }
93
+ },
94
+ changed: (changes, context) => engines.get(context)?.changed(changes),
95
+ missed: (context) => engines.get(context)?.missed(),
96
+ stop: async (context) => engines.get(context)?.stop(),
97
+ })
98
+
99
+ export const realtime: UseOf<Realtime> = realtimeDefinition.use
100
+ export const realtimePlugin: PluginOf<Realtime> = realtimeDefinition.plugin
package/src/live.ts ADDED
@@ -0,0 +1,384 @@
1
+ // A live list or row (D-154): its window follows each flush's changes, a
2
+ // row placed or removed at a time, and sends only what changed. Its tasks
3
+ // run one after another; one that fails reads the window again, and a
4
+ // second failure closes it.
5
+ import type { Caller, Logger, ModelQueries, Row } from 'ultimabase'
6
+ import type { LivePlan } from 'ultimabase/internal'
7
+ import {
8
+ inMemory,
9
+ liveSchema,
10
+ matches,
11
+ servedRows,
12
+ sortKeys,
13
+ storedRows,
14
+ } from 'ultimabase/internal'
15
+ import type { Finder } from './affected.ts'
16
+ import { affected } from './affected.ts'
17
+ import type { Batch } from './changes.ts'
18
+ import type { Reach } from './reach.ts'
19
+ import { reachOf } from './reach.ts'
20
+ import type { Loader } from './reads.ts'
21
+ import { finderOf, pass } from './reads.ts'
22
+ import type { LiveMessage } from './messages.ts'
23
+ import type { Entry } from './window.ts'
24
+ import { Window } from './window.ts'
25
+ import { Tracking } from './tracking.ts'
26
+
27
+ export type Spec = {
28
+ readonly model: string
29
+ readonly queries: ModelQueries
30
+ /** The read's own `where`, which `totalCount` counts. */
31
+ readonly where: unknown
32
+ /** Its `select`, `include` or `omit`, each level's `id` selected. */
33
+ readonly shape: { readonly [key: string]: unknown }
34
+ readonly totalCount: boolean
35
+ readonly row: boolean
36
+ readonly send: (message: LiveMessage) => void
37
+ readonly logger: Logger
38
+ }
39
+
40
+ export class Live {
41
+ caller: Caller
42
+ #plan: LivePlan
43
+ #reach: Reach
44
+ #window: Window
45
+ readonly #spec: Spec
46
+ readonly #find: Finder
47
+ readonly #tracking = new Tracking()
48
+ #count: number | undefined
49
+ #hasMore = false
50
+ #revision = 0
51
+ #queue: Promise<void> = Promise.resolve()
52
+ #closed = false
53
+
54
+ constructor(spec: Spec, caller: Caller, plan: LivePlan, first: number) {
55
+ this.#spec = spec
56
+ this.caller = caller
57
+ this.#plan = plan
58
+ this.#reach = reachOf(liveSchema(), spec.model, plan)
59
+ this.#window = new Window(plan.orderBy, first)
60
+ this.#find = finderOf(spec.model)
61
+ }
62
+
63
+ get first(): number {
64
+ return this.#window.first
65
+ }
66
+
67
+ /** Reads the window and sends it; a failure closes the read. */
68
+ async start(): Promise<void> {
69
+ const run = this.#reset()
70
+ this.#queue = run.catch(() => undefined)
71
+ try {
72
+ await run
73
+ } catch (error) {
74
+ this.close()
75
+ throw error
76
+ }
77
+ }
78
+
79
+ /** Follows `batch`, or reads the window again when changes were missed. */
80
+ async apply(batch: Batch, loader: Loader, missed: boolean): Promise<void> {
81
+ const [, settled] = this.#enqueue(async () =>
82
+ missed ? this.#reset() : this.#apply(batch, loader),
83
+ )
84
+ return settled
85
+ }
86
+
87
+ /** Takes the caller's new plan; a changed one reads the window again. */
88
+ async replan(caller: Caller, plan: LivePlan): Promise<void> {
89
+ const [, settled] = this.#enqueue(async () => {
90
+ const same =
91
+ JSON.stringify(plan) === JSON.stringify(this.#plan) &&
92
+ JSON.stringify(caller) === JSON.stringify(this.caller)
93
+ this.caller = caller
94
+ if (same) return
95
+ this.#plan = plan
96
+ this.#reach = reachOf(liveSchema(), this.#spec.model, plan)
97
+ await this.#reset()
98
+ })
99
+ return settled
100
+ }
101
+
102
+ /** Shows `more` rows after the last one. */
103
+ async grow(more: number): Promise<void> {
104
+ const [run] = this.#enqueue(async () => {
105
+ this.#window.mark()
106
+ this.#window.first += more
107
+ // Sent even when no row follows: the client waits for it.
108
+ await this.#send(new Set(), new Set(), false, true)
109
+ })
110
+ return run
111
+ }
112
+
113
+ /** Reads the window again and sends it whole. */
114
+ async resend(): Promise<void> {
115
+ const [, settled] = this.#enqueue(async () => this.#reset())
116
+ return settled
117
+ }
118
+
119
+ close(): void {
120
+ this.#closed = true
121
+ this.#tracking.clear()
122
+ }
123
+
124
+ #enqueue(task: () => Promise<void>): readonly [Promise<void>, Promise<void>] {
125
+ const run = this.#queue.then(async () =>
126
+ this.#closed ? undefined : task(),
127
+ )
128
+ const settled = run.catch(async (error: unknown) => this.#recover(error))
129
+ this.#queue = settled
130
+ return [run, settled]
131
+ }
132
+
133
+ async #recover(error: unknown): Promise<void> {
134
+ const { logger, model, send } = this.#spec
135
+ if (this.#closed) return
136
+ logger.error(
137
+ { err: error },
138
+ `A live read of ${model} failed: it reads again.`,
139
+ )
140
+ try {
141
+ await this.#reset()
142
+ } catch (again) {
143
+ logger.error({ err: again }, `A live read of ${model} failed: it closes.`)
144
+ this.close()
145
+ send({ kind: 'closed' })
146
+ }
147
+ }
148
+
149
+ #entry(row: Row): Entry {
150
+ const keys = sortKeys(this.#spec.model, row, this.#plan.orderBy)
151
+ return { id: row.id, keys }
152
+ }
153
+
154
+ /**
155
+ * The stored rows after the window's last, from `where` and in order:
156
+ * from the flush's read of the read's own filter when its rights, checked
157
+ * in memory, leave enough.
158
+ */
159
+ async #after(take: number, loader?: Loader): Promise<readonly Row[]> {
160
+ const { model } = this.#spec
161
+ const { where, filter, rights, orderBy } = this.#plan
162
+ const last = this.#window.entries.at(-1)
163
+ const page = {
164
+ orderBy,
165
+ ...(last && { cursor: { id: { equals: last.id } }, skip: 1 }),
166
+ take,
167
+ }
168
+ if (loader !== undefined && inMemory(model, rights)) {
169
+ const rows = await loader.after(model, {
170
+ ...(filter && { where: filter }),
171
+ ...page,
172
+ })
173
+ const kept = rows.filter((row) => matches(model, row, rights))
174
+ if (kept.length >= take || rows.length < take) return kept.slice(0, take)
175
+ }
176
+ return storedRows(model, {
177
+ ...(where && { where }),
178
+ ...page,
179
+ fields: orderBy.map(({ field }) => field),
180
+ })
181
+ }
182
+
183
+ async #refill(loader?: Loader): Promise<void> {
184
+ const missing = this.#window.missing()
185
+ if (missing <= 0) return
186
+ const rows = await this.#after(missing + 1, loader)
187
+ const entries = rows.slice(0, missing).map((row) => this.#entry(row))
188
+ this.#window.fill(entries, rows.length > missing)
189
+ }
190
+
191
+ async #counted(): Promise<number> {
192
+ const { queries, where } = this.#spec
193
+ const args = where === undefined ? {} : { where }
194
+ return queries.count(args, { as: this.caller })
195
+ }
196
+
197
+ /**
198
+ * Reads `ids` as the caller into `nodes`, from the rows the flush loaded
199
+ * if any; returns those it cannot read.
200
+ */
201
+ async #read(
202
+ ids: readonly string[],
203
+ nodes: Map<string, Row>,
204
+ loader?: Loader,
205
+ ): Promise<readonly string[]> {
206
+ if (ids.length === 0) return []
207
+ const { model, queries, shape } = this.#spec
208
+ const args = { ...shape, where: { id: { in: ids } } }
209
+ const found =
210
+ loader === undefined
211
+ ? await queries.findMany(args, { as: this.caller })
212
+ : await servedRows(
213
+ model,
214
+ args,
215
+ this.caller,
216
+ await loader.load(model, ids),
217
+ )
218
+ for (const node of found) {
219
+ nodes.set(node.id, node)
220
+ this.#tracking.track(node, this.#plan.include)
221
+ }
222
+ return ids.filter((id) => !nodes.has(id))
223
+ }
224
+
225
+ /**
226
+ * Refills the window and reads the rows it places; a row the caller
227
+ * cannot read leaves it, `gone`.
228
+ */
229
+ async #settle(
230
+ again: ReadonlySet<string>,
231
+ gone: Set<string>,
232
+ loader?: Loader,
233
+ ): Promise<{
234
+ readonly moves: ReturnType<Window['moves']>
235
+ readonly nodes: ReadonlyMap<string, Row>
236
+ }> {
237
+ const nodes = new Map<string, Row>()
238
+ for (let round = 0; round < 3; round += 1) {
239
+ await this.#refill(loader)
240
+ const moves = this.#window.moves(again)
241
+ const unread = moves.placed.flatMap(({ id }) =>
242
+ nodes.has(id) ? [] : [id],
243
+ )
244
+ const missing = await this.#read(unread, nodes, loader)
245
+ if (missing.length === 0) return { moves, nodes }
246
+ for (const id of missing) {
247
+ this.#window.remove(id)
248
+ gone.add(id)
249
+ }
250
+ }
251
+ throw new Error('The rows the live plan finds are not readable.')
252
+ }
253
+
254
+ #next(): number {
255
+ this.#revision += 1
256
+ return this.#revision
257
+ }
258
+
259
+ async #reset(): Promise<void> {
260
+ const { model, row, send, totalCount } = this.#spec
261
+ const { where, orderBy } = this.#plan
262
+ const { first } = this.#window
263
+ this.#window = new Window(orderBy, first)
264
+ this.#tracking.clear()
265
+ const rows = await storedRows(model, {
266
+ ...(where && { where }),
267
+ orderBy,
268
+ take: first + 2,
269
+ fields: orderBy.map(({ field }) => field),
270
+ })
271
+ const entries = rows.slice(0, first + 1).map((each) => this.#entry(each))
272
+ this.#window.fill(entries, rows.length > first + 1)
273
+ const { nodes } = await this.#settle(new Set(), new Set())
274
+ this.#count = totalCount ? await this.#counted() : undefined
275
+ this.#hasMore = this.#window.hasMore()
276
+ const shown = this.#window.shown().flatMap(({ id }) => nodes.get(id) ?? [])
277
+ if (this.#closed) return
278
+ if (row) {
279
+ send({ kind: 'row', revision: this.#next(), row: shown[0] ?? null })
280
+ return
281
+ }
282
+ send({
283
+ kind: 'reset',
284
+ revision: this.#next(),
285
+ rows: shown,
286
+ hasMore: this.#hasMore,
287
+ ...(this.#count !== undefined && { totalCount: this.#count }),
288
+ })
289
+ }
290
+
291
+ async #apply(batch: Batch, loader: Loader): Promise<void> {
292
+ const { model, totalCount } = this.#spec
293
+ const window = this.#window
294
+ const effect = await affected(
295
+ {
296
+ model,
297
+ reach: this.#reach,
298
+ window: window.ids(),
299
+ tracked: this.#tracking.tracked,
300
+ find: this.#find,
301
+ },
302
+ batch,
303
+ )
304
+ if (effect === 'resync') return this.#reset()
305
+ const gone = new Set(effect.deleted)
306
+ const candidates = [...effect.candidates].filter((id) => !gone.has(id))
307
+ const rows = await loader.load(model, candidates)
308
+ const kept = await pass(model, rows, this.#plan.where)
309
+ const inside = window.ids()
310
+ const leaving = candidates.filter((id) => inside.has(id) && !kept.has(id))
311
+ const left = new Map(
312
+ leaving.flatMap((id) => rows.get(id) ?? []).map((row) => [row.id, row]),
313
+ )
314
+ const readable = await pass(model, left, this.#plan.rights)
315
+ const recount =
316
+ totalCount &&
317
+ (candidates.length > 0 || effect.deleted.size > 0 || effect.beyond)
318
+ // A write that leaves the window as it was costs no pass over it.
319
+ const touched =
320
+ gone.size > 0 ||
321
+ effect.resend.size > 0 ||
322
+ kept.size > 0 ||
323
+ leaving.length > 0
324
+ if (!touched && !recount) return
325
+ window.mark()
326
+ for (const id of effect.deleted) window.remove(id)
327
+ for (const id of leaving) {
328
+ window.remove(id)
329
+ if (!readable.has(id)) gone.add(id)
330
+ }
331
+ for (const id of kept) {
332
+ const row = rows.get(id)
333
+ if (row !== undefined) window.place(this.#entry(row))
334
+ }
335
+ await this.#send(effect.resend, gone, recount, false, loader)
336
+ }
337
+
338
+ /** Sends how the rows shown at the mark changed, if they did or `always`. */
339
+ async #send(
340
+ again: ReadonlySet<string>,
341
+ gone: Set<string>,
342
+ recount: boolean,
343
+ always = false,
344
+ loader?: Loader,
345
+ ): Promise<void> {
346
+ const { row, send } = this.#spec
347
+ const { moves, nodes } = await this.#settle(again, gone, loader)
348
+ for (const id of moves.removed) this.#tracking.untrack(id)
349
+ let totalCount: number | undefined
350
+ if (recount) {
351
+ const count = await this.#counted()
352
+ if (count !== this.#count) totalCount = count
353
+ this.#count = count
354
+ }
355
+ if (this.#closed) return
356
+ if (row) {
357
+ if (moves.removed.length + moves.placed.length === 0) return
358
+ const [entry] = this.#window.shown()
359
+ const shown = entry === undefined ? null : (nodes.get(entry.id) ?? null)
360
+ send({ kind: 'row', revision: this.#next(), row: shown })
361
+ return
362
+ }
363
+ const changes = [
364
+ ...moves.removed.map((id) => ({ remove: id, gone: gone.has(id) })),
365
+ ...moves.placed.flatMap(({ id, after }) => {
366
+ const node = nodes.get(id)
367
+ return node === undefined ? [] : [{ upsert: node, after }]
368
+ }),
369
+ ]
370
+ const hasMore = this.#window.hasMore()
371
+ const more = hasMore !== this.#hasMore
372
+ this.#hasMore = hasMore
373
+ if (changes.length === 0 && !more && totalCount === undefined && !always) {
374
+ return
375
+ }
376
+ send({
377
+ kind: 'changes',
378
+ revision: this.#next(),
379
+ changes,
380
+ ...(more && { hasMore }),
381
+ ...(totalCount !== undefined && { totalCount }),
382
+ })
383
+ }
384
+ }
@@ -0,0 +1,39 @@
1
+ // What a live read sends (D-154); a transport writes it in its own shape.
2
+ import type { Row } from 'ultimabase'
3
+
4
+ /** A row that left the window, `gone` when deleted or no longer readable. */
5
+ export type Removal = { readonly remove: string; readonly gone: boolean }
6
+ /** A row placed after another, first with `after: null`. */
7
+ export type Upsert = { readonly upsert: Row; readonly after: string | null }
8
+
9
+ /** What a live read sends; `revision` grows by one per message. */
10
+ export type LiveMessage =
11
+ | {
12
+ readonly kind: 'reset'
13
+ readonly revision: number
14
+ readonly rows: readonly Row[]
15
+ readonly hasMore: boolean
16
+ readonly totalCount?: number
17
+ }
18
+ | {
19
+ readonly kind: 'changes'
20
+ readonly revision: number
21
+ readonly changes: readonly (Removal | Upsert)[]
22
+ /** Only when it changed. */
23
+ readonly hasMore?: boolean
24
+ /** Only when it changed. */
25
+ readonly totalCount?: number
26
+ }
27
+ | {
28
+ readonly kind: 'row'
29
+ readonly revision: number
30
+ readonly row: Row | null
31
+ }
32
+ | {
33
+ readonly kind: 'value'
34
+ readonly revision: number
35
+ /** A live function's whole result. */
36
+ readonly value: unknown
37
+ }
38
+ /** With the error a live function failed with, which closed it. */
39
+ | { readonly kind: 'closed'; readonly error?: unknown }