@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
@@ -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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
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.0"
35
+ },
36
+ "devDependencies": {
37
+ "@ultimabase/acl": "0.1.0",
38
+ "@ultimabase/roles": "0.1.0",
39
+ "@ultimabase/soft-delete": "0.1.0",
40
+ "@ultimabase/testing": "0.1.0",
41
+ "ultimabase": "0.1.0",
42
+ "vite": "8.3.4",
43
+ "vitest": "5.0.5"
44
+ }
6
45
  }
@@ -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
+ }
@@ -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
+ }