@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.
- package/CHANGELOG.md +12 -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/dist/reads.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Filter, Nested, Row } from 'ultimabase';
|
|
2
|
+
import type { Finder } from './affected.ts';
|
|
3
|
+
import type { Step } from './reach.ts';
|
|
4
|
+
/** A filter on the root that holds when `steps` reach a row passing `inner`. */
|
|
5
|
+
export declare const through: (steps: readonly Step[], inner: Filter) => Filter;
|
|
6
|
+
/** Finds root rows of `model` through one indexed query per call. */
|
|
7
|
+
export declare const finderOf: (model: string) => Finder;
|
|
8
|
+
/**
|
|
9
|
+
* Each changed row, read once per flush whatever reads it, and each page
|
|
10
|
+
* of rows after a window's last, once for the live reads that share it.
|
|
11
|
+
*/
|
|
12
|
+
export declare class Loader {
|
|
13
|
+
#private;
|
|
14
|
+
/** The stored rows `read` finds, whose rows `load` then gives. */
|
|
15
|
+
after(model: string, read: Nested): Promise<readonly Row[]>;
|
|
16
|
+
/** The stored rows of `model` in `ids`; a deleted row is missing. */
|
|
17
|
+
load(model: string, ids: readonly string[]): Promise<ReadonlyMap<string, Row>>;
|
|
18
|
+
}
|
|
19
|
+
/** The ids of `rows` that pass `filter`: in memory when it reads the row alone. */
|
|
20
|
+
export declare function pass(model: string, rows: ReadonlyMap<string, Row>, filter: Filter | undefined): Promise<ReadonlySet<string>>;
|
|
21
|
+
//# sourceMappingURL=reads.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"reads.d.ts","sourceRoot":"","sources":["../src/reads.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,YAAY,CAAA;AAErD,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,eAAe,CAAA;AAC3C,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,YAAY,CAAA;AAEtC,gFAAgF;AAChF,eAAO,MAAM,OAAO,UAAW,SAAS,IAAI,EAAE,SAAS,MAAM,KAAG,MAM9D,CAAA;AAKF,qEAAqE;AACrE,eAAO,MAAM,QAAQ,UACZ,MAAM,KAAG,MAMhB,CAAA;AAEF;;;GAGG;AACH,qBAAa,MAAM;;IAWlB,kEAAkE;IAC5D,KAAK,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,GAAG,EAAE,CAAC,CAahE;IAED,qEAAqE;IAC/D,IAAI,CACT,KAAK,EAAE,MAAM,EACb,GAAG,EAAE,SAAS,MAAM,EAAE,GACpB,OAAO,CAAC,WAAW,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC,CAoBnC;CACD;AAED,mFAAmF;AACnF,wBAAsB,IAAI,CACzB,KAAK,EAAE,MAAM,EACb,IAAI,EAAE,WAAW,CAAC,MAAM,EAAE,GAAG,CAAC,EAC9B,MAAM,EAAE,MAAM,GAAG,SAAS,GACxB,OAAO,CAAC,WAAW,CAAC,MAAM,CAAC,CAAC,CAS9B"}
|
package/dist/reads.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { inMemory, matches, storedRows } from 'ultimabase/internal';
|
|
2
|
+
/** A filter on the root that holds when `steps` reach a row passing `inner`. */
|
|
3
|
+
export const through = (steps, inner) => steps.reduceRight((filter, { relation, many }) => ({
|
|
4
|
+
[relation]: many ? { some: filter } : { is: filter },
|
|
5
|
+
}), inner);
|
|
6
|
+
/** The `model` rows in `ids`, as one filter. */
|
|
7
|
+
const among = (ids) => ({ id: { in: [...ids] } });
|
|
8
|
+
/** Finds root rows of `model` through one indexed query per call. */
|
|
9
|
+
export const finderOf = (model) => async (steps, ids, within) => {
|
|
10
|
+
const reach = through(steps, among(ids));
|
|
11
|
+
const where = within === undefined ? reach : { AND: [among(within), reach] };
|
|
12
|
+
const rows = await storedRows(model, { where, fields: ['id'] });
|
|
13
|
+
return rows.map(({ id }) => id);
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Each changed row, read once per flush whatever reads it, and each page
|
|
17
|
+
* of rows after a window's last, once for the live reads that share it.
|
|
18
|
+
*/
|
|
19
|
+
export class Loader {
|
|
20
|
+
#rows = new Map();
|
|
21
|
+
#pages = new Map();
|
|
22
|
+
#known(model) {
|
|
23
|
+
const known = this.#rows.get(model) ?? new Map();
|
|
24
|
+
this.#rows.set(model, known);
|
|
25
|
+
return known;
|
|
26
|
+
}
|
|
27
|
+
/** The stored rows `read` finds, whose rows `load` then gives. */
|
|
28
|
+
async after(model, read) {
|
|
29
|
+
const key = JSON.stringify([model, read]);
|
|
30
|
+
let page = this.#pages.get(key);
|
|
31
|
+
if (page === undefined) {
|
|
32
|
+
page = storedRows(model, read);
|
|
33
|
+
this.#pages.set(key, page);
|
|
34
|
+
const rows = await page;
|
|
35
|
+
const known = this.#known(model);
|
|
36
|
+
for (const row of rows) {
|
|
37
|
+
if (!known.has(row.id))
|
|
38
|
+
known.set(row.id, Promise.resolve(row));
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return page;
|
|
42
|
+
}
|
|
43
|
+
/** The stored rows of `model` in `ids`; a deleted row is missing. */
|
|
44
|
+
async load(model, ids) {
|
|
45
|
+
const known = this.#known(model);
|
|
46
|
+
const missing = ids.filter((id) => !known.has(id));
|
|
47
|
+
if (missing.length > 0) {
|
|
48
|
+
const read = storedRows(model, { where: among(missing) }).then((rows) => new Map(rows.map((row) => [row.id, row])));
|
|
49
|
+
for (const id of missing) {
|
|
50
|
+
known.set(id, read.then((rows) => rows.get(id)));
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const found = new Map();
|
|
54
|
+
for (const id of ids) {
|
|
55
|
+
const row = await known.get(id);
|
|
56
|
+
if (row !== undefined)
|
|
57
|
+
found.set(id, row);
|
|
58
|
+
}
|
|
59
|
+
return found;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/** The ids of `rows` that pass `filter`: in memory when it reads the row alone. */
|
|
63
|
+
export async function pass(model, rows, filter) {
|
|
64
|
+
if (filter === undefined || rows.size === 0)
|
|
65
|
+
return new Set(rows.keys());
|
|
66
|
+
if (inMemory(model, filter)) {
|
|
67
|
+
const kept = [...rows.values()].filter((row) => matches(model, row, filter));
|
|
68
|
+
return new Set(kept.map(({ id }) => id));
|
|
69
|
+
}
|
|
70
|
+
const where = { AND: [among([...rows.keys()]), filter] };
|
|
71
|
+
const kept = await storedRows(model, { where, fields: ['id'] });
|
|
72
|
+
return new Set(kept.map(({ id }) => id));
|
|
73
|
+
}
|
package/dist/shape.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
type Args = {
|
|
2
|
+
readonly [key: string]: unknown;
|
|
3
|
+
};
|
|
4
|
+
/** What `livePlan` reads: `args` without the engine's own keys. */
|
|
5
|
+
export declare function liveArgs(args: unknown, row: boolean): unknown;
|
|
6
|
+
export type Shape = {
|
|
7
|
+
readonly first: number;
|
|
8
|
+
readonly where: unknown;
|
|
9
|
+
readonly totalCount: boolean;
|
|
10
|
+
/** The selection each window row is read with. */
|
|
11
|
+
readonly shape: Args;
|
|
12
|
+
};
|
|
13
|
+
/** `args`' window and selection; a list's `first` is a positive integer. */
|
|
14
|
+
export declare function shapeOf(args: unknown, row: boolean): Shape;
|
|
15
|
+
export {};
|
|
16
|
+
//# sourceMappingURL=shape.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"shape.d.ts","sourceRoot":"","sources":["../src/shape.ts"],"names":[],"mappings":"AAKA,KAAK,IAAI,GAAG;IAAE,QAAQ,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAA;CAAE,CAAA;AAwC/C,mEAAmE;AACnE,wBAAgB,QAAQ,CAAC,IAAI,EAAE,OAAO,EAAE,GAAG,EAAE,OAAO,GAAG,OAAO,CAK7D;AAED,MAAM,MAAM,KAAK,GAAG;IACnB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;IACtB,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAA;IACvB,QAAQ,CAAC,UAAU,EAAE,OAAO,CAAA;IAC5B,kDAAkD;IAClD,QAAQ,CAAC,KAAK,EAAE,IAAI,CAAA;CACpB,CAAA;AAED,4EAA4E;AAC5E,wBAAgB,OAAO,CAAC,IAAI,EAAE,OAAO,EAAE,GAAG,EAAE,OAAO,GAAG,KAAK,CAuB1D"}
|
package/dist/shape.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
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
|
+
const isArgs = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
6
|
+
const invalid = (path, message) => new PublicError('VALIDATION_FAILED', message, {
|
|
7
|
+
details: { issues: [{ path: [path], message }] },
|
|
8
|
+
});
|
|
9
|
+
/** `nested`, a relation's value in a selection, with its rows' `id`. */
|
|
10
|
+
const nestedWithIds = (nested) => isArgs(nested) ? withIds(nested) : nested;
|
|
11
|
+
/** `args`' selection, each level's `id` selected and never omitted. */
|
|
12
|
+
function withIds(args) {
|
|
13
|
+
const { select, include, omit, ...rest } = args;
|
|
14
|
+
const out = { ...rest };
|
|
15
|
+
if (isArgs(select)) {
|
|
16
|
+
const fields = Object.entries(select).map(([field, value]) => [
|
|
17
|
+
field,
|
|
18
|
+
field === '_count' ? value : nestedWithIds(value),
|
|
19
|
+
]);
|
|
20
|
+
out['select'] = { ...Object.fromEntries(fields), id: true };
|
|
21
|
+
}
|
|
22
|
+
else if (select !== undefined)
|
|
23
|
+
out['select'] = select;
|
|
24
|
+
if (isArgs(include)) {
|
|
25
|
+
const fields = Object.entries(include).map(([field, value]) => [
|
|
26
|
+
field,
|
|
27
|
+
field === '_count' ? value : nestedWithIds(value),
|
|
28
|
+
]);
|
|
29
|
+
out['include'] = Object.fromEntries(fields);
|
|
30
|
+
}
|
|
31
|
+
else if (include !== undefined)
|
|
32
|
+
out['include'] = include;
|
|
33
|
+
if (isArgs(omit)) {
|
|
34
|
+
out['omit'] = Object.fromEntries(Object.entries(omit).filter(([field]) => field !== 'id'));
|
|
35
|
+
}
|
|
36
|
+
else if (omit !== undefined)
|
|
37
|
+
out['omit'] = omit;
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
/** What `livePlan` reads: `args` without the engine's own keys. */
|
|
41
|
+
export function liveArgs(args, row) {
|
|
42
|
+
if (!isArgs(args))
|
|
43
|
+
return args;
|
|
44
|
+
if (row)
|
|
45
|
+
return withIds(args);
|
|
46
|
+
const { first: _first, totalCount: _totalCount, ...rest } = args;
|
|
47
|
+
return withIds(rest);
|
|
48
|
+
}
|
|
49
|
+
/** `args`' window and selection; a list's `first` is a positive integer. */
|
|
50
|
+
export function shapeOf(args, row) {
|
|
51
|
+
const read = isArgs(args) ? args : {};
|
|
52
|
+
const { where, first, totalCount } = read;
|
|
53
|
+
if (!row &&
|
|
54
|
+
(!Number.isInteger(first) || typeof first !== 'number' || first < 1)) {
|
|
55
|
+
throw invalid('first', 'first must be a positive integer.');
|
|
56
|
+
}
|
|
57
|
+
if (!row && totalCount !== undefined && typeof totalCount !== 'boolean') {
|
|
58
|
+
throw invalid('totalCount', 'totalCount must be a boolean.');
|
|
59
|
+
}
|
|
60
|
+
const { select, include, omit } = withIds(read);
|
|
61
|
+
return {
|
|
62
|
+
first: typeof first === 'number' && !row ? first : 1,
|
|
63
|
+
where,
|
|
64
|
+
totalCount: !row && totalCount === true,
|
|
65
|
+
shape: {
|
|
66
|
+
...(select !== undefined && { select }),
|
|
67
|
+
...(include !== undefined && { include }),
|
|
68
|
+
...(omit !== undefined && { omit }),
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
package/dist/stale.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { Recorded } from 'ultimabase/internal';
|
|
2
|
+
import type { Batch } from './changes.ts';
|
|
3
|
+
import type { Loader } from './reads.ts';
|
|
4
|
+
export declare const anyStale: (reads: readonly Recorded[], batch: Batch, loader: Loader) => Promise<boolean>;
|
|
5
|
+
//# sourceMappingURL=stale.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"stale.d.ts","sourceRoot":"","sources":["../src/stale.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,qBAAqB,CAAA;AAEnD,OAAO,KAAK,EAAE,KAAK,EAAU,MAAM,cAAc,CAAA;AAIjD,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,YAAY,CAAA;AAoDxC,eAAO,MAAM,QAAQ,UACb,SAAS,QAAQ,EAAE,SACnB,KAAK,UACJ,MAAM,KACZ,OAAO,CAAC,OAAO,CAGjB,CAAA"}
|
package/dist/stale.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { inMemory, liveSchema, matches } from 'ultimabase/internal';
|
|
2
|
+
import { listed, touched } from './changes.js';
|
|
3
|
+
import { modelsOf, stepOf, walk } from './reach.js';
|
|
4
|
+
const fieldsOf = (fields) => fields === undefined ? 'all' : new Set(fields);
|
|
5
|
+
/** Whether a write in `batch` may change what `read` found. */
|
|
6
|
+
async function stale(read, batch, loader) {
|
|
7
|
+
const models = modelsOf(liveSchema());
|
|
8
|
+
if (read.relation !== undefined) {
|
|
9
|
+
const [model = '', field = ''] = read.relation.split('.');
|
|
10
|
+
const step = stepOf(models, model, field);
|
|
11
|
+
if (touched(batch.get(model), new Set([field])))
|
|
12
|
+
return true;
|
|
13
|
+
if (step && touched(batch.get(step.target), new Set([step.inverse]))) {
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
const { model, where, ids } = read;
|
|
18
|
+
if (where !== undefined && !inMemory(model, where)) {
|
|
19
|
+
const levels = [];
|
|
20
|
+
walk(models, model, where, [], false, levels);
|
|
21
|
+
return levels.some((level) => batch.has(level.model));
|
|
22
|
+
}
|
|
23
|
+
const changes = batch.get(model);
|
|
24
|
+
if (changes === undefined)
|
|
25
|
+
return false;
|
|
26
|
+
const { inserts, deletes } = changes;
|
|
27
|
+
const filters = fieldsOf(read.filters);
|
|
28
|
+
const any = read.fields === undefined || filters === 'all'
|
|
29
|
+
? 'all'
|
|
30
|
+
: new Set([...read.fields, ...filters]);
|
|
31
|
+
// An update that sets no field it returned or chose its rows by is none.
|
|
32
|
+
const updates = touched(changes, any) ? changes.updates : undefined;
|
|
33
|
+
if (ids === undefined || inserts === 'all' || deletes === 'all') {
|
|
34
|
+
return (inserts ?? deletes ?? updates) !== undefined;
|
|
35
|
+
}
|
|
36
|
+
if (updates === 'all')
|
|
37
|
+
return true;
|
|
38
|
+
const found = new Set(ids);
|
|
39
|
+
const written = [...listed(updates), ...listed(deletes)];
|
|
40
|
+
if (written.some((id) => found.has(id)))
|
|
41
|
+
return true;
|
|
42
|
+
// A row enters when inserted, or updated in a field that chose the rows.
|
|
43
|
+
const moved = touched(changes, filters) ? listed(updates) : [];
|
|
44
|
+
const others = [...listed(inserts), ...moved];
|
|
45
|
+
if (others.length === 0)
|
|
46
|
+
return false;
|
|
47
|
+
if (where === undefined)
|
|
48
|
+
return true;
|
|
49
|
+
const rows = await loader.load(model, others);
|
|
50
|
+
return [...rows.values()].some((row) => matches(model, row, where));
|
|
51
|
+
}
|
|
52
|
+
export const anyStale = async (reads, batch, loader) => {
|
|
53
|
+
for (const read of reads)
|
|
54
|
+
if (await stale(read, batch, loader))
|
|
55
|
+
return true;
|
|
56
|
+
return false;
|
|
57
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Row } from 'ultimabase';
|
|
2
|
+
import type { LivePlan } from 'ultimabase/internal';
|
|
3
|
+
export declare class Tracking {
|
|
4
|
+
#private;
|
|
5
|
+
readonly tracked: Map<string, Map<string, Set<string>>>;
|
|
6
|
+
clear(): void;
|
|
7
|
+
/** Forgets the related rows the window's row `root` reached. */
|
|
8
|
+
untrack(root: string): void;
|
|
9
|
+
/** Keeps the related rows `node`, as read through `include`, reaches. */
|
|
10
|
+
track(node: Row, include: LivePlan['include']): void;
|
|
11
|
+
}
|
|
12
|
+
//# sourceMappingURL=tracking.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tracking.d.ts","sourceRoot":"","sources":["../src/tracking.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,YAAY,CAAA;AACrC,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,qBAAqB,CAAA;AAwBnD,qBAAa,QAAQ;;IACpB,QAAQ,CAAC,OAAO,EAAE,GAAG,CAAC,MAAM,EAAE,GAAG,CAAC,MAAM,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC,CAAY;IAGnE,KAAK,IAAI,IAAI,CAGZ;IAED,gEAAgE;IAChE,OAAO,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAO1B;IAED,yEAAyE;IACzE,KAAK,CAAC,IAAI,EAAE,GAAG,EAAE,OAAO,EAAE,QAAQ,CAAC,SAAS,CAAC,GAAG,IAAI,CAYnD;CACD"}
|
package/dist/tracking.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/** The rows `include` reached from `node`, as `[path, id]`. */
|
|
2
|
+
function reached(node, include, path, out) {
|
|
3
|
+
for (const [name, inner] of Object.entries(include ?? {})) {
|
|
4
|
+
const at = path === '' ? name : `${path}.${name}`;
|
|
5
|
+
const value = node[name];
|
|
6
|
+
const rows = Array.isArray(value) ? value : [value];
|
|
7
|
+
for (const row of rows) {
|
|
8
|
+
if (typeof row !== 'object' || row === null)
|
|
9
|
+
continue;
|
|
10
|
+
const id = Reflect.get(row, 'id');
|
|
11
|
+
if (typeof id === 'string')
|
|
12
|
+
out.push([at, id]);
|
|
13
|
+
reached(Object.fromEntries(Object.entries(row)), inner.include, at, out);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export class Tracking {
|
|
18
|
+
tracked = new Map();
|
|
19
|
+
#reaching = new Map();
|
|
20
|
+
clear() {
|
|
21
|
+
this.tracked.clear();
|
|
22
|
+
this.#reaching.clear();
|
|
23
|
+
}
|
|
24
|
+
/** Forgets the related rows the window's row `root` reached. */
|
|
25
|
+
untrack(root) {
|
|
26
|
+
for (const [path, id] of this.#reaching.get(root) ?? []) {
|
|
27
|
+
const roots = this.tracked.get(path)?.get(id);
|
|
28
|
+
roots?.delete(root);
|
|
29
|
+
if (roots?.size === 0)
|
|
30
|
+
this.tracked.get(path)?.delete(id);
|
|
31
|
+
}
|
|
32
|
+
this.#reaching.delete(root);
|
|
33
|
+
}
|
|
34
|
+
/** Keeps the related rows `node`, as read through `include`, reaches. */
|
|
35
|
+
track(node, include) {
|
|
36
|
+
const out = [];
|
|
37
|
+
reached(node, include, '', out);
|
|
38
|
+
this.untrack(node.id);
|
|
39
|
+
this.#reaching.set(node.id, out);
|
|
40
|
+
for (const [path, id] of out) {
|
|
41
|
+
const ids = this.tracked.get(path) ?? new Map();
|
|
42
|
+
this.tracked.set(path, ids);
|
|
43
|
+
const roots = ids.get(id) ?? new Set();
|
|
44
|
+
ids.set(id, roots);
|
|
45
|
+
roots.add(node.id);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
package/dist/viewer.d.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { Caller, ExposedFunction, ExposedModel, Logger } from 'ultimabase';
|
|
2
|
+
import { recording } from 'ultimabase/internal';
|
|
3
|
+
import type { Batch } from './changes.ts';
|
|
4
|
+
import type { LiveMessage } from './messages.ts';
|
|
5
|
+
import type { Loader } from './reads.ts';
|
|
6
|
+
export type Settings = {
|
|
7
|
+
readonly models: readonly ExposedModel<{
|
|
8
|
+
readonly maxLive?: number;
|
|
9
|
+
}>[];
|
|
10
|
+
readonly functions: readonly ExposedFunction<{
|
|
11
|
+
readonly debounce?: number;
|
|
12
|
+
}>[];
|
|
13
|
+
readonly maxLive: number;
|
|
14
|
+
readonly maxSubscriptions: number;
|
|
15
|
+
readonly maxConnectionRows: number;
|
|
16
|
+
readonly identify: (request: Request) => Promise<Caller>;
|
|
17
|
+
readonly logger: Logger;
|
|
18
|
+
};
|
|
19
|
+
/** A live read's handle. */
|
|
20
|
+
export interface LiveHandle {
|
|
21
|
+
/** Shows `more` rows after the last; a list over its limit is refused. */
|
|
22
|
+
grow(this: void, more: number): Promise<void>;
|
|
23
|
+
/** Sends the whole window again, as after messages were dropped. */
|
|
24
|
+
resend(this: void): Promise<void>;
|
|
25
|
+
close(this: void): void;
|
|
26
|
+
}
|
|
27
|
+
type Built<R> = Awaited<ReturnType<typeof recording<R>>>;
|
|
28
|
+
/** A function's name, with its plugin's for a plugin's (D-113). */
|
|
29
|
+
export declare const ownerOf: (fn: ExposedFunction) => string;
|
|
30
|
+
export declare class Viewer {
|
|
31
|
+
#private;
|
|
32
|
+
/** `onDue` runs once the caller stops holding, to rebuild it. */
|
|
33
|
+
constructor(settings: Settings, request: Request, identified: Built<Caller>, hooks: {
|
|
34
|
+
readonly onClose: () => void;
|
|
35
|
+
readonly onDue: () => void;
|
|
36
|
+
});
|
|
37
|
+
/** The caller the live reads run as, kept current by each rebuild. */
|
|
38
|
+
get caller(): Caller;
|
|
39
|
+
get closed(): boolean;
|
|
40
|
+
/** A live list of `model`'s rows, from the top of its order. */
|
|
41
|
+
list(model: string, args: unknown, send: (message: LiveMessage) => void): Promise<LiveHandle>;
|
|
42
|
+
/** A live row of `model`, by a unique `where`. */
|
|
43
|
+
row(model: string, args: unknown, send: (message: LiveMessage) => void): Promise<LiveHandle>;
|
|
44
|
+
/**
|
|
45
|
+
* A live function's result, `compute` as the caller, run again once a
|
|
46
|
+
* write may change what it read.
|
|
47
|
+
*/
|
|
48
|
+
value(name: string, compute: (caller: Caller) => Promise<unknown>, send: (message: LiveMessage) => void): Promise<LiveHandle>;
|
|
49
|
+
close(): void;
|
|
50
|
+
/** Follows `batch`: rebuilds the caller first when a write may change it. */
|
|
51
|
+
flush(batch: Batch, loader: Loader, missed: boolean): Promise<void>;
|
|
52
|
+
/**
|
|
53
|
+
* Follows `request`'s caller from now on, built again at once; whether
|
|
54
|
+
* it is the same user, as a closed viewer is not.
|
|
55
|
+
*/
|
|
56
|
+
adopt(request: Request): Promise<boolean>;
|
|
57
|
+
/** Builds the caller again, then each live read's plan. */
|
|
58
|
+
rebuild(): Promise<void>;
|
|
59
|
+
/** Closes every live read, telling each. */
|
|
60
|
+
end(): void;
|
|
61
|
+
}
|
|
62
|
+
export {};
|
|
63
|
+
//# sourceMappingURL=viewer.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"viewer.d.ts","sourceRoot":"","sources":["../src/viewer.ts"],"names":[],"mappings":"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"}
|
package/dist/viewer.js
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { livePlan, recording } from 'ultimabase/internal';
|
|
2
|
+
import { checked, invalid } from './checked.js';
|
|
3
|
+
import { Computed } from './computed.js';
|
|
4
|
+
import { Live } from './live.js';
|
|
5
|
+
import { liveArgs, shapeOf } from './shape.js';
|
|
6
|
+
import { anyStale } from './stale.js';
|
|
7
|
+
/** The least wait before a rebuild, so a past expiry is no hot loop. */
|
|
8
|
+
const soonest = 1000;
|
|
9
|
+
/** The longest wait `setTimeout` keeps. */
|
|
10
|
+
const latest = 2 ** 31 - 1;
|
|
11
|
+
/** A function's name, with its plugin's for a plugin's (D-113). */
|
|
12
|
+
export const ownerOf = (fn) => fn.plugin === undefined ? fn.name : `${fn.plugin}.${fn.name}`;
|
|
13
|
+
/** How long a live function's wake waits for quiet, in milliseconds. */
|
|
14
|
+
const debounce = 50;
|
|
15
|
+
/** Who `caller` is, which a rebuilt caller must keep. */
|
|
16
|
+
const whoOf = (caller) => {
|
|
17
|
+
if (caller.kind !== 'user')
|
|
18
|
+
return JSON.stringify(caller);
|
|
19
|
+
const id = Reflect.get(caller.user, 'id');
|
|
20
|
+
return JSON.stringify(['user', id]);
|
|
21
|
+
};
|
|
22
|
+
export class Viewer {
|
|
23
|
+
#settings;
|
|
24
|
+
#request;
|
|
25
|
+
#onClose;
|
|
26
|
+
#onDue;
|
|
27
|
+
#live = new Map();
|
|
28
|
+
#computed = new Map();
|
|
29
|
+
#caller;
|
|
30
|
+
#reads;
|
|
31
|
+
#expires;
|
|
32
|
+
#due;
|
|
33
|
+
#closed = false;
|
|
34
|
+
/** `onDue` runs once the caller stops holding, to rebuild it. */
|
|
35
|
+
constructor(settings, request, identified, hooks) {
|
|
36
|
+
this.#settings = settings;
|
|
37
|
+
this.#request = request;
|
|
38
|
+
this.#caller = identified.result;
|
|
39
|
+
this.#reads = identified.reads;
|
|
40
|
+
this.#expires = identified.expires;
|
|
41
|
+
this.#onClose = hooks.onClose;
|
|
42
|
+
this.#onDue = hooks.onDue;
|
|
43
|
+
this.#reschedule();
|
|
44
|
+
}
|
|
45
|
+
/** The caller the live reads run as, kept current by each rebuild. */
|
|
46
|
+
get caller() {
|
|
47
|
+
return this.#caller;
|
|
48
|
+
}
|
|
49
|
+
get closed() {
|
|
50
|
+
return this.#closed;
|
|
51
|
+
}
|
|
52
|
+
/** A live list of `model`'s rows, from the top of its order. */
|
|
53
|
+
async list(model, args, send) {
|
|
54
|
+
return this.#follow(model, args, false, send);
|
|
55
|
+
}
|
|
56
|
+
/** A live row of `model`, by a unique `where`. */
|
|
57
|
+
async row(model, args, send) {
|
|
58
|
+
return this.#follow(model, args, true, send);
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* A live function's result, `compute` as the caller, run again once a
|
|
62
|
+
* write may change what it read.
|
|
63
|
+
*/
|
|
64
|
+
async value(name, compute, send) {
|
|
65
|
+
if (this.#closed)
|
|
66
|
+
throw new Error('The viewer is closed.');
|
|
67
|
+
const exposed = this.#settings.functions.find((fn) => ownerOf(fn) === name);
|
|
68
|
+
if (!exposed?.used)
|
|
69
|
+
throw invalid('function', `${name} is not live.`);
|
|
70
|
+
this.#holds();
|
|
71
|
+
const computed = new Computed({
|
|
72
|
+
compute,
|
|
73
|
+
send,
|
|
74
|
+
debounce: exposed.with?.debounce ?? debounce,
|
|
75
|
+
onRun: () => this.#reschedule(),
|
|
76
|
+
onFail: () => this.#computed.delete(computed),
|
|
77
|
+
}, this.#caller);
|
|
78
|
+
this.#computed.set(computed, send);
|
|
79
|
+
try {
|
|
80
|
+
await computed.start();
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
this.#computed.delete(computed);
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
grow: async () => {
|
|
88
|
+
throw invalid('first', `${name} is no live list.`);
|
|
89
|
+
},
|
|
90
|
+
resend: async () => computed.resend(),
|
|
91
|
+
close: () => {
|
|
92
|
+
computed.close();
|
|
93
|
+
this.#computed.delete(computed);
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
close() {
|
|
98
|
+
if (this.#closed)
|
|
99
|
+
return;
|
|
100
|
+
this.#closed = true;
|
|
101
|
+
clearTimeout(this.#due);
|
|
102
|
+
for (const live of this.#live.keys())
|
|
103
|
+
live.close();
|
|
104
|
+
this.#live.clear();
|
|
105
|
+
for (const computed of this.#computed.keys())
|
|
106
|
+
computed.close();
|
|
107
|
+
this.#computed.clear();
|
|
108
|
+
this.#onClose();
|
|
109
|
+
}
|
|
110
|
+
/** Follows `batch`: rebuilds the caller first when a write may change it. */
|
|
111
|
+
async flush(batch, loader, missed) {
|
|
112
|
+
if (this.#closed)
|
|
113
|
+
return;
|
|
114
|
+
const reads = [
|
|
115
|
+
...this.#reads,
|
|
116
|
+
...[...this.#live.values()].flatMap((subscription) => subscription.reads),
|
|
117
|
+
];
|
|
118
|
+
if (missed || (await anyStale(reads, batch, loader))) {
|
|
119
|
+
await this.rebuild();
|
|
120
|
+
}
|
|
121
|
+
const lives = [...this.#live.keys(), ...this.#computed.keys()];
|
|
122
|
+
await Promise.all(lives.map(async (live) => live.apply(batch, loader, missed)));
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Follows `request`'s caller from now on, built again at once; whether
|
|
126
|
+
* it is the same user, as a closed viewer is not.
|
|
127
|
+
*/
|
|
128
|
+
async adopt(request) {
|
|
129
|
+
this.#request = request;
|
|
130
|
+
await this.rebuild();
|
|
131
|
+
return !this.#closed;
|
|
132
|
+
}
|
|
133
|
+
/** Builds the caller again, then each live read's plan. */
|
|
134
|
+
async rebuild() {
|
|
135
|
+
if (this.#closed)
|
|
136
|
+
return;
|
|
137
|
+
let identified;
|
|
138
|
+
try {
|
|
139
|
+
identified = await recording(async () => this.#settings.identify(this.#request));
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return this.end();
|
|
143
|
+
}
|
|
144
|
+
const caller = identified.result;
|
|
145
|
+
if (whoOf(caller) !== whoOf(this.#caller))
|
|
146
|
+
return this.end();
|
|
147
|
+
this.#caller = caller;
|
|
148
|
+
this.#reads = identified.reads;
|
|
149
|
+
this.#expires = identified.expires;
|
|
150
|
+
await Promise.all([...this.#live].map(async ([live, subscription]) => {
|
|
151
|
+
try {
|
|
152
|
+
const { model, args, row } = subscription;
|
|
153
|
+
const planned = await recording(async () => livePlan(model, liveArgs(args, row), caller, row));
|
|
154
|
+
subscription.reads = planned.reads;
|
|
155
|
+
subscription.expires = planned.expires;
|
|
156
|
+
await live.replan(caller, planned.result);
|
|
157
|
+
}
|
|
158
|
+
catch (error) {
|
|
159
|
+
this.#settings.logger.error({ err: error }, `A live read of ${subscription.model} cannot be planned: it closes.`);
|
|
160
|
+
this.#drop(live);
|
|
161
|
+
}
|
|
162
|
+
}));
|
|
163
|
+
await Promise.all([...this.#computed.keys()].map(async (computed) => computed.replan(caller)));
|
|
164
|
+
this.#reschedule();
|
|
165
|
+
}
|
|
166
|
+
/** Waits for the earliest time the caller or a live read stops holding. */
|
|
167
|
+
#reschedule() {
|
|
168
|
+
clearTimeout(this.#due);
|
|
169
|
+
this.#due = undefined;
|
|
170
|
+
if (this.#closed)
|
|
171
|
+
return;
|
|
172
|
+
const subscriptions = [...this.#live.values(), ...this.#computed.keys()];
|
|
173
|
+
const times = [
|
|
174
|
+
this.#expires,
|
|
175
|
+
...subscriptions.map(({ expires }) => expires),
|
|
176
|
+
]
|
|
177
|
+
.filter((at) => at !== undefined)
|
|
178
|
+
.map((at) => at.getTime());
|
|
179
|
+
if (times.length === 0)
|
|
180
|
+
return;
|
|
181
|
+
const wait = Math.min(Math.max(Math.min(...times) - Date.now(), soonest), latest);
|
|
182
|
+
this.#due = setTimeout(this.#onDue, wait);
|
|
183
|
+
this.#due.unref();
|
|
184
|
+
}
|
|
185
|
+
#drop(live) {
|
|
186
|
+
const subscription = this.#live.get(live);
|
|
187
|
+
live.close();
|
|
188
|
+
this.#live.delete(live);
|
|
189
|
+
subscription?.send({ kind: 'closed' });
|
|
190
|
+
}
|
|
191
|
+
/** Closes every live read, telling each. */
|
|
192
|
+
end() {
|
|
193
|
+
const sends = [
|
|
194
|
+
...[...this.#live.values()].map(({ send }) => send),
|
|
195
|
+
...this.#computed.values(),
|
|
196
|
+
];
|
|
197
|
+
this.close();
|
|
198
|
+
for (const send of sends)
|
|
199
|
+
send({ kind: 'closed' });
|
|
200
|
+
}
|
|
201
|
+
async #follow(model, args, row, send) {
|
|
202
|
+
if (this.#closed)
|
|
203
|
+
throw new Error('The viewer is closed.');
|
|
204
|
+
const exposed = this.#settings.models.find(({ schema }) => schema.name === model);
|
|
205
|
+
if (!exposed?.used)
|
|
206
|
+
throw invalid('model', `${model} is not live.`);
|
|
207
|
+
this.#holds();
|
|
208
|
+
const { first, where, totalCount, shape } = shapeOf(args, row);
|
|
209
|
+
const caller = this.#caller;
|
|
210
|
+
const planned = await recording(async () => livePlan(model, liveArgs(args, row), caller, row));
|
|
211
|
+
const limit = exposed.with?.maxLive ?? this.#settings.maxLive;
|
|
212
|
+
const nested = checked(this.#settings.models, model, planned.result, 'include');
|
|
213
|
+
if (first * (1 + nested) > limit) {
|
|
214
|
+
throw invalid('first', `first is over ${limit}, the most a live list follows.`);
|
|
215
|
+
}
|
|
216
|
+
this.#fits(first * (1 + nested));
|
|
217
|
+
const live = new Live({
|
|
218
|
+
model,
|
|
219
|
+
queries: exposed.queries,
|
|
220
|
+
where,
|
|
221
|
+
shape,
|
|
222
|
+
totalCount,
|
|
223
|
+
row,
|
|
224
|
+
send,
|
|
225
|
+
logger: this.#settings.logger,
|
|
226
|
+
}, caller, planned.result, first);
|
|
227
|
+
const { reads, expires } = planned;
|
|
228
|
+
const rows = first * (1 + nested);
|
|
229
|
+
const subscription = { model, args, row, send, rows, reads, expires };
|
|
230
|
+
this.#live.set(live, subscription);
|
|
231
|
+
try {
|
|
232
|
+
await live.start();
|
|
233
|
+
}
|
|
234
|
+
catch (error) {
|
|
235
|
+
this.#live.delete(live);
|
|
236
|
+
throw error;
|
|
237
|
+
}
|
|
238
|
+
this.#reschedule();
|
|
239
|
+
return {
|
|
240
|
+
grow: async (more) => {
|
|
241
|
+
if (!Number.isInteger(more) || more < 1) {
|
|
242
|
+
throw invalid('first', 'first must be a positive integer.');
|
|
243
|
+
}
|
|
244
|
+
if ((live.first + more) * (1 + nested) > limit) {
|
|
245
|
+
throw invalid('first', `first is over ${limit}, the most a live list follows.`);
|
|
246
|
+
}
|
|
247
|
+
const grown = (live.first + more) * (1 + nested);
|
|
248
|
+
this.#fits(grown - subscription.rows);
|
|
249
|
+
subscription.rows = grown;
|
|
250
|
+
return live.grow(more);
|
|
251
|
+
},
|
|
252
|
+
resend: async () => live.resend(),
|
|
253
|
+
close: () => {
|
|
254
|
+
live.close();
|
|
255
|
+
this.#live.delete(live);
|
|
256
|
+
},
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
/** Refuses one more live read past what the connection holds. */
|
|
260
|
+
#holds() {
|
|
261
|
+
const { maxSubscriptions } = this.#settings;
|
|
262
|
+
if (this.#live.size + this.#computed.size < maxSubscriptions)
|
|
263
|
+
return;
|
|
264
|
+
throw invalid('subscriptions', `A connection holds at most ${maxSubscriptions} live reads.`);
|
|
265
|
+
}
|
|
266
|
+
/** Refuses `more` rows past what the connection follows in all. */
|
|
267
|
+
#fits(more) {
|
|
268
|
+
const { maxConnectionRows } = this.#settings;
|
|
269
|
+
let rows = more;
|
|
270
|
+
for (const subscription of this.#live.values())
|
|
271
|
+
rows += subscription.rows;
|
|
272
|
+
if (rows <= maxConnectionRows)
|
|
273
|
+
return;
|
|
274
|
+
throw invalid('first', `A connection follows at most ${maxConnectionRows} rows.`);
|
|
275
|
+
}
|
|
276
|
+
}
|