@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/reach.ts ADDED
@@ -0,0 +1,251 @@
1
+ // Where a live read reaches (D-154): the fields its filter and order read on
2
+ // its own rows, the relations its filter goes through, level by level, and
3
+ // the relations its selection shows, so a write is followed only where it
4
+ // lands.
5
+ import type { Condition, Filter, Nested, Schema, SchemaField } from 'ultimabase'
6
+ import type { Fields } from './changes.ts'
7
+ import type { LivePlan } from 'ultimabase/internal'
8
+
9
+ /** A relation followed from one model to the next. */
10
+ export type Step = {
11
+ readonly relation: string
12
+ readonly many: boolean
13
+ readonly target: string
14
+ readonly inverse: string
15
+ /** The target stores the key: a link changes there, out of sight. */
16
+ readonly keyOnTarget: boolean
17
+ }
18
+
19
+ /** A level of a filter: its rows are those its steps reach from the root. */
20
+ export type Level = {
21
+ readonly steps: readonly Step[]
22
+ readonly model: string
23
+ /** The fields it reads, the key back to the level above included. */
24
+ readonly fields: Fields
25
+ /** Under `NOT`, `isNot`, `every`, `none` or `is: null`: a lost link may add a row. */
26
+ readonly negative: boolean
27
+ }
28
+
29
+ /** A relation the selection shows, or counts. */
30
+ export type Shown = {
31
+ /** The relations from the root, joined by dots. */
32
+ readonly path: string
33
+ readonly steps: readonly Step[]
34
+ readonly model: string
35
+ /** The fields of its rows that show, or decide which rows show. */
36
+ readonly fields: Fields
37
+ /** A `_count`: its rows are not in the result to track. */
38
+ readonly counted: boolean
39
+ }
40
+
41
+ export type Reach = {
42
+ /** The filter's levels, the root's first. */
43
+ readonly levels: readonly Level[]
44
+ /** The fields that place a root row: its filter's and its order's. */
45
+ readonly placing: Fields
46
+ /** The root fields that show. */
47
+ readonly showing: Fields
48
+ readonly shown: readonly Shown[]
49
+ /** Models a nested filter or order reaches further: any change resends all. */
50
+ readonly deep: ReadonlySet<string>
51
+ }
52
+
53
+ type Models = ReadonlyMap<string, ReadonlyMap<string, SchemaField>>
54
+
55
+ const indexes = new WeakMap<Schema, Models>()
56
+
57
+ /** `schema`'s fields by model and name. */
58
+ export function modelsOf(schema: Schema): Models {
59
+ const known = indexes.get(schema)
60
+ if (known !== undefined) return known
61
+ const models = new Map(
62
+ schema.models.map(({ name, fields }) => [
63
+ name,
64
+ new Map(fields.map((field) => [field.name, field])),
65
+ ]),
66
+ )
67
+ indexes.set(schema, models)
68
+ return models
69
+ }
70
+
71
+ /** The relation `name` of `model`, if it is one. */
72
+ export function stepOf(
73
+ models: Models,
74
+ model: string,
75
+ name: string,
76
+ ): Step | undefined {
77
+ const field = models.get(model)?.get(name)
78
+ if (field === undefined || field.kind === 'scalar') return undefined
79
+ const back = models.get(field.target)?.get(field.inverse)
80
+ return {
81
+ relation: name,
82
+ many: field.kind === 'many',
83
+ target: field.target,
84
+ inverse: field.inverse,
85
+ keyOnTarget: back?.kind === 'one' && back.stored,
86
+ }
87
+ }
88
+
89
+ const relationKeys = ['is', 'isNot', 'some', 'every', 'none'] as const
90
+
91
+ /** What a nested read selects. */
92
+ type Read = Pick<Nested, 'include' | 'count' | 'fields'>
93
+
94
+ const isList = (
95
+ value: Condition | readonly Filter[],
96
+ ): value is readonly Filter[] => Array.isArray(value)
97
+
98
+ /**
99
+ * Adds the levels of `filter`, read on `model`'s rows, to `levels`; returns
100
+ * the fields it reads on them.
101
+ */
102
+ export function walk(
103
+ models: Models,
104
+ model: string,
105
+ filter: Filter | undefined,
106
+ steps: readonly Step[],
107
+ negative: boolean,
108
+ levels: Level[],
109
+ ): Fields {
110
+ const fields = new Set<string>()
111
+ const back = steps.at(-1)
112
+ if (back !== undefined) fields.add(back.inverse)
113
+ let all = false
114
+ const visit = (part: Filter | undefined, under: boolean): void => {
115
+ for (const [key, value] of Object.entries(part ?? {})) {
116
+ if (value === undefined) continue
117
+ if (isList(value)) {
118
+ for (const inner of value) visit(inner, under || key === 'NOT')
119
+ continue
120
+ }
121
+ if (key === '_search') all = true
122
+ fields.add(key)
123
+ const step = stepOf(models, model, key)
124
+ if (step === undefined) continue
125
+ for (const condition of relationKeys) {
126
+ const inner = value[condition]
127
+ if (inner === undefined) continue
128
+ const lost =
129
+ condition === 'is'
130
+ ? inner === null
131
+ : condition !== 'some' && !(condition === 'isNot' && inner === null)
132
+ const next = [...steps, step]
133
+ walk(models, step.target, inner ?? {}, next, under || lost, levels)
134
+ }
135
+ }
136
+ }
137
+ visit(filter, negative)
138
+ const read = all ? 'all' : fields
139
+ levels.push({ steps, model, fields: read, negative })
140
+ return read
141
+ }
142
+
143
+ /** The fields a nested read shows: `'all'` when it lists none. */
144
+ function showing(nested: Read, also: readonly string[] = []): Fields {
145
+ if (nested.fields === undefined) return 'all'
146
+ return new Set([
147
+ ...nested.fields,
148
+ ...Object.keys(nested.include ?? {}),
149
+ ...Object.keys(nested.count ?? {}),
150
+ ...also,
151
+ ])
152
+ }
153
+
154
+ const union = (a: Fields, b: Fields): Fields =>
155
+ a === 'all' || b === 'all' ? 'all' : new Set([...a, ...b])
156
+
157
+ /** The models a nested filter or order reaches beyond `model`. */
158
+ function further(
159
+ models: Models,
160
+ model: string,
161
+ where: Filter | undefined,
162
+ orderBy: Nested['orderBy'],
163
+ deep: Set<string>,
164
+ ): Fields {
165
+ const levels: Level[] = []
166
+ const fields = walk(models, model, where, [], false, levels)
167
+ for (const level of levels) {
168
+ if (level.steps.length > 0) deep.add(level.model)
169
+ }
170
+ for (const order of orderBy ?? []) {
171
+ let on = model
172
+ for (const { relation } of order.through ?? []) {
173
+ on = stepOf(models, on, relation)?.target ?? on
174
+ deep.add(on)
175
+ }
176
+ const counted = order.count && stepOf(models, on, order.field)
177
+ if (counted) deep.add(counted.target)
178
+ }
179
+ return fields
180
+ }
181
+
182
+ /** Adds what `nested`, read on `model`'s rows at `path`, shows. */
183
+ function walkShown(
184
+ models: Models,
185
+ model: string,
186
+ nested: Read,
187
+ at: { readonly path: string; readonly steps: readonly Step[] },
188
+ shown: Shown[],
189
+ deep: Set<string>,
190
+ ): void {
191
+ const named = (name: string) => (at.path ? `${at.path}.${name}` : name)
192
+ for (const [name, inner] of Object.entries(nested.include ?? {})) {
193
+ const step = stepOf(models, model, name)
194
+ if (step === undefined) continue
195
+ const steps = [...at.steps, step]
196
+ const filtered = further(
197
+ models,
198
+ step.target,
199
+ inner.where,
200
+ inner.orderBy,
201
+ deep,
202
+ )
203
+ const fields = union(showing(inner, [step.inverse]), filtered)
204
+ shown.push({
205
+ path: named(name),
206
+ steps,
207
+ model: step.target,
208
+ fields,
209
+ counted: false,
210
+ })
211
+ walkShown(
212
+ models,
213
+ step.target,
214
+ inner,
215
+ { path: named(name), steps },
216
+ shown,
217
+ deep,
218
+ )
219
+ }
220
+ for (const [name, where] of Object.entries(nested.count ?? {})) {
221
+ const step = stepOf(models, model, name)
222
+ if (step === undefined) continue
223
+ const filtered = further(models, step.target, where, undefined, deep)
224
+ shown.push({
225
+ path: named(`_count.${name}`),
226
+ steps: [...at.steps, step],
227
+ model: step.target,
228
+ fields: union(new Set([step.inverse]), filtered),
229
+ counted: true,
230
+ })
231
+ }
232
+ }
233
+
234
+ /** Where `plan`, a live read of `model`, reaches. */
235
+ export function reachOf(schema: Schema, model: string, plan: LivePlan): Reach {
236
+ const models = modelsOf(schema)
237
+ const levels: Level[] = []
238
+ const root = walk(models, model, plan.where, [], false, levels)
239
+ levels.reverse()
240
+ const order = new Set(plan.orderBy.map(({ field }) => field))
241
+ const shown: Shown[] = []
242
+ const deep = new Set<string>()
243
+ walkShown(models, model, plan, { path: '', steps: [] }, shown, deep)
244
+ return {
245
+ levels,
246
+ placing: union(root, order),
247
+ showing: showing(plan),
248
+ shown,
249
+ deep,
250
+ }
251
+ }
package/src/reads.ts ADDED
@@ -0,0 +1,103 @@
1
+ // The reads a flush makes (D-154): the root rows a relation's rows reach,
2
+ // a filter checked on a few rows, in memory when it reads the row alone,
3
+ // and each changed row loaded once for every live read that needs it.
4
+ import type { Filter, Nested, Row } from 'ultimabase'
5
+ import { inMemory, matches, storedRows } from 'ultimabase/internal'
6
+ import type { Finder } from './affected.ts'
7
+ import type { Step } from './reach.ts'
8
+
9
+ /** A filter on the root that holds when `steps` reach a row passing `inner`. */
10
+ export const through = (steps: readonly Step[], inner: Filter): Filter =>
11
+ steps.reduceRight<Filter>(
12
+ (filter, { relation, many }) => ({
13
+ [relation]: many ? { some: filter } : { is: filter },
14
+ }),
15
+ inner,
16
+ )
17
+
18
+ /** The `model` rows in `ids`, as one filter. */
19
+ const among = (ids: readonly string[]): Filter => ({ id: { in: [...ids] } })
20
+
21
+ /** Finds root rows of `model` through one indexed query per call. */
22
+ export const finderOf =
23
+ (model: string): Finder =>
24
+ async (steps, ids, within) => {
25
+ const reach = through(steps, among(ids))
26
+ const where = within === undefined ? reach : { AND: [among(within), reach] }
27
+ const rows = await storedRows(model, { where, fields: ['id'] })
28
+ return rows.map(({ id }) => id)
29
+ }
30
+
31
+ /**
32
+ * Each changed row, read once per flush whatever reads it, and each page
33
+ * of rows after a window's last, once for the live reads that share it.
34
+ */
35
+ export class Loader {
36
+ readonly #rows = new Map<string, Map<string, Promise<Row | undefined>>>()
37
+ readonly #pages = new Map<string, Promise<readonly Row[]>>()
38
+
39
+ #known(model: string): Map<string, Promise<Row | undefined>> {
40
+ const known =
41
+ this.#rows.get(model) ?? new Map<string, Promise<Row | undefined>>()
42
+ this.#rows.set(model, known)
43
+ return known
44
+ }
45
+
46
+ /** The stored rows `read` finds, whose rows `load` then gives. */
47
+ async after(model: string, read: Nested): Promise<readonly Row[]> {
48
+ const key = JSON.stringify([model, read])
49
+ let page = this.#pages.get(key)
50
+ if (page === undefined) {
51
+ page = storedRows(model, read)
52
+ this.#pages.set(key, page)
53
+ const rows = await page
54
+ const known = this.#known(model)
55
+ for (const row of rows) {
56
+ if (!known.has(row.id)) known.set(row.id, Promise.resolve(row))
57
+ }
58
+ }
59
+ return page
60
+ }
61
+
62
+ /** The stored rows of `model` in `ids`; a deleted row is missing. */
63
+ async load(
64
+ model: string,
65
+ ids: readonly string[],
66
+ ): Promise<ReadonlyMap<string, Row>> {
67
+ const known = this.#known(model)
68
+ const missing = ids.filter((id) => !known.has(id))
69
+ if (missing.length > 0) {
70
+ const read = storedRows(model, { where: among(missing) }).then(
71
+ (rows) => new Map(rows.map((row) => [row.id, row])),
72
+ )
73
+ for (const id of missing) {
74
+ known.set(
75
+ id,
76
+ read.then((rows) => rows.get(id)),
77
+ )
78
+ }
79
+ }
80
+ const found = new Map<string, Row>()
81
+ for (const id of ids) {
82
+ const row = await known.get(id)
83
+ if (row !== undefined) found.set(id, row)
84
+ }
85
+ return found
86
+ }
87
+ }
88
+
89
+ /** The ids of `rows` that pass `filter`: in memory when it reads the row alone. */
90
+ export async function pass(
91
+ model: string,
92
+ rows: ReadonlyMap<string, Row>,
93
+ filter: Filter | undefined,
94
+ ): Promise<ReadonlySet<string>> {
95
+ if (filter === undefined || rows.size === 0) return new Set(rows.keys())
96
+ if (inMemory(model, filter)) {
97
+ const kept = [...rows.values()].filter((row) => matches(model, row, filter))
98
+ return new Set(kept.map(({ id }) => id))
99
+ }
100
+ const where = { AND: [among([...rows.keys()]), filter] }
101
+ const kept = await storedRows(model, { where, fields: ['id'] })
102
+ return new Set(kept.map(({ id }) => id))
103
+ }
package/src/shape.ts ADDED
@@ -0,0 +1,86 @@
1
+ // What a live read asks for (D-154): `first` and `totalCount` are the
2
+ // engine's own; the rest is planned as a `findMany`'s, with each level's
3
+ // `id` selected, which places and tracks the rows it reaches.
4
+ import { PublicError } from 'ultimabase'
5
+
6
+ type Args = { readonly [key: string]: unknown }
7
+
8
+ const isArgs = (value: unknown): value is Args =>
9
+ typeof value === 'object' && value !== null && !Array.isArray(value)
10
+
11
+ const invalid = (path: string, message: string): PublicError =>
12
+ new PublicError('VALIDATION_FAILED', message, {
13
+ details: { issues: [{ path: [path], message }] },
14
+ })
15
+
16
+ /** `nested`, a relation's value in a selection, with its rows' `id`. */
17
+ const nestedWithIds = (nested: unknown): unknown =>
18
+ isArgs(nested) ? withIds(nested) : nested
19
+
20
+ /** `args`' selection, each level's `id` selected and never omitted. */
21
+ function withIds(args: Args): Args {
22
+ const { select, include, omit, ...rest } = args
23
+ const out: { [key: string]: unknown } = { ...rest }
24
+ if (isArgs(select)) {
25
+ const fields = Object.entries(select).map(([field, value]) => [
26
+ field,
27
+ field === '_count' ? value : nestedWithIds(value),
28
+ ])
29
+ out['select'] = { ...Object.fromEntries(fields), id: true }
30
+ } else if (select !== undefined) out['select'] = select
31
+ if (isArgs(include)) {
32
+ const fields = Object.entries(include).map(([field, value]) => [
33
+ field,
34
+ field === '_count' ? value : nestedWithIds(value),
35
+ ])
36
+ out['include'] = Object.fromEntries(fields)
37
+ } else if (include !== undefined) out['include'] = include
38
+ if (isArgs(omit)) {
39
+ out['omit'] = Object.fromEntries(
40
+ Object.entries(omit).filter(([field]) => field !== 'id'),
41
+ )
42
+ } else if (omit !== undefined) out['omit'] = omit
43
+ return out
44
+ }
45
+
46
+ /** What `livePlan` reads: `args` without the engine's own keys. */
47
+ export function liveArgs(args: unknown, row: boolean): unknown {
48
+ if (!isArgs(args)) return args
49
+ if (row) return withIds(args)
50
+ const { first: _first, totalCount: _totalCount, ...rest } = args
51
+ return withIds(rest)
52
+ }
53
+
54
+ export type Shape = {
55
+ readonly first: number
56
+ readonly where: unknown
57
+ readonly totalCount: boolean
58
+ /** The selection each window row is read with. */
59
+ readonly shape: Args
60
+ }
61
+
62
+ /** `args`' window and selection; a list's `first` is a positive integer. */
63
+ export function shapeOf(args: unknown, row: boolean): Shape {
64
+ const read = isArgs(args) ? args : {}
65
+ const { where, first, totalCount } = read
66
+ if (
67
+ !row &&
68
+ (!Number.isInteger(first) || typeof first !== 'number' || first < 1)
69
+ ) {
70
+ throw invalid('first', 'first must be a positive integer.')
71
+ }
72
+ if (!row && totalCount !== undefined && typeof totalCount !== 'boolean') {
73
+ throw invalid('totalCount', 'totalCount must be a boolean.')
74
+ }
75
+ const { select, include, omit } = withIds(read)
76
+ return {
77
+ first: typeof first === 'number' && !row ? first : 1,
78
+ where,
79
+ totalCount: !row && totalCount === true,
80
+ shape: {
81
+ ...(select !== undefined && { select }),
82
+ ...(include !== undefined && { include }),
83
+ ...(omit !== undefined && { omit }),
84
+ },
85
+ }
86
+ }
package/src/stale.ts ADDED
@@ -0,0 +1,68 @@
1
+ // Whether a write may change what a recorded read found (D-154): a caller or
2
+ // a live read's plan that read it is built again.
3
+ import type { Recorded } from 'ultimabase/internal'
4
+ import { inMemory, liveSchema, matches } from 'ultimabase/internal'
5
+ import type { Batch, Fields } from './changes.ts'
6
+ import { listed, touched } from './changes.ts'
7
+ import { modelsOf, stepOf, walk } from './reach.ts'
8
+ import type { Level } from './reach.ts'
9
+ import type { Loader } from './reads.ts'
10
+
11
+ const fieldsOf = (fields: readonly string[] | undefined): Fields =>
12
+ fields === undefined ? 'all' : new Set(fields)
13
+
14
+ /** Whether a write in `batch` may change what `read` found. */
15
+ async function stale(
16
+ read: Recorded,
17
+ batch: Batch,
18
+ loader: Loader,
19
+ ): Promise<boolean> {
20
+ const models = modelsOf(liveSchema())
21
+ if (read.relation !== undefined) {
22
+ const [model = '', field = ''] = read.relation.split('.')
23
+ const step = stepOf(models, model, field)
24
+ if (touched(batch.get(model), new Set([field]))) return true
25
+ if (step && touched(batch.get(step.target), new Set([step.inverse]))) {
26
+ return true
27
+ }
28
+ }
29
+ const { model, where, ids } = read
30
+ if (where !== undefined && !inMemory(model, where)) {
31
+ const levels: Level[] = []
32
+ walk(models, model, where, [], false, levels)
33
+ return levels.some((level) => batch.has(level.model))
34
+ }
35
+ const changes = batch.get(model)
36
+ if (changes === undefined) return false
37
+ const { inserts, deletes } = changes
38
+ const filters = fieldsOf(read.filters)
39
+ const any =
40
+ read.fields === undefined || filters === 'all'
41
+ ? 'all'
42
+ : new Set([...read.fields, ...filters])
43
+ // An update that sets no field it returned or chose its rows by is none.
44
+ const updates = touched(changes, any) ? changes.updates : undefined
45
+ if (ids === undefined || inserts === 'all' || deletes === 'all') {
46
+ return (inserts ?? deletes ?? updates) !== undefined
47
+ }
48
+ if (updates === 'all') return true
49
+ const found = new Set(ids)
50
+ const written = [...listed(updates), ...listed(deletes)]
51
+ if (written.some((id) => found.has(id))) return true
52
+ // A row enters when inserted, or updated in a field that chose the rows.
53
+ const moved = touched(changes, filters) ? listed(updates) : []
54
+ const others = [...listed(inserts), ...moved]
55
+ if (others.length === 0) return false
56
+ if (where === undefined) return true
57
+ const rows = await loader.load(model, others)
58
+ return [...rows.values()].some((row) => matches(model, row, where))
59
+ }
60
+
61
+ export const anyStale = async (
62
+ reads: readonly Recorded[],
63
+ batch: Batch,
64
+ loader: Loader,
65
+ ): Promise<boolean> => {
66
+ for (const read of reads) if (await stale(read, batch, loader)) return true
67
+ return false
68
+ }
@@ -0,0 +1,61 @@
1
+ // The related rows a live read shows (D-154): by relation path then id, the
2
+ // window's rows that reach each one, so a write to it resends them.
3
+ import type { Row } from 'ultimabase'
4
+ import type { LivePlan } from 'ultimabase/internal'
5
+
6
+ type Shown = { readonly [field: string]: unknown }
7
+
8
+ /** The rows `include` reached from `node`, as `[path, id]`. */
9
+ function reached(
10
+ node: Shown,
11
+ include: LivePlan['include'],
12
+ path: string,
13
+ out: (readonly [string, string])[],
14
+ ): void {
15
+ for (const [name, inner] of Object.entries(include ?? {})) {
16
+ const at = path === '' ? name : `${path}.${name}`
17
+ const value = node[name]
18
+ const rows: readonly unknown[] = Array.isArray(value) ? value : [value]
19
+ for (const row of rows) {
20
+ if (typeof row !== 'object' || row === null) continue
21
+ const id: unknown = Reflect.get(row, 'id')
22
+ if (typeof id === 'string') out.push([at, id])
23
+ reached(Object.fromEntries(Object.entries(row)), inner.include, at, out)
24
+ }
25
+ }
26
+ }
27
+
28
+ export class Tracking {
29
+ readonly tracked: Map<string, Map<string, Set<string>>> = new Map()
30
+ readonly #reaching = new Map<string, readonly (readonly [string, string])[]>()
31
+
32
+ clear(): void {
33
+ this.tracked.clear()
34
+ this.#reaching.clear()
35
+ }
36
+
37
+ /** Forgets the related rows the window's row `root` reached. */
38
+ untrack(root: string): void {
39
+ for (const [path, id] of this.#reaching.get(root) ?? []) {
40
+ const roots = this.tracked.get(path)?.get(id)
41
+ roots?.delete(root)
42
+ if (roots?.size === 0) this.tracked.get(path)?.delete(id)
43
+ }
44
+ this.#reaching.delete(root)
45
+ }
46
+
47
+ /** Keeps the related rows `node`, as read through `include`, reaches. */
48
+ track(node: Row, include: LivePlan['include']): void {
49
+ const out: (readonly [string, string])[] = []
50
+ reached(node, include, '', out)
51
+ this.untrack(node.id)
52
+ this.#reaching.set(node.id, out)
53
+ for (const [path, id] of out) {
54
+ const ids = this.tracked.get(path) ?? new Map<string, Set<string>>()
55
+ this.tracked.set(path, ids)
56
+ const roots = ids.get(id) ?? new Set<string>()
57
+ ids.set(id, roots)
58
+ roots.add(node.id)
59
+ }
60
+ }
61
+ }