@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/dist/window.d.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { Order } from 'ultimabase';
|
|
2
|
+
export type Entry = {
|
|
3
|
+
readonly id: string;
|
|
4
|
+
readonly keys: readonly unknown[];
|
|
5
|
+
};
|
|
6
|
+
/** What changed between two windows, in the order a client applies it. */
|
|
7
|
+
export type Moves = {
|
|
8
|
+
readonly removed: readonly string[];
|
|
9
|
+
/** Each row placed again, after the row before it, `null` for the first. */
|
|
10
|
+
readonly placed: readonly {
|
|
11
|
+
readonly id: string;
|
|
12
|
+
readonly after: string | null;
|
|
13
|
+
}[];
|
|
14
|
+
};
|
|
15
|
+
export declare class Window {
|
|
16
|
+
#private;
|
|
17
|
+
/** Rows may follow the last entry. */
|
|
18
|
+
rest: boolean;
|
|
19
|
+
first: number;
|
|
20
|
+
constructor(orderBy: readonly Order[], first: number);
|
|
21
|
+
/** Starts noting changes: `moves` tells those since. */
|
|
22
|
+
mark(): void;
|
|
23
|
+
/** The rows a client shows. */
|
|
24
|
+
get entries(): readonly Entry[];
|
|
25
|
+
shown(): readonly Entry[];
|
|
26
|
+
hasMore(): boolean;
|
|
27
|
+
/** The ids of the entries, kept as they change. */
|
|
28
|
+
ids(): ReadonlySet<string>;
|
|
29
|
+
/** How many rows a read after the last entry must bring. */
|
|
30
|
+
missing(): number;
|
|
31
|
+
/** Adds the rows read after the last entry. */
|
|
32
|
+
fill(entries: readonly Entry[], rest: boolean): void;
|
|
33
|
+
remove(id: string): boolean;
|
|
34
|
+
/** Places a row that passes the filter, or leaves it outside. */
|
|
35
|
+
place(entry: Entry): void;
|
|
36
|
+
/**
|
|
37
|
+
* How the rows shown at the mark, none before the first, became those
|
|
38
|
+
* shown now. Only the rows changed since, and the one past the shown rows
|
|
39
|
+
* then and now, may have come or gone.
|
|
40
|
+
*/
|
|
41
|
+
moves(again: ReadonlySet<string>): Moves;
|
|
42
|
+
}
|
|
43
|
+
//# sourceMappingURL=window.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"window.d.ts","sourceRoot":"","sources":["../src/window.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,YAAY,CAAA;AAGvC,MAAM,MAAM,KAAK,GAAG;IAAE,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,SAAS,OAAO,EAAE,CAAA;CAAE,CAAA;AAE9E,0EAA0E;AAC1E,MAAM,MAAM,KAAK,GAAG;IACnB,QAAQ,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,CAAA;IACnC,4EAA4E;IAC5E,QAAQ,CAAC,MAAM,EAAE,SAAS;QACzB,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;QACnB,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;KAC7B,EAAE,CAAA;CACH,CAAA;AAED,qBAAa,MAAM;;IAGlB,sCAAsC;IACtC,IAAI,UAAQ;IACZ,KAAK,EAAE,MAAM,CAAA;IAOb,YAAY,OAAO,EAAE,SAAS,KAAK,EAAE,EAAE,KAAK,EAAE,MAAM,EAGnD;IAED,wDAAwD;IACxD,IAAI,IAAI,IAAI,CAGX;IAOD,+BAA+B;IAC/B,IAAI,OAAO,IAAI,SAAS,KAAK,EAAE,CAE9B;IAED,KAAK,IAAI,SAAS,KAAK,EAAE,CAExB;IAED,OAAO,IAAI,OAAO,CAEjB;IAED,mDAAmD;IACnD,GAAG,IAAI,WAAW,CAAC,MAAM,CAAC,CAEzB;IAED,4DAA4D;IAC5D,OAAO,IAAI,MAAM,CAEhB;IAED,+CAA+C;IAC/C,IAAI,CAAC,OAAO,EAAE,SAAS,KAAK,EAAE,EAAE,IAAI,EAAE,OAAO,GAAG,IAAI,CAOnD;IAED,MAAM,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAM1B;IAED,iEAAiE;IACjE,KAAK,CAAC,KAAK,EAAE,KAAK,GAAG,IAAI,CAqBxB;IAED;;;;OAIG;IACH,KAAK,CAAC,KAAK,EAAE,WAAW,CAAC,MAAM,CAAC,GAAG,KAAK,CAgCvC;CACD"}
|
package/dist/window.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { compareKeys } from 'ultimabase/internal';
|
|
2
|
+
export class Window {
|
|
3
|
+
#entries = [];
|
|
4
|
+
#ids = new Set();
|
|
5
|
+
/** Rows may follow the last entry. */
|
|
6
|
+
rest = false;
|
|
7
|
+
first;
|
|
8
|
+
#orderBy;
|
|
9
|
+
/** The rows changed since the mark, each with its keys then, if it was in. */
|
|
10
|
+
#was;
|
|
11
|
+
/** The row past those shown at the mark. */
|
|
12
|
+
#extra;
|
|
13
|
+
constructor(orderBy, first) {
|
|
14
|
+
this.#orderBy = orderBy;
|
|
15
|
+
this.first = first;
|
|
16
|
+
}
|
|
17
|
+
/** Starts noting changes: `moves` tells those since. */
|
|
18
|
+
mark() {
|
|
19
|
+
this.#was = new Map();
|
|
20
|
+
this.#extra = this.#entries[this.first]?.id;
|
|
21
|
+
}
|
|
22
|
+
#note(id, keys) {
|
|
23
|
+
const was = this.#was;
|
|
24
|
+
if (was !== undefined && !was.has(id))
|
|
25
|
+
was.set(id, keys);
|
|
26
|
+
}
|
|
27
|
+
/** The rows a client shows. */
|
|
28
|
+
get entries() {
|
|
29
|
+
return this.#entries;
|
|
30
|
+
}
|
|
31
|
+
shown() {
|
|
32
|
+
return this.#entries.slice(0, this.first);
|
|
33
|
+
}
|
|
34
|
+
hasMore() {
|
|
35
|
+
return this.#entries.length > this.first;
|
|
36
|
+
}
|
|
37
|
+
/** The ids of the entries, kept as they change. */
|
|
38
|
+
ids() {
|
|
39
|
+
return this.#ids;
|
|
40
|
+
}
|
|
41
|
+
/** How many rows a read after the last entry must bring. */
|
|
42
|
+
missing() {
|
|
43
|
+
return this.rest ? this.first + 1 - this.#entries.length : 0;
|
|
44
|
+
}
|
|
45
|
+
/** Adds the rows read after the last entry. */
|
|
46
|
+
fill(entries, rest) {
|
|
47
|
+
this.#entries.push(...entries);
|
|
48
|
+
for (const { id } of entries) {
|
|
49
|
+
this.#note(id, undefined);
|
|
50
|
+
this.#ids.add(id);
|
|
51
|
+
}
|
|
52
|
+
this.rest = rest;
|
|
53
|
+
}
|
|
54
|
+
remove(id) {
|
|
55
|
+
if (!this.#ids.delete(id))
|
|
56
|
+
return false;
|
|
57
|
+
const at = this.#entries.findIndex((entry) => entry.id === id);
|
|
58
|
+
this.#note(id, this.#entries[at]?.keys);
|
|
59
|
+
this.#entries.splice(at, 1);
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
/** Places a row that passes the filter, or leaves it outside. */
|
|
63
|
+
place(entry) {
|
|
64
|
+
this.remove(entry.id);
|
|
65
|
+
const entries = this.#entries;
|
|
66
|
+
let low = 0;
|
|
67
|
+
let high = entries.length;
|
|
68
|
+
while (low < high) {
|
|
69
|
+
const middle = (low + high) >> 1;
|
|
70
|
+
const keys = entries[middle]?.keys ?? [];
|
|
71
|
+
if (compareKeys(keys, entry.keys, this.#orderBy) < 0)
|
|
72
|
+
low = middle + 1;
|
|
73
|
+
else
|
|
74
|
+
high = middle;
|
|
75
|
+
}
|
|
76
|
+
if (low === entries.length && this.rest)
|
|
77
|
+
return;
|
|
78
|
+
this.#note(entry.id, undefined);
|
|
79
|
+
entries.splice(low, 0, entry);
|
|
80
|
+
this.#ids.add(entry.id);
|
|
81
|
+
const last = entries.length > this.first + 1 ? entries.pop() : undefined;
|
|
82
|
+
if (last !== undefined) {
|
|
83
|
+
this.#note(last.id, last.keys);
|
|
84
|
+
this.#ids.delete(last.id);
|
|
85
|
+
this.rest = true;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* How the rows shown at the mark, none before the first, became those
|
|
90
|
+
* shown now. Only the rows changed since, and the one past the shown rows
|
|
91
|
+
* then and now, may have come or gone.
|
|
92
|
+
*/
|
|
93
|
+
moves(again) {
|
|
94
|
+
const entries = this.#entries;
|
|
95
|
+
const after = (at) => ({
|
|
96
|
+
id: entries[at]?.id ?? '',
|
|
97
|
+
after: entries[at - 1]?.id ?? null,
|
|
98
|
+
});
|
|
99
|
+
const notes = this.#was;
|
|
100
|
+
if (notes === undefined) {
|
|
101
|
+
return { removed: [], placed: this.shown().map((_, at) => after(at)) };
|
|
102
|
+
}
|
|
103
|
+
const extra = entries[this.first]?.id;
|
|
104
|
+
const was = (id) => id !== this.#extra &&
|
|
105
|
+
(notes.has(id) ? notes.get(id) !== undefined : this.#ids.has(id));
|
|
106
|
+
const shown = (id) => id !== extra && this.#ids.has(id);
|
|
107
|
+
const ids = [
|
|
108
|
+
...new Set([...notes.keys(), ...again, this.#extra, extra]),
|
|
109
|
+
].filter((id) => id !== undefined);
|
|
110
|
+
const removed = ids.filter((id) => was(id) && !shown(id));
|
|
111
|
+
const placed = ids.flatMap((id) => {
|
|
112
|
+
if (!shown(id))
|
|
113
|
+
return [];
|
|
114
|
+
const at = entries.findIndex((entry) => entry.id === id);
|
|
115
|
+
const old = notes.get(id);
|
|
116
|
+
// The rows whose keys stayed keep their order among themselves.
|
|
117
|
+
const still = was(id) &&
|
|
118
|
+
!again.has(id) &&
|
|
119
|
+
(old === undefined ||
|
|
120
|
+
compareKeys(old, entries[at]?.keys ?? [], this.#orderBy) === 0);
|
|
121
|
+
return still ? [] : [at];
|
|
122
|
+
});
|
|
123
|
+
return { removed, placed: placed.toSorted((a, b) => a - b).map(after) };
|
|
124
|
+
}
|
|
125
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,45 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimabase/realtime",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Realtime for UltimaBase: live lists and live rows that follow every write, under the read rules.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "Apache-2.0",
|
|
7
|
+
"homepage": "https://ultimabase.dev/docs/realtime",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/MrEkinox/ultimabase.git",
|
|
11
|
+
"directory": "packages/realtime"
|
|
12
|
+
},
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=24.7.0"
|
|
15
|
+
},
|
|
16
|
+
"exports": {
|
|
17
|
+
".": "./dist/index.js"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"dist",
|
|
21
|
+
"src",
|
|
22
|
+
"!src/**/*.test.ts",
|
|
23
|
+
"!src/test"
|
|
24
|
+
],
|
|
25
|
+
"scripts": {
|
|
26
|
+
"prepack": "rm -rf dist && yarn run -T tsc --project tsconfig.build.json"
|
|
27
|
+
},
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"exports": {
|
|
30
|
+
".": "./dist/index.js"
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"peerDependencies": {
|
|
34
|
+
"ultimabase": "^0.1.1"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@ultimabase/acl": "0.1.1",
|
|
38
|
+
"@ultimabase/roles": "0.1.1",
|
|
39
|
+
"@ultimabase/soft-delete": "0.1.1",
|
|
40
|
+
"@ultimabase/testing": "0.1.1",
|
|
41
|
+
"ultimabase": "0.1.1",
|
|
42
|
+
"vite": "8.3.4",
|
|
43
|
+
"vitest": "5.0.5"
|
|
44
|
+
}
|
|
6
45
|
}
|
package/src/affected.ts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// What a flush's changes do to one live read (D-154): the root rows to place
|
|
2
|
+
// again, the window rows deleted or to send again, or a resync when the feed
|
|
3
|
+
// does not say which rows changed.
|
|
4
|
+
import type { Batch } from './changes.ts'
|
|
5
|
+
import { listed, touched, unlisted } from './changes.ts'
|
|
6
|
+
import type { Reach, Step } from './reach.ts'
|
|
7
|
+
|
|
8
|
+
export type Affected = {
|
|
9
|
+
/** Root rows whose membership or order may have changed. */
|
|
10
|
+
readonly candidates: ReadonlySet<string>
|
|
11
|
+
/** Window rows deleted. */
|
|
12
|
+
readonly deleted: ReadonlySet<string>
|
|
13
|
+
/** A row past the window may have left the list. */
|
|
14
|
+
readonly beyond: boolean
|
|
15
|
+
/** Window rows to read and send again. */
|
|
16
|
+
readonly resend: ReadonlySet<string>
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The root rows that reach one of `ids` through `steps`, among `within` when
|
|
21
|
+
* it is given.
|
|
22
|
+
*/
|
|
23
|
+
export type Finder = (
|
|
24
|
+
steps: readonly Step[],
|
|
25
|
+
ids: readonly string[],
|
|
26
|
+
within?: readonly string[],
|
|
27
|
+
) => Promise<readonly string[]>
|
|
28
|
+
|
|
29
|
+
/** The window rows each row a shown relation reached is shown under. */
|
|
30
|
+
export type Tracked = ReadonlyMap<
|
|
31
|
+
string,
|
|
32
|
+
ReadonlyMap<string, ReadonlySet<string>>
|
|
33
|
+
>
|
|
34
|
+
|
|
35
|
+
export type Facing = {
|
|
36
|
+
readonly model: string
|
|
37
|
+
readonly reach: Reach
|
|
38
|
+
readonly window: ReadonlySet<string>
|
|
39
|
+
readonly tracked: Tracked
|
|
40
|
+
readonly find: Finder
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** What `batch` does to the live read `facing` describes. */
|
|
44
|
+
export async function affected(
|
|
45
|
+
facing: Facing,
|
|
46
|
+
batch: Batch,
|
|
47
|
+
): Promise<Affected | 'resync'> {
|
|
48
|
+
const { model, reach, window, find } = facing
|
|
49
|
+
const root = batch.get(model)
|
|
50
|
+
if (unlisted(root)) return 'resync'
|
|
51
|
+
const candidates = new Set(listed(root?.inserts))
|
|
52
|
+
const deleted = new Set<string>()
|
|
53
|
+
let beyond = false
|
|
54
|
+
for (const id of listed(root?.deletes)) {
|
|
55
|
+
if (window.has(id)) deleted.add(id)
|
|
56
|
+
else beyond = true
|
|
57
|
+
}
|
|
58
|
+
const updated = listed(root?.updates)
|
|
59
|
+
if (touched(root, reach.placing)) {
|
|
60
|
+
for (const id of updated) candidates.add(id)
|
|
61
|
+
}
|
|
62
|
+
const resend = new Set<string>()
|
|
63
|
+
if (touched(root, reach.showing)) {
|
|
64
|
+
for (const id of updated) if (window.has(id)) resend.add(id)
|
|
65
|
+
}
|
|
66
|
+
for (const level of reach.levels) {
|
|
67
|
+
const changes = batch.get(level.model)
|
|
68
|
+
const step = level.steps.at(-1)
|
|
69
|
+
if (step === undefined || changes === undefined) continue
|
|
70
|
+
if (unlisted(changes)) return 'resync'
|
|
71
|
+
const ids = [
|
|
72
|
+
...listed(changes.inserts),
|
|
73
|
+
...(touched(changes, level.fields) ? listed(changes.updates) : []),
|
|
74
|
+
]
|
|
75
|
+
if (ids.length > 0) {
|
|
76
|
+
for (const id of await find(level.steps, ids)) candidates.add(id)
|
|
77
|
+
}
|
|
78
|
+
// A link lost where it is stored: the rows that had it are unknown.
|
|
79
|
+
const lost =
|
|
80
|
+
changes.deletes !== undefined ||
|
|
81
|
+
(step.keyOnTarget && touched(changes, new Set([step.inverse])))
|
|
82
|
+
if (!lost) continue
|
|
83
|
+
if (level.negative) return 'resync'
|
|
84
|
+
for (const id of window) candidates.add(id)
|
|
85
|
+
}
|
|
86
|
+
const again = await shown(facing, batch)
|
|
87
|
+
for (const id of again === 'all' ? window : again) resend.add(id)
|
|
88
|
+
return { candidates, deleted, beyond, resend }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The window rows whose shown relations changed. */
|
|
92
|
+
async function shown(
|
|
93
|
+
facing: Facing,
|
|
94
|
+
batch: Batch,
|
|
95
|
+
): Promise<ReadonlySet<string> | 'all'> {
|
|
96
|
+
const { reach, window, tracked, find } = facing
|
|
97
|
+
for (const model of reach.deep) if (batch.has(model)) return 'all'
|
|
98
|
+
const resend = new Set<string>()
|
|
99
|
+
for (const relation of reach.shown) {
|
|
100
|
+
const changes = batch.get(relation.model)
|
|
101
|
+
if (changes === undefined) continue
|
|
102
|
+
if (unlisted(changes)) return 'all'
|
|
103
|
+
const updated = touched(changes, relation.fields)
|
|
104
|
+
? listed(changes.updates)
|
|
105
|
+
: []
|
|
106
|
+
if (relation.counted && changes.deletes !== undefined) return 'all'
|
|
107
|
+
const known = tracked.get(relation.path)
|
|
108
|
+
for (const id of [...updated, ...listed(changes.deletes)]) {
|
|
109
|
+
for (const root of known?.get(id) ?? []) resend.add(root)
|
|
110
|
+
}
|
|
111
|
+
const ids = [...listed(changes.inserts), ...updated]
|
|
112
|
+
if (ids.length === 0 || window.size === 0) continue
|
|
113
|
+
for (const id of await find(relation.steps, ids, [...window])) {
|
|
114
|
+
resend.add(id)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return resend
|
|
118
|
+
}
|
package/src/changes.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// One flush's changes (D-154), by model: the rows each kind of write reached
|
|
2
|
+
// and the fields updates set. A list the feed left out is `'all'`, every row
|
|
3
|
+
// or every field. A link or an unlink reads as an update of both sides,
|
|
4
|
+
// under each side's relation, as a `one` key's change already does.
|
|
5
|
+
import type { Changed, Schema } from 'ultimabase'
|
|
6
|
+
|
|
7
|
+
/** Some rows by id, or every row when the feed did not list them. */
|
|
8
|
+
export type Rows = ReadonlySet<string> | 'all'
|
|
9
|
+
/** Some fields by name, or every field. */
|
|
10
|
+
export type Fields = ReadonlySet<string> | 'all'
|
|
11
|
+
|
|
12
|
+
export interface ModelChanges {
|
|
13
|
+
readonly inserts?: Rows
|
|
14
|
+
readonly updates?: Rows
|
|
15
|
+
readonly deletes?: Rows
|
|
16
|
+
/** The fields its updates set. */
|
|
17
|
+
readonly fields?: Fields
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type Batch = ReadonlyMap<string, ModelChanges>
|
|
21
|
+
|
|
22
|
+
type Building = {
|
|
23
|
+
inserts?: Set<string> | 'all'
|
|
24
|
+
updates?: Set<string> | 'all'
|
|
25
|
+
deletes?: Set<string> | 'all'
|
|
26
|
+
fields?: Set<string> | 'all'
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const merge = (
|
|
30
|
+
into: Set<string> | 'all' | undefined,
|
|
31
|
+
values: readonly string[] | undefined,
|
|
32
|
+
): Set<string> | 'all' => {
|
|
33
|
+
if (into === 'all' || values === undefined) return 'all'
|
|
34
|
+
const set = into ?? new Set<string>()
|
|
35
|
+
for (const value of values) set.add(value)
|
|
36
|
+
return set
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** `changes` by model; a link's sides are found in `schema`. */
|
|
40
|
+
export function batchOf(changes: readonly Changed[], schema: Schema): Batch {
|
|
41
|
+
const batch = new Map<string, Building>()
|
|
42
|
+
const of = (model: string): Building => {
|
|
43
|
+
const known = batch.get(model)
|
|
44
|
+
if (known !== undefined) return known
|
|
45
|
+
const created: Building = {}
|
|
46
|
+
batch.set(model, created)
|
|
47
|
+
return created
|
|
48
|
+
}
|
|
49
|
+
const updated = (
|
|
50
|
+
model: string,
|
|
51
|
+
ids: readonly string[] | undefined,
|
|
52
|
+
fields: readonly string[] | undefined,
|
|
53
|
+
): void => {
|
|
54
|
+
const building = of(model)
|
|
55
|
+
building.updates = merge(building.updates, ids)
|
|
56
|
+
building.fields = merge(building.fields, fields)
|
|
57
|
+
}
|
|
58
|
+
for (const change of changes) {
|
|
59
|
+
if (change.kind === 'link' || change.kind === 'unlink') {
|
|
60
|
+
const field = schema.models
|
|
61
|
+
.find(({ name }) => name === change.model)
|
|
62
|
+
?.fields.find(({ name }) => name === change.field)
|
|
63
|
+
const pairs = change.pairs
|
|
64
|
+
updated(
|
|
65
|
+
change.model,
|
|
66
|
+
pairs?.map(([id]) => id),
|
|
67
|
+
[change.field],
|
|
68
|
+
)
|
|
69
|
+
if (field?.kind === 'many') {
|
|
70
|
+
updated(
|
|
71
|
+
field.target,
|
|
72
|
+
pairs?.map(([, id]) => id),
|
|
73
|
+
[field.inverse],
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
} else if (change.kind === 'update') {
|
|
77
|
+
updated(change.model, change.ids, change.fields)
|
|
78
|
+
} else if (change.kind === 'insert' || change.kind === 'delete') {
|
|
79
|
+
const building = of(change.model)
|
|
80
|
+
const kind = change.kind === 'insert' ? 'inserts' : 'deletes'
|
|
81
|
+
building[kind] = merge(building[kind], change.ids)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return batch
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Whether `changes` updated one of `fields`. */
|
|
88
|
+
export function touched(
|
|
89
|
+
changes: ModelChanges | undefined,
|
|
90
|
+
fields: Fields,
|
|
91
|
+
): boolean {
|
|
92
|
+
const set = changes?.fields
|
|
93
|
+
if (changes?.updates === undefined || set === undefined) return false
|
|
94
|
+
if (set === 'all' || fields === 'all') return true
|
|
95
|
+
for (const field of set) if (fields.has(field)) return true
|
|
96
|
+
return false
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Whether a list of `changes` is unknown. */
|
|
100
|
+
export const unlisted = (changes: ModelChanges | undefined): boolean =>
|
|
101
|
+
changes?.inserts === 'all' ||
|
|
102
|
+
changes?.updates === 'all' ||
|
|
103
|
+
changes?.deletes === 'all'
|
|
104
|
+
|
|
105
|
+
/** The ids of `rows`, none when they are every row. */
|
|
106
|
+
export const listed = (rows: Rows | undefined): readonly string[] =>
|
|
107
|
+
rows === undefined || rows === 'all' ? [] : [...rows]
|
package/src/checked.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// What a live read may ask (D-154): its order sorts by the model's own
|
|
2
|
+
// fields, and its selection reaches only live models, each nested list with a
|
|
3
|
+
// take. A refusal names the argument it comes from.
|
|
4
|
+
import type { ExposedModel } from 'ultimabase'
|
|
5
|
+
import { PublicError } from 'ultimabase'
|
|
6
|
+
import type { LivePlan } from 'ultimabase/internal'
|
|
7
|
+
import { liveSchema } from 'ultimabase/internal'
|
|
8
|
+
import { modelsOf, stepOf } from './reach.ts'
|
|
9
|
+
|
|
10
|
+
export const invalid = (path: string, message: string): PublicError =>
|
|
11
|
+
new PublicError('VALIDATION_FAILED', message, {
|
|
12
|
+
details: { issues: [{ path: [path], message }] },
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Refuses an order that reads another row and a selection that reaches a
|
|
17
|
+
* model which is not live; returns the nested rows each root row follows.
|
|
18
|
+
*/
|
|
19
|
+
export function checked(
|
|
20
|
+
exposed: readonly ExposedModel[],
|
|
21
|
+
model: string,
|
|
22
|
+
plan: Omit<LivePlan, 'where' | 'filter' | 'rights'>,
|
|
23
|
+
path: string,
|
|
24
|
+
): number {
|
|
25
|
+
for (const order of plan.orderBy) {
|
|
26
|
+
if (order.through ?? order.count ?? order.relevance) {
|
|
27
|
+
throw invalid(
|
|
28
|
+
'orderBy',
|
|
29
|
+
`A live list sorts by ${model}'s own fields: ${order.field} reads another row.`,
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const models = modelsOf(liveSchema())
|
|
34
|
+
const live = (target: string, at: string): void => {
|
|
35
|
+
const used = exposed.find(({ schema }) => schema.name === target)
|
|
36
|
+
if (!used?.used) throw invalid(at, `${target} is not live.`)
|
|
37
|
+
}
|
|
38
|
+
let rows = 0
|
|
39
|
+
for (const [name, inner] of Object.entries(plan.include ?? {})) {
|
|
40
|
+
const step = stepOf(models, model, name)
|
|
41
|
+
if (step === undefined) continue
|
|
42
|
+
const at = `${path}.${name}`
|
|
43
|
+
live(step.target, at)
|
|
44
|
+
const below = checked(exposed, step.target, { ...inner, orderBy: [] }, at)
|
|
45
|
+
if (!step.many) {
|
|
46
|
+
rows += below
|
|
47
|
+
continue
|
|
48
|
+
}
|
|
49
|
+
if (inner.take === undefined) {
|
|
50
|
+
throw invalid(at, `A live ${name} list needs a take.`)
|
|
51
|
+
}
|
|
52
|
+
rows += Math.abs(inner.take) * (1 + below)
|
|
53
|
+
}
|
|
54
|
+
for (const name of Object.keys(plan.count ?? {})) {
|
|
55
|
+
const step = stepOf(models, model, name)
|
|
56
|
+
if (step !== undefined) live(step.target, `${path}._count.${name}`)
|
|
57
|
+
}
|
|
58
|
+
return rows
|
|
59
|
+
}
|
package/src/computed.ts
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// A live function (D-154): its run as the subscriber, with the reads it made,
|
|
2
|
+
// run again once a write may change one of them, after a quiet wait. Each new
|
|
3
|
+
// result is sent whole; one equal to the last is not. A wake during a run
|
|
4
|
+
// runs it once more after it, and an error closes it.
|
|
5
|
+
import { isDeepStrictEqual } from 'node:util'
|
|
6
|
+
import type { Caller } from 'ultimabase'
|
|
7
|
+
import type { Recorded } from 'ultimabase/internal'
|
|
8
|
+
import { recording } from 'ultimabase/internal'
|
|
9
|
+
import type { Batch } from './changes.ts'
|
|
10
|
+
import type { LiveMessage } from './messages.ts'
|
|
11
|
+
import type { Loader } from './reads.ts'
|
|
12
|
+
import { anyStale } from './stale.ts'
|
|
13
|
+
|
|
14
|
+
/** The longest a run waits after the first wake, however many follow. */
|
|
15
|
+
const longest = 1000
|
|
16
|
+
|
|
17
|
+
export type ComputedOptions = {
|
|
18
|
+
/** The function's result for `caller`, shaped as its transport sends it. */
|
|
19
|
+
readonly compute: (caller: Caller) => Promise<unknown>
|
|
20
|
+
readonly send: (message: LiveMessage) => void
|
|
21
|
+
/** How long a wake waits for quiet, in milliseconds. */
|
|
22
|
+
readonly debounce: number
|
|
23
|
+
/** Runs once a run kept its reads and expiry. */
|
|
24
|
+
readonly onRun: () => void
|
|
25
|
+
/** Runs once an error closed it, after it sent so. */
|
|
26
|
+
readonly onFail: () => void
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class Computed {
|
|
30
|
+
readonly #options: ComputedOptions
|
|
31
|
+
#caller: Caller
|
|
32
|
+
/** What the last run read. */
|
|
33
|
+
reads: readonly Recorded[] = []
|
|
34
|
+
/** When the last run stops holding, as a caller that expires. */
|
|
35
|
+
expires: Date | undefined
|
|
36
|
+
#revision = 0
|
|
37
|
+
#last: unknown
|
|
38
|
+
#first: number | undefined
|
|
39
|
+
#timer: NodeJS.Timeout | undefined
|
|
40
|
+
#running: Promise<void> | undefined
|
|
41
|
+
#again = false
|
|
42
|
+
#closed = false
|
|
43
|
+
|
|
44
|
+
constructor(options: ComputedOptions, caller: Caller) {
|
|
45
|
+
this.#options = options
|
|
46
|
+
this.#caller = caller
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Runs it a first time; an error is thrown, as a refused subscription. */
|
|
50
|
+
async start(): Promise<void> {
|
|
51
|
+
const ran = await recording(async () => this.#options.compute(this.#caller))
|
|
52
|
+
this.#kept(ran)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Wakes it when a write in `batch` may change what it read. */
|
|
56
|
+
async apply(batch: Batch, loader: Loader, missed: boolean): Promise<void> {
|
|
57
|
+
if (this.#closed) return
|
|
58
|
+
if (missed || (await anyStale(this.reads, batch, loader))) this.#wake()
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Runs it again at once as `caller`, whose rights changed. */
|
|
62
|
+
async replan(caller: Caller): Promise<void> {
|
|
63
|
+
this.#caller = caller
|
|
64
|
+
return this.#rerun()
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Sends the last result again, as after messages were dropped. */
|
|
68
|
+
resend(): void {
|
|
69
|
+
if (this.#closed) return
|
|
70
|
+
this.#revision += 1
|
|
71
|
+
const value = this.#last
|
|
72
|
+
this.#options.send({ kind: 'value', revision: this.#revision, value })
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
close(): void {
|
|
76
|
+
this.#closed = true
|
|
77
|
+
clearTimeout(this.#timer)
|
|
78
|
+
this.#timer = undefined
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
#wake(): void {
|
|
82
|
+
const now = Date.now()
|
|
83
|
+
this.#first ??= now
|
|
84
|
+
clearTimeout(this.#timer)
|
|
85
|
+
const wait = Math.min(this.#options.debounce, this.#first + longest - now)
|
|
86
|
+
this.#timer = setTimeout(
|
|
87
|
+
() => {
|
|
88
|
+
this.#rerun().catch(() => undefined)
|
|
89
|
+
},
|
|
90
|
+
Math.max(wait, 0),
|
|
91
|
+
)
|
|
92
|
+
this.#timer.unref()
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async #rerun(): Promise<void> {
|
|
96
|
+
clearTimeout(this.#timer)
|
|
97
|
+
this.#timer = undefined
|
|
98
|
+
this.#first = undefined
|
|
99
|
+
if (this.#closed) return
|
|
100
|
+
if (this.#running !== undefined) {
|
|
101
|
+
this.#again = true
|
|
102
|
+
return this.#running
|
|
103
|
+
}
|
|
104
|
+
this.#running = this.#run()
|
|
105
|
+
try {
|
|
106
|
+
await this.#running
|
|
107
|
+
} finally {
|
|
108
|
+
this.#running = undefined
|
|
109
|
+
}
|
|
110
|
+
if (!this.#again) return
|
|
111
|
+
this.#again = false
|
|
112
|
+
return this.#rerun()
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async #run(): Promise<void> {
|
|
116
|
+
let ran: Awaited<ReturnType<typeof recording<unknown>>>
|
|
117
|
+
try {
|
|
118
|
+
ran = await recording(async () => this.#options.compute(this.#caller))
|
|
119
|
+
} catch (error) {
|
|
120
|
+
if (this.#closed) return
|
|
121
|
+
this.close()
|
|
122
|
+
this.#options.send({ kind: 'closed', error })
|
|
123
|
+
return this.#options.onFail()
|
|
124
|
+
}
|
|
125
|
+
if (!this.#closed) this.#kept(ran)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Keeps a run's reads and sends its result, unless the last was equal. */
|
|
129
|
+
#kept(ran: Awaited<ReturnType<typeof recording<unknown>>>): void {
|
|
130
|
+
this.reads = ran.reads
|
|
131
|
+
this.expires = ran.expires
|
|
132
|
+
this.#options.onRun()
|
|
133
|
+
if (this.#revision > 0 && isDeepStrictEqual(ran.result, this.#last)) {
|
|
134
|
+
return
|
|
135
|
+
}
|
|
136
|
+
this.#last = ran.result
|
|
137
|
+
this.#revision += 1
|
|
138
|
+
const value = ran.result
|
|
139
|
+
this.#options.send({ kind: 'value', revision: this.#revision, value })
|
|
140
|
+
}
|
|
141
|
+
}
|