@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.
Files changed (63) hide show
  1. package/CHANGELOG.md +12 -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/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
+ }