@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.
- package/CHANGELOG.md +19 -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/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
|
+
}
|
package/src/messages.ts
ADDED
|
@@ -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 }
|