@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/dist/index.js ADDED
@@ -0,0 +1,52 @@
1
+ import { definePlugin } from 'ultimabase';
2
+ import manifest from '../package.json' with { type: 'json' };
3
+ import { Engine } from './engine.js';
4
+ import { ownerOf } from './viewer.js';
5
+ // Each running application's engine, by the context its plugin received.
6
+ const engines = new WeakMap();
7
+ const positive = (name, value) => {
8
+ if (value === undefined || (Number.isInteger(value) && value > 0))
9
+ return;
10
+ throw new Error(`realtime's ${name} must be a positive integer.`);
11
+ };
12
+ const realtimeDefinition = definePlugin({
13
+ name: 'realtime',
14
+ manifest,
15
+ api: (context) => {
16
+ const { options, identify, logger, models, functions } = context;
17
+ positive('maxLive', options.maxLive);
18
+ positive('maxSubscriptions', options.maxSubscriptions);
19
+ positive('maxConnectionRows', options.maxConnectionRows);
20
+ positive('refresh', options.refresh);
21
+ for (const model of models)
22
+ positive('maxLive', model.with?.maxLive);
23
+ for (const fn of functions) {
24
+ positive('debounce', fn.with?.debounce);
25
+ if (fn.used && !fn.query) {
26
+ throw new Error(`${ownerOf(fn)} is a mutation: it cannot be live.`);
27
+ }
28
+ }
29
+ const engine = new Engine({
30
+ models,
31
+ functions,
32
+ identify,
33
+ logger,
34
+ maxLive: options.maxLive ?? 1000,
35
+ maxSubscriptions: options.maxSubscriptions ?? 100,
36
+ maxConnectionRows: options.maxConnectionRows ?? 10_000,
37
+ }, options.refresh ?? 300_000);
38
+ engines.set(context, engine);
39
+ const live = new Set(models.filter(({ used }) => used).map(({ schema }) => schema.name));
40
+ const followed = new Set(functions.filter(({ used }) => used).map(ownerOf));
41
+ return {
42
+ connect: async (request) => engine.connect(request),
43
+ live: (model) => live.has(model),
44
+ liveFunction: (name) => followed.has(name),
45
+ };
46
+ },
47
+ changed: (changes, context) => engines.get(context)?.changed(changes),
48
+ missed: (context) => engines.get(context)?.missed(),
49
+ stop: async (context) => engines.get(context)?.stop(),
50
+ });
51
+ export const realtime = realtimeDefinition.use;
52
+ export const realtimePlugin = realtimeDefinition.plugin;
package/dist/live.d.ts ADDED
@@ -0,0 +1,37 @@
1
+ import type { Caller, Logger, ModelQueries } from 'ultimabase';
2
+ import type { LivePlan } from 'ultimabase/internal';
3
+ import type { Batch } from './changes.ts';
4
+ import type { Loader } from './reads.ts';
5
+ import type { LiveMessage } from './messages.ts';
6
+ export type Spec = {
7
+ readonly model: string;
8
+ readonly queries: ModelQueries;
9
+ /** The read's own `where`, which `totalCount` counts. */
10
+ readonly where: unknown;
11
+ /** Its `select`, `include` or `omit`, each level's `id` selected. */
12
+ readonly shape: {
13
+ readonly [key: string]: unknown;
14
+ };
15
+ readonly totalCount: boolean;
16
+ readonly row: boolean;
17
+ readonly send: (message: LiveMessage) => void;
18
+ readonly logger: Logger;
19
+ };
20
+ export declare class Live {
21
+ #private;
22
+ caller: Caller;
23
+ constructor(spec: Spec, caller: Caller, plan: LivePlan, first: number);
24
+ get first(): number;
25
+ /** Reads the window and sends it; a failure closes the read. */
26
+ start(): Promise<void>;
27
+ /** Follows `batch`, or reads the window again when changes were missed. */
28
+ apply(batch: Batch, loader: Loader, missed: boolean): Promise<void>;
29
+ /** Takes the caller's new plan; a changed one reads the window again. */
30
+ replan(caller: Caller, plan: LivePlan): Promise<void>;
31
+ /** Shows `more` rows after the last one. */
32
+ grow(more: number): Promise<void>;
33
+ /** Reads the window again and sends it whole. */
34
+ resend(): Promise<void>;
35
+ close(): void;
36
+ }
37
+ //# sourceMappingURL=live.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"live.d.ts","sourceRoot":"","sources":["../src/live.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,YAAY,EAAO,MAAM,YAAY,CAAA;AACnE,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,qBAAqB,CAAA;AAWnD,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,cAAc,CAAA;AAGzC,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,YAAY,CAAA;AAExC,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,eAAe,CAAA;AAKhD,MAAM,MAAM,IAAI,GAAG;IAClB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;IACtB,QAAQ,CAAC,OAAO,EAAE,YAAY,CAAA;IAC9B,yDAAyD;IACzD,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAA;IACvB,qEAAqE;IACrE,QAAQ,CAAC,KAAK,EAAE;QAAE,QAAQ,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAA;KAAE,CAAA;IACnD,QAAQ,CAAC,UAAU,EAAE,OAAO,CAAA;IAC5B,QAAQ,CAAC,GAAG,EAAE,OAAO,CAAA;IACrB,QAAQ,CAAC,IAAI,EAAE,CAAC,OAAO,EAAE,WAAW,KAAK,IAAI,CAAA;IAC7C,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CACvB,CAAA;AAED,qBAAa,IAAI;;IAChB,MAAM,EAAE,MAAM,CAAA;IAad,YAAY,IAAI,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,QAAQ,EAAE,KAAK,EAAE,MAAM,EAOpE;IAED,IAAI,KAAK,IAAI,MAAM,CAElB;IAED,gEAAgE;IAC1D,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAS3B;IAED,2EAA2E;IACrE,KAAK,CAAC,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CAKxE;IAED,yEAAyE;IACnE,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAY1D;IAED,4CAA4C;IACtC,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAQtC;IAED,iDAAiD;IAC3C,MAAM,IAAI,OAAO,CAAC,IAAI,CAAC,CAG5B;IAED,KAAK,IAAI,IAAI,CAGZ;CAsQD"}
package/dist/live.js ADDED
@@ -0,0 +1,309 @@
1
+ import { inMemory, liveSchema, matches, servedRows, sortKeys, storedRows, } from 'ultimabase/internal';
2
+ import { affected } from './affected.js';
3
+ import { reachOf } from './reach.js';
4
+ import { finderOf, pass } from './reads.js';
5
+ import { Window } from './window.js';
6
+ import { Tracking } from './tracking.js';
7
+ export class Live {
8
+ caller;
9
+ #plan;
10
+ #reach;
11
+ #window;
12
+ #spec;
13
+ #find;
14
+ #tracking = new Tracking();
15
+ #count;
16
+ #hasMore = false;
17
+ #revision = 0;
18
+ #queue = Promise.resolve();
19
+ #closed = false;
20
+ constructor(spec, caller, plan, first) {
21
+ this.#spec = spec;
22
+ this.caller = caller;
23
+ this.#plan = plan;
24
+ this.#reach = reachOf(liveSchema(), spec.model, plan);
25
+ this.#window = new Window(plan.orderBy, first);
26
+ this.#find = finderOf(spec.model);
27
+ }
28
+ get first() {
29
+ return this.#window.first;
30
+ }
31
+ /** Reads the window and sends it; a failure closes the read. */
32
+ async start() {
33
+ const run = this.#reset();
34
+ this.#queue = run.catch(() => undefined);
35
+ try {
36
+ await run;
37
+ }
38
+ catch (error) {
39
+ this.close();
40
+ throw error;
41
+ }
42
+ }
43
+ /** Follows `batch`, or reads the window again when changes were missed. */
44
+ async apply(batch, loader, missed) {
45
+ const [, settled] = this.#enqueue(async () => missed ? this.#reset() : this.#apply(batch, loader));
46
+ return settled;
47
+ }
48
+ /** Takes the caller's new plan; a changed one reads the window again. */
49
+ async replan(caller, plan) {
50
+ const [, settled] = this.#enqueue(async () => {
51
+ const same = JSON.stringify(plan) === JSON.stringify(this.#plan) &&
52
+ JSON.stringify(caller) === JSON.stringify(this.caller);
53
+ this.caller = caller;
54
+ if (same)
55
+ return;
56
+ this.#plan = plan;
57
+ this.#reach = reachOf(liveSchema(), this.#spec.model, plan);
58
+ await this.#reset();
59
+ });
60
+ return settled;
61
+ }
62
+ /** Shows `more` rows after the last one. */
63
+ async grow(more) {
64
+ const [run] = this.#enqueue(async () => {
65
+ this.#window.mark();
66
+ this.#window.first += more;
67
+ // Sent even when no row follows: the client waits for it.
68
+ await this.#send(new Set(), new Set(), false, true);
69
+ });
70
+ return run;
71
+ }
72
+ /** Reads the window again and sends it whole. */
73
+ async resend() {
74
+ const [, settled] = this.#enqueue(async () => this.#reset());
75
+ return settled;
76
+ }
77
+ close() {
78
+ this.#closed = true;
79
+ this.#tracking.clear();
80
+ }
81
+ #enqueue(task) {
82
+ const run = this.#queue.then(async () => this.#closed ? undefined : task());
83
+ const settled = run.catch(async (error) => this.#recover(error));
84
+ this.#queue = settled;
85
+ return [run, settled];
86
+ }
87
+ async #recover(error) {
88
+ const { logger, model, send } = this.#spec;
89
+ if (this.#closed)
90
+ return;
91
+ logger.error({ err: error }, `A live read of ${model} failed: it reads again.`);
92
+ try {
93
+ await this.#reset();
94
+ }
95
+ catch (again) {
96
+ logger.error({ err: again }, `A live read of ${model} failed: it closes.`);
97
+ this.close();
98
+ send({ kind: 'closed' });
99
+ }
100
+ }
101
+ #entry(row) {
102
+ const keys = sortKeys(this.#spec.model, row, this.#plan.orderBy);
103
+ return { id: row.id, keys };
104
+ }
105
+ /**
106
+ * The stored rows after the window's last, from `where` and in order:
107
+ * from the flush's read of the read's own filter when its rights, checked
108
+ * in memory, leave enough.
109
+ */
110
+ async #after(take, loader) {
111
+ const { model } = this.#spec;
112
+ const { where, filter, rights, orderBy } = this.#plan;
113
+ const last = this.#window.entries.at(-1);
114
+ const page = {
115
+ orderBy,
116
+ ...(last && { cursor: { id: { equals: last.id } }, skip: 1 }),
117
+ take,
118
+ };
119
+ if (loader !== undefined && inMemory(model, rights)) {
120
+ const rows = await loader.after(model, {
121
+ ...(filter && { where: filter }),
122
+ ...page,
123
+ });
124
+ const kept = rows.filter((row) => matches(model, row, rights));
125
+ if (kept.length >= take || rows.length < take)
126
+ return kept.slice(0, take);
127
+ }
128
+ return storedRows(model, {
129
+ ...(where && { where }),
130
+ ...page,
131
+ fields: orderBy.map(({ field }) => field),
132
+ });
133
+ }
134
+ async #refill(loader) {
135
+ const missing = this.#window.missing();
136
+ if (missing <= 0)
137
+ return;
138
+ const rows = await this.#after(missing + 1, loader);
139
+ const entries = rows.slice(0, missing).map((row) => this.#entry(row));
140
+ this.#window.fill(entries, rows.length > missing);
141
+ }
142
+ async #counted() {
143
+ const { queries, where } = this.#spec;
144
+ const args = where === undefined ? {} : { where };
145
+ return queries.count(args, { as: this.caller });
146
+ }
147
+ /**
148
+ * Reads `ids` as the caller into `nodes`, from the rows the flush loaded
149
+ * if any; returns those it cannot read.
150
+ */
151
+ async #read(ids, nodes, loader) {
152
+ if (ids.length === 0)
153
+ return [];
154
+ const { model, queries, shape } = this.#spec;
155
+ const args = { ...shape, where: { id: { in: ids } } };
156
+ const found = loader === undefined
157
+ ? await queries.findMany(args, { as: this.caller })
158
+ : await servedRows(model, args, this.caller, await loader.load(model, ids));
159
+ for (const node of found) {
160
+ nodes.set(node.id, node);
161
+ this.#tracking.track(node, this.#plan.include);
162
+ }
163
+ return ids.filter((id) => !nodes.has(id));
164
+ }
165
+ /**
166
+ * Refills the window and reads the rows it places; a row the caller
167
+ * cannot read leaves it, `gone`.
168
+ */
169
+ async #settle(again, gone, loader) {
170
+ const nodes = new Map();
171
+ for (let round = 0; round < 3; round += 1) {
172
+ await this.#refill(loader);
173
+ const moves = this.#window.moves(again);
174
+ const unread = moves.placed.flatMap(({ id }) => nodes.has(id) ? [] : [id]);
175
+ const missing = await this.#read(unread, nodes, loader);
176
+ if (missing.length === 0)
177
+ return { moves, nodes };
178
+ for (const id of missing) {
179
+ this.#window.remove(id);
180
+ gone.add(id);
181
+ }
182
+ }
183
+ throw new Error('The rows the live plan finds are not readable.');
184
+ }
185
+ #next() {
186
+ this.#revision += 1;
187
+ return this.#revision;
188
+ }
189
+ async #reset() {
190
+ const { model, row, send, totalCount } = this.#spec;
191
+ const { where, orderBy } = this.#plan;
192
+ const { first } = this.#window;
193
+ this.#window = new Window(orderBy, first);
194
+ this.#tracking.clear();
195
+ const rows = await storedRows(model, {
196
+ ...(where && { where }),
197
+ orderBy,
198
+ take: first + 2,
199
+ fields: orderBy.map(({ field }) => field),
200
+ });
201
+ const entries = rows.slice(0, first + 1).map((each) => this.#entry(each));
202
+ this.#window.fill(entries, rows.length > first + 1);
203
+ const { nodes } = await this.#settle(new Set(), new Set());
204
+ this.#count = totalCount ? await this.#counted() : undefined;
205
+ this.#hasMore = this.#window.hasMore();
206
+ const shown = this.#window.shown().flatMap(({ id }) => nodes.get(id) ?? []);
207
+ if (this.#closed)
208
+ return;
209
+ if (row) {
210
+ send({ kind: 'row', revision: this.#next(), row: shown[0] ?? null });
211
+ return;
212
+ }
213
+ send({
214
+ kind: 'reset',
215
+ revision: this.#next(),
216
+ rows: shown,
217
+ hasMore: this.#hasMore,
218
+ ...(this.#count !== undefined && { totalCount: this.#count }),
219
+ });
220
+ }
221
+ async #apply(batch, loader) {
222
+ const { model, totalCount } = this.#spec;
223
+ const window = this.#window;
224
+ const effect = await affected({
225
+ model,
226
+ reach: this.#reach,
227
+ window: window.ids(),
228
+ tracked: this.#tracking.tracked,
229
+ find: this.#find,
230
+ }, batch);
231
+ if (effect === 'resync')
232
+ return this.#reset();
233
+ const gone = new Set(effect.deleted);
234
+ const candidates = [...effect.candidates].filter((id) => !gone.has(id));
235
+ const rows = await loader.load(model, candidates);
236
+ const kept = await pass(model, rows, this.#plan.where);
237
+ const inside = window.ids();
238
+ const leaving = candidates.filter((id) => inside.has(id) && !kept.has(id));
239
+ const left = new Map(leaving.flatMap((id) => rows.get(id) ?? []).map((row) => [row.id, row]));
240
+ const readable = await pass(model, left, this.#plan.rights);
241
+ const recount = totalCount &&
242
+ (candidates.length > 0 || effect.deleted.size > 0 || effect.beyond);
243
+ // A write that leaves the window as it was costs no pass over it.
244
+ const touched = gone.size > 0 ||
245
+ effect.resend.size > 0 ||
246
+ kept.size > 0 ||
247
+ leaving.length > 0;
248
+ if (!touched && !recount)
249
+ return;
250
+ window.mark();
251
+ for (const id of effect.deleted)
252
+ window.remove(id);
253
+ for (const id of leaving) {
254
+ window.remove(id);
255
+ if (!readable.has(id))
256
+ gone.add(id);
257
+ }
258
+ for (const id of kept) {
259
+ const row = rows.get(id);
260
+ if (row !== undefined)
261
+ window.place(this.#entry(row));
262
+ }
263
+ await this.#send(effect.resend, gone, recount, false, loader);
264
+ }
265
+ /** Sends how the rows shown at the mark changed, if they did or `always`. */
266
+ async #send(again, gone, recount, always = false, loader) {
267
+ const { row, send } = this.#spec;
268
+ const { moves, nodes } = await this.#settle(again, gone, loader);
269
+ for (const id of moves.removed)
270
+ this.#tracking.untrack(id);
271
+ let totalCount;
272
+ if (recount) {
273
+ const count = await this.#counted();
274
+ if (count !== this.#count)
275
+ totalCount = count;
276
+ this.#count = count;
277
+ }
278
+ if (this.#closed)
279
+ return;
280
+ if (row) {
281
+ if (moves.removed.length + moves.placed.length === 0)
282
+ return;
283
+ const [entry] = this.#window.shown();
284
+ const shown = entry === undefined ? null : (nodes.get(entry.id) ?? null);
285
+ send({ kind: 'row', revision: this.#next(), row: shown });
286
+ return;
287
+ }
288
+ const changes = [
289
+ ...moves.removed.map((id) => ({ remove: id, gone: gone.has(id) })),
290
+ ...moves.placed.flatMap(({ id, after }) => {
291
+ const node = nodes.get(id);
292
+ return node === undefined ? [] : [{ upsert: node, after }];
293
+ }),
294
+ ];
295
+ const hasMore = this.#window.hasMore();
296
+ const more = hasMore !== this.#hasMore;
297
+ this.#hasMore = hasMore;
298
+ if (changes.length === 0 && !more && totalCount === undefined && !always) {
299
+ return;
300
+ }
301
+ send({
302
+ kind: 'changes',
303
+ revision: this.#next(),
304
+ changes,
305
+ ...(more && { hasMore }),
306
+ ...(totalCount !== undefined && { totalCount }),
307
+ });
308
+ }
309
+ }
@@ -0,0 +1,42 @@
1
+ import type { Row } from 'ultimabase';
2
+ /** A row that left the window, `gone` when deleted or no longer readable. */
3
+ export type Removal = {
4
+ readonly remove: string;
5
+ readonly gone: boolean;
6
+ };
7
+ /** A row placed after another, first with `after: null`. */
8
+ export type Upsert = {
9
+ readonly upsert: Row;
10
+ readonly after: string | null;
11
+ };
12
+ /** What a live read sends; `revision` grows by one per message. */
13
+ export type LiveMessage = {
14
+ readonly kind: 'reset';
15
+ readonly revision: number;
16
+ readonly rows: readonly Row[];
17
+ readonly hasMore: boolean;
18
+ readonly totalCount?: number;
19
+ } | {
20
+ readonly kind: 'changes';
21
+ readonly revision: number;
22
+ readonly changes: readonly (Removal | Upsert)[];
23
+ /** Only when it changed. */
24
+ readonly hasMore?: boolean;
25
+ /** Only when it changed. */
26
+ readonly totalCount?: number;
27
+ } | {
28
+ readonly kind: 'row';
29
+ readonly revision: number;
30
+ readonly row: Row | null;
31
+ } | {
32
+ readonly kind: 'value';
33
+ readonly revision: number;
34
+ /** A live function's whole result. */
35
+ readonly value: unknown;
36
+ }
37
+ /** With the error a live function failed with, which closed it. */
38
+ | {
39
+ readonly kind: 'closed';
40
+ readonly error?: unknown;
41
+ };
42
+ //# sourceMappingURL=messages.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"messages.d.ts","sourceRoot":"","sources":["../src/messages.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,YAAY,CAAA;AAErC,6EAA6E;AAC7E,MAAM,MAAM,OAAO,GAAG;IAAE,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAA;CAAE,CAAA;AACzE,4DAA4D;AAC5D,MAAM,MAAM,MAAM,GAAG;IAAE,QAAQ,CAAC,MAAM,EAAE,GAAG,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,CAAA;AAE5E,mEAAmE;AACnE,MAAM,MAAM,WAAW,GACpB;IACA,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAA;IACtB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;IACzB,QAAQ,CAAC,IAAI,EAAE,SAAS,GAAG,EAAE,CAAA;IAC7B,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAA;IACzB,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAA;CAC3B,GACD;IACA,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAA;IACxB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;IACzB,QAAQ,CAAC,OAAO,EAAE,SAAS,CAAC,OAAO,GAAG,MAAM,CAAC,EAAE,CAAA;IAC/C,4BAA4B;IAC5B,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,CAAA;IAC1B,4BAA4B;IAC5B,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAA;CAC3B,GACD;IACA,QAAQ,CAAC,IAAI,EAAE,KAAK,CAAA;IACpB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;IACzB,QAAQ,CAAC,GAAG,EAAE,GAAG,GAAG,IAAI,CAAA;CACvB,GACD;IACA,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAA;IACtB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;IACzB,sCAAsC;IACtC,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAA;CACtB;AACH,mEAAmE;GACjE;IAAE,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC;IAAC,QAAQ,CAAC,KAAK,CAAC,EAAE,OAAO,CAAA;CAAE,CAAA"}
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,57 @@
1
+ import type { Filter, Schema, SchemaField } from 'ultimabase';
2
+ import type { Fields } from './changes.ts';
3
+ import type { LivePlan } from 'ultimabase/internal';
4
+ /** A relation followed from one model to the next. */
5
+ export type Step = {
6
+ readonly relation: string;
7
+ readonly many: boolean;
8
+ readonly target: string;
9
+ readonly inverse: string;
10
+ /** The target stores the key: a link changes there, out of sight. */
11
+ readonly keyOnTarget: boolean;
12
+ };
13
+ /** A level of a filter: its rows are those its steps reach from the root. */
14
+ export type Level = {
15
+ readonly steps: readonly Step[];
16
+ readonly model: string;
17
+ /** The fields it reads, the key back to the level above included. */
18
+ readonly fields: Fields;
19
+ /** Under `NOT`, `isNot`, `every`, `none` or `is: null`: a lost link may add a row. */
20
+ readonly negative: boolean;
21
+ };
22
+ /** A relation the selection shows, or counts. */
23
+ export type Shown = {
24
+ /** The relations from the root, joined by dots. */
25
+ readonly path: string;
26
+ readonly steps: readonly Step[];
27
+ readonly model: string;
28
+ /** The fields of its rows that show, or decide which rows show. */
29
+ readonly fields: Fields;
30
+ /** A `_count`: its rows are not in the result to track. */
31
+ readonly counted: boolean;
32
+ };
33
+ export type Reach = {
34
+ /** The filter's levels, the root's first. */
35
+ readonly levels: readonly Level[];
36
+ /** The fields that place a root row: its filter's and its order's. */
37
+ readonly placing: Fields;
38
+ /** The root fields that show. */
39
+ readonly showing: Fields;
40
+ readonly shown: readonly Shown[];
41
+ /** Models a nested filter or order reaches further: any change resends all. */
42
+ readonly deep: ReadonlySet<string>;
43
+ };
44
+ type Models = ReadonlyMap<string, ReadonlyMap<string, SchemaField>>;
45
+ /** `schema`'s fields by model and name. */
46
+ export declare function modelsOf(schema: Schema): Models;
47
+ /** The relation `name` of `model`, if it is one. */
48
+ export declare function stepOf(models: Models, model: string, name: string): Step | undefined;
49
+ /**
50
+ * Adds the levels of `filter`, read on `model`'s rows, to `levels`; returns
51
+ * the fields it reads on them.
52
+ */
53
+ export declare function walk(models: Models, model: string, filter: Filter | undefined, steps: readonly Step[], negative: boolean, levels: Level[]): Fields;
54
+ /** Where `plan`, a live read of `model`, reaches. */
55
+ export declare function reachOf(schema: Schema, model: string, plan: LivePlan): Reach;
56
+ export {};
57
+ //# sourceMappingURL=reach.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"reach.d.ts","sourceRoot":"","sources":["../src/reach.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAa,MAAM,EAAU,MAAM,EAAE,WAAW,EAAE,MAAM,YAAY,CAAA;AAChF,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,cAAc,CAAA;AAC1C,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,qBAAqB,CAAA;AAEnD,sDAAsD;AACtD,MAAM,MAAM,IAAI,GAAG;IAClB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;IACzB,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAA;IACtB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;IACxB,qEAAqE;IACrE,QAAQ,CAAC,WAAW,EAAE,OAAO,CAAA;CAC7B,CAAA;AAED,6EAA6E;AAC7E,MAAM,MAAM,KAAK,GAAG;IACnB,QAAQ,CAAC,KAAK,EAAE,SAAS,IAAI,EAAE,CAAA;IAC/B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;IACtB,qEAAqE;IACrE,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,sFAAsF;IACtF,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAA;CAC1B,CAAA;AAED,iDAAiD;AACjD,MAAM,MAAM,KAAK,GAAG;IACnB,mDAAmD;IACnD,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,KAAK,EAAE,SAAS,IAAI,EAAE,CAAA;IAC/B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;IACtB,mEAAmE;IACnE,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,2DAA2D;IAC3D,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAA;CACzB,CAAA;AAED,MAAM,MAAM,KAAK,GAAG;IACnB,6CAA6C;IAC7C,QAAQ,CAAC,MAAM,EAAE,SAAS,KAAK,EAAE,CAAA;IACjC,sEAAsE;IACtE,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;IACxB,iCAAiC;IACjC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;IACxB,QAAQ,CAAC,KAAK,EAAE,SAAS,KAAK,EAAE,CAAA;IAChC,+EAA+E;IAC/E,QAAQ,CAAC,IAAI,EAAE,WAAW,CAAC,MAAM,CAAC,CAAA;CAClC,CAAA;AAED,KAAK,MAAM,GAAG,WAAW,CAAC,MAAM,EAAE,WAAW,CAAC,MAAM,EAAE,WAAW,CAAC,CAAC,CAAA;AAInE,2CAA2C;AAC3C,wBAAgB,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAW/C;AAED,oDAAoD;AACpD,wBAAgB,MAAM,CACrB,MAAM,EAAE,MAAM,EACd,KAAK,EAAE,MAAM,EACb,IAAI,EAAE,MAAM,GACV,IAAI,GAAG,SAAS,CAWlB;AAWD;;;GAGG;AACH,wBAAgB,IAAI,CACnB,MAAM,EAAE,MAAM,EACd,KAAK,EAAE,MAAM,EACb,MAAM,EAAE,MAAM,GAAG,SAAS,EAC1B,KAAK,EAAE,SAAS,IAAI,EAAE,EACtB,QAAQ,EAAE,OAAO,EACjB,MAAM,EAAE,KAAK,EAAE,GACb,MAAM,CAgCR;AA6FD,qDAAqD;AACrD,wBAAgB,OAAO,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,QAAQ,GAAG,KAAK,CAgB5E"}
package/dist/reach.js ADDED
@@ -0,0 +1,154 @@
1
+ const indexes = new WeakMap();
2
+ /** `schema`'s fields by model and name. */
3
+ export function modelsOf(schema) {
4
+ const known = indexes.get(schema);
5
+ if (known !== undefined)
6
+ return known;
7
+ const models = new Map(schema.models.map(({ name, fields }) => [
8
+ name,
9
+ new Map(fields.map((field) => [field.name, field])),
10
+ ]));
11
+ indexes.set(schema, models);
12
+ return models;
13
+ }
14
+ /** The relation `name` of `model`, if it is one. */
15
+ export function stepOf(models, model, name) {
16
+ const field = models.get(model)?.get(name);
17
+ if (field === undefined || field.kind === 'scalar')
18
+ return undefined;
19
+ const back = models.get(field.target)?.get(field.inverse);
20
+ return {
21
+ relation: name,
22
+ many: field.kind === 'many',
23
+ target: field.target,
24
+ inverse: field.inverse,
25
+ keyOnTarget: back?.kind === 'one' && back.stored,
26
+ };
27
+ }
28
+ const relationKeys = ['is', 'isNot', 'some', 'every', 'none'];
29
+ const isList = (value) => Array.isArray(value);
30
+ /**
31
+ * Adds the levels of `filter`, read on `model`'s rows, to `levels`; returns
32
+ * the fields it reads on them.
33
+ */
34
+ export function walk(models, model, filter, steps, negative, levels) {
35
+ const fields = new Set();
36
+ const back = steps.at(-1);
37
+ if (back !== undefined)
38
+ fields.add(back.inverse);
39
+ let all = false;
40
+ const visit = (part, under) => {
41
+ for (const [key, value] of Object.entries(part ?? {})) {
42
+ if (value === undefined)
43
+ continue;
44
+ if (isList(value)) {
45
+ for (const inner of value)
46
+ visit(inner, under || key === 'NOT');
47
+ continue;
48
+ }
49
+ if (key === '_search')
50
+ all = true;
51
+ fields.add(key);
52
+ const step = stepOf(models, model, key);
53
+ if (step === undefined)
54
+ continue;
55
+ for (const condition of relationKeys) {
56
+ const inner = value[condition];
57
+ if (inner === undefined)
58
+ continue;
59
+ const lost = condition === 'is'
60
+ ? inner === null
61
+ : condition !== 'some' && !(condition === 'isNot' && inner === null);
62
+ const next = [...steps, step];
63
+ walk(models, step.target, inner ?? {}, next, under || lost, levels);
64
+ }
65
+ }
66
+ };
67
+ visit(filter, negative);
68
+ const read = all ? 'all' : fields;
69
+ levels.push({ steps, model, fields: read, negative });
70
+ return read;
71
+ }
72
+ /** The fields a nested read shows: `'all'` when it lists none. */
73
+ function showing(nested, also = []) {
74
+ if (nested.fields === undefined)
75
+ return 'all';
76
+ return new Set([
77
+ ...nested.fields,
78
+ ...Object.keys(nested.include ?? {}),
79
+ ...Object.keys(nested.count ?? {}),
80
+ ...also,
81
+ ]);
82
+ }
83
+ const union = (a, b) => a === 'all' || b === 'all' ? 'all' : new Set([...a, ...b]);
84
+ /** The models a nested filter or order reaches beyond `model`. */
85
+ function further(models, model, where, orderBy, deep) {
86
+ const levels = [];
87
+ const fields = walk(models, model, where, [], false, levels);
88
+ for (const level of levels) {
89
+ if (level.steps.length > 0)
90
+ deep.add(level.model);
91
+ }
92
+ for (const order of orderBy ?? []) {
93
+ let on = model;
94
+ for (const { relation } of order.through ?? []) {
95
+ on = stepOf(models, on, relation)?.target ?? on;
96
+ deep.add(on);
97
+ }
98
+ const counted = order.count && stepOf(models, on, order.field);
99
+ if (counted)
100
+ deep.add(counted.target);
101
+ }
102
+ return fields;
103
+ }
104
+ /** Adds what `nested`, read on `model`'s rows at `path`, shows. */
105
+ function walkShown(models, model, nested, at, shown, deep) {
106
+ const named = (name) => (at.path ? `${at.path}.${name}` : name);
107
+ for (const [name, inner] of Object.entries(nested.include ?? {})) {
108
+ const step = stepOf(models, model, name);
109
+ if (step === undefined)
110
+ continue;
111
+ const steps = [...at.steps, step];
112
+ const filtered = further(models, step.target, inner.where, inner.orderBy, deep);
113
+ const fields = union(showing(inner, [step.inverse]), filtered);
114
+ shown.push({
115
+ path: named(name),
116
+ steps,
117
+ model: step.target,
118
+ fields,
119
+ counted: false,
120
+ });
121
+ walkShown(models, step.target, inner, { path: named(name), steps }, shown, deep);
122
+ }
123
+ for (const [name, where] of Object.entries(nested.count ?? {})) {
124
+ const step = stepOf(models, model, name);
125
+ if (step === undefined)
126
+ continue;
127
+ const filtered = further(models, step.target, where, undefined, deep);
128
+ shown.push({
129
+ path: named(`_count.${name}`),
130
+ steps: [...at.steps, step],
131
+ model: step.target,
132
+ fields: union(new Set([step.inverse]), filtered),
133
+ counted: true,
134
+ });
135
+ }
136
+ }
137
+ /** Where `plan`, a live read of `model`, reaches. */
138
+ export function reachOf(schema, model, plan) {
139
+ const models = modelsOf(schema);
140
+ const levels = [];
141
+ const root = walk(models, model, plan.where, [], false, levels);
142
+ levels.reverse();
143
+ const order = new Set(plan.orderBy.map(({ field }) => field));
144
+ const shown = [];
145
+ const deep = new Set();
146
+ walkShown(models, model, plan, { path: '', steps: [] }, shown, deep);
147
+ return {
148
+ levels,
149
+ placing: union(root, order),
150
+ showing: showing(plan),
151
+ shown,
152
+ deep,
153
+ };
154
+ }