@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/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
|
+
}
|
package/src/tracking.ts
ADDED
|
@@ -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
|
+
}
|