@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.
Files changed (63) hide show
  1. package/CHANGELOG.md +19 -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/CHANGELOG.md ADDED
@@ -0,0 +1,19 @@
1
+ # @ultimabase/realtime
2
+
3
+ ## 0.1.1
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies
8
+ - ultimabase@0.1.1
9
+
10
+ ## 0.1.0
11
+
12
+ ### Minor Changes
13
+
14
+ - 0e885c4: The first release: the kernel, its adapters, transports and plugins, the testing helpers and the starter.
15
+
16
+ ### Patch Changes
17
+
18
+ - Updated dependencies [0e885c4]
19
+ - ultimabase@0.1.0
package/README.md CHANGED
@@ -1,3 +1,5 @@
1
- # Temporary Holding Version
1
+ # @ultimabase/realtime
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Live reads for UltimaBase. A client subscribes to a list or a row, gets it once, then only what changed, under its own read rules, over GraphQL subscriptions or Server-Sent Events.
4
+
5
+ Documentation: [Realtime](https://ultimabase.dev/docs/realtime).
@@ -0,0 +1,29 @@
1
+ import type { Batch } from './changes.ts';
2
+ import type { Reach, Step } from './reach.ts';
3
+ export type Affected = {
4
+ /** Root rows whose membership or order may have changed. */
5
+ readonly candidates: ReadonlySet<string>;
6
+ /** Window rows deleted. */
7
+ readonly deleted: ReadonlySet<string>;
8
+ /** A row past the window may have left the list. */
9
+ readonly beyond: boolean;
10
+ /** Window rows to read and send again. */
11
+ readonly resend: ReadonlySet<string>;
12
+ };
13
+ /**
14
+ * The root rows that reach one of `ids` through `steps`, among `within` when
15
+ * it is given.
16
+ */
17
+ export type Finder = (steps: readonly Step[], ids: readonly string[], within?: readonly string[]) => Promise<readonly string[]>;
18
+ /** The window rows each row a shown relation reached is shown under. */
19
+ export type Tracked = ReadonlyMap<string, ReadonlyMap<string, ReadonlySet<string>>>;
20
+ export type Facing = {
21
+ readonly model: string;
22
+ readonly reach: Reach;
23
+ readonly window: ReadonlySet<string>;
24
+ readonly tracked: Tracked;
25
+ readonly find: Finder;
26
+ };
27
+ /** What `batch` does to the live read `facing` describes. */
28
+ export declare function affected(facing: Facing, batch: Batch): Promise<Affected | 'resync'>;
29
+ //# sourceMappingURL=affected.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"affected.d.ts","sourceRoot":"","sources":["../src/affected.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,cAAc,CAAA;AAEzC,OAAO,KAAK,EAAE,KAAK,EAAE,IAAI,EAAE,MAAM,YAAY,CAAA;AAE7C,MAAM,MAAM,QAAQ,GAAG;IACtB,4DAA4D;IAC5D,QAAQ,CAAC,UAAU,EAAE,WAAW,CAAC,MAAM,CAAC,CAAA;IACxC,2BAA2B;IAC3B,QAAQ,CAAC,OAAO,EAAE,WAAW,CAAC,MAAM,CAAC,CAAA;IACrC,oDAAoD;IACpD,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAA;IACxB,0CAA0C;IAC1C,QAAQ,CAAC,MAAM,EAAE,WAAW,CAAC,MAAM,CAAC,CAAA;CACpC,CAAA;AAED;;;GAGG;AACH,MAAM,MAAM,MAAM,GAAG,CACpB,KAAK,EAAE,SAAS,IAAI,EAAE,EACtB,GAAG,EAAE,SAAS,MAAM,EAAE,EACtB,MAAM,CAAC,EAAE,SAAS,MAAM,EAAE,KACtB,OAAO,CAAC,SAAS,MAAM,EAAE,CAAC,CAAA;AAE/B,wEAAwE;AACxE,MAAM,MAAM,OAAO,GAAG,WAAW,CAChC,MAAM,EACN,WAAW,CAAC,MAAM,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC,CACxC,CAAA;AAED,MAAM,MAAM,MAAM,GAAG;IACpB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;IACtB,QAAQ,CAAC,KAAK,EAAE,KAAK,CAAA;IACrB,QAAQ,CAAC,MAAM,EAAE,WAAW,CAAC,MAAM,CAAC,CAAA;IACpC,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAA;IACzB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;CACrB,CAAA;AAED,6DAA6D;AAC7D,wBAAsB,QAAQ,CAC7B,MAAM,EAAE,MAAM,EACd,KAAK,EAAE,KAAK,GACV,OAAO,CAAC,QAAQ,GAAG,QAAQ,CAAC,CA0C9B"}
@@ -0,0 +1,89 @@
1
+ import { listed, touched, unlisted } from './changes.js';
2
+ /** What `batch` does to the live read `facing` describes. */
3
+ export async function affected(facing, batch) {
4
+ const { model, reach, window, find } = facing;
5
+ const root = batch.get(model);
6
+ if (unlisted(root))
7
+ return 'resync';
8
+ const candidates = new Set(listed(root?.inserts));
9
+ const deleted = new Set();
10
+ let beyond = false;
11
+ for (const id of listed(root?.deletes)) {
12
+ if (window.has(id))
13
+ deleted.add(id);
14
+ else
15
+ beyond = true;
16
+ }
17
+ const updated = listed(root?.updates);
18
+ if (touched(root, reach.placing)) {
19
+ for (const id of updated)
20
+ candidates.add(id);
21
+ }
22
+ const resend = new Set();
23
+ if (touched(root, reach.showing)) {
24
+ for (const id of updated)
25
+ if (window.has(id))
26
+ resend.add(id);
27
+ }
28
+ for (const level of reach.levels) {
29
+ const changes = batch.get(level.model);
30
+ const step = level.steps.at(-1);
31
+ if (step === undefined || changes === undefined)
32
+ continue;
33
+ if (unlisted(changes))
34
+ return 'resync';
35
+ const ids = [
36
+ ...listed(changes.inserts),
37
+ ...(touched(changes, level.fields) ? listed(changes.updates) : []),
38
+ ];
39
+ if (ids.length > 0) {
40
+ for (const id of await find(level.steps, ids))
41
+ candidates.add(id);
42
+ }
43
+ // A link lost where it is stored: the rows that had it are unknown.
44
+ const lost = changes.deletes !== undefined ||
45
+ (step.keyOnTarget && touched(changes, new Set([step.inverse])));
46
+ if (!lost)
47
+ continue;
48
+ if (level.negative)
49
+ return 'resync';
50
+ for (const id of window)
51
+ candidates.add(id);
52
+ }
53
+ const again = await shown(facing, batch);
54
+ for (const id of again === 'all' ? window : again)
55
+ resend.add(id);
56
+ return { candidates, deleted, beyond, resend };
57
+ }
58
+ /** The window rows whose shown relations changed. */
59
+ async function shown(facing, batch) {
60
+ const { reach, window, tracked, find } = facing;
61
+ for (const model of reach.deep)
62
+ if (batch.has(model))
63
+ return 'all';
64
+ const resend = new Set();
65
+ for (const relation of reach.shown) {
66
+ const changes = batch.get(relation.model);
67
+ if (changes === undefined)
68
+ continue;
69
+ if (unlisted(changes))
70
+ return 'all';
71
+ const updated = touched(changes, relation.fields)
72
+ ? listed(changes.updates)
73
+ : [];
74
+ if (relation.counted && changes.deletes !== undefined)
75
+ return 'all';
76
+ const known = tracked.get(relation.path);
77
+ for (const id of [...updated, ...listed(changes.deletes)]) {
78
+ for (const root of known?.get(id) ?? [])
79
+ resend.add(root);
80
+ }
81
+ const ids = [...listed(changes.inserts), ...updated];
82
+ if (ids.length === 0 || window.size === 0)
83
+ continue;
84
+ for (const id of await find(relation.steps, ids, [...window])) {
85
+ resend.add(id);
86
+ }
87
+ }
88
+ return resend;
89
+ }
@@ -0,0 +1,22 @@
1
+ import type { Changed, Schema } from 'ultimabase';
2
+ /** Some rows by id, or every row when the feed did not list them. */
3
+ export type Rows = ReadonlySet<string> | 'all';
4
+ /** Some fields by name, or every field. */
5
+ export type Fields = ReadonlySet<string> | 'all';
6
+ export interface ModelChanges {
7
+ readonly inserts?: Rows;
8
+ readonly updates?: Rows;
9
+ readonly deletes?: Rows;
10
+ /** The fields its updates set. */
11
+ readonly fields?: Fields;
12
+ }
13
+ export type Batch = ReadonlyMap<string, ModelChanges>;
14
+ /** `changes` by model; a link's sides are found in `schema`. */
15
+ export declare function batchOf(changes: readonly Changed[], schema: Schema): Batch;
16
+ /** Whether `changes` updated one of `fields`. */
17
+ export declare function touched(changes: ModelChanges | undefined, fields: Fields): boolean;
18
+ /** Whether a list of `changes` is unknown. */
19
+ export declare const unlisted: (changes: ModelChanges | undefined) => boolean;
20
+ /** The ids of `rows`, none when they are every row. */
21
+ export declare const listed: (rows: Rows | undefined) => readonly string[];
22
+ //# sourceMappingURL=changes.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"changes.d.ts","sourceRoot":"","sources":["../src/changes.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,YAAY,CAAA;AAEjD,qEAAqE;AACrE,MAAM,MAAM,IAAI,GAAG,WAAW,CAAC,MAAM,CAAC,GAAG,KAAK,CAAA;AAC9C,2CAA2C;AAC3C,MAAM,MAAM,MAAM,GAAG,WAAW,CAAC,MAAM,CAAC,GAAG,KAAK,CAAA;AAEhD,MAAM,WAAW,YAAY;IAC5B,QAAQ,CAAC,OAAO,CAAC,EAAE,IAAI,CAAA;IACvB,QAAQ,CAAC,OAAO,CAAC,EAAE,IAAI,CAAA;IACvB,QAAQ,CAAC,OAAO,CAAC,EAAE,IAAI,CAAA;IACvB,kCAAkC;IAClC,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAA;CACxB;AAED,MAAM,MAAM,KAAK,GAAG,WAAW,CAAC,MAAM,EAAE,YAAY,CAAC,CAAA;AAmBrD,gEAAgE;AAChE,wBAAgB,OAAO,CAAC,OAAO,EAAE,SAAS,OAAO,EAAE,EAAE,MAAM,EAAE,MAAM,GAAG,KAAK,CA6C1E;AAED,iDAAiD;AACjD,wBAAgB,OAAO,CACtB,OAAO,EAAE,YAAY,GAAG,SAAS,EACjC,MAAM,EAAE,MAAM,GACZ,OAAO,CAMT;AAED,8CAA8C;AAC9C,eAAO,MAAM,QAAQ,YAAa,YAAY,GAAG,SAAS,KAAG,OAGlC,CAAA;AAE3B,uDAAuD;AACvD,eAAO,MAAM,MAAM,SAAU,IAAI,GAAG,SAAS,KAAG,SAAS,MAAM,EACT,CAAA"}
@@ -0,0 +1,64 @@
1
+ const merge = (into, values) => {
2
+ if (into === 'all' || values === undefined)
3
+ return 'all';
4
+ const set = into ?? new Set();
5
+ for (const value of values)
6
+ set.add(value);
7
+ return set;
8
+ };
9
+ /** `changes` by model; a link's sides are found in `schema`. */
10
+ export function batchOf(changes, schema) {
11
+ const batch = new Map();
12
+ const of = (model) => {
13
+ const known = batch.get(model);
14
+ if (known !== undefined)
15
+ return known;
16
+ const created = {};
17
+ batch.set(model, created);
18
+ return created;
19
+ };
20
+ const updated = (model, ids, fields) => {
21
+ const building = of(model);
22
+ building.updates = merge(building.updates, ids);
23
+ building.fields = merge(building.fields, fields);
24
+ };
25
+ for (const change of changes) {
26
+ if (change.kind === 'link' || change.kind === 'unlink') {
27
+ const field = schema.models
28
+ .find(({ name }) => name === change.model)
29
+ ?.fields.find(({ name }) => name === change.field);
30
+ const pairs = change.pairs;
31
+ updated(change.model, pairs?.map(([id]) => id), [change.field]);
32
+ if (field?.kind === 'many') {
33
+ updated(field.target, pairs?.map(([, id]) => id), [field.inverse]);
34
+ }
35
+ }
36
+ else if (change.kind === 'update') {
37
+ updated(change.model, change.ids, change.fields);
38
+ }
39
+ else if (change.kind === 'insert' || change.kind === 'delete') {
40
+ const building = of(change.model);
41
+ const kind = change.kind === 'insert' ? 'inserts' : 'deletes';
42
+ building[kind] = merge(building[kind], change.ids);
43
+ }
44
+ }
45
+ return batch;
46
+ }
47
+ /** Whether `changes` updated one of `fields`. */
48
+ export function touched(changes, fields) {
49
+ const set = changes?.fields;
50
+ if (changes?.updates === undefined || set === undefined)
51
+ return false;
52
+ if (set === 'all' || fields === 'all')
53
+ return true;
54
+ for (const field of set)
55
+ if (fields.has(field))
56
+ return true;
57
+ return false;
58
+ }
59
+ /** Whether a list of `changes` is unknown. */
60
+ export const unlisted = (changes) => changes?.inserts === 'all' ||
61
+ changes?.updates === 'all' ||
62
+ changes?.deletes === 'all';
63
+ /** The ids of `rows`, none when they are every row. */
64
+ export const listed = (rows) => rows === undefined || rows === 'all' ? [] : [...rows];
@@ -0,0 +1,10 @@
1
+ import type { ExposedModel } from 'ultimabase';
2
+ import { PublicError } from 'ultimabase';
3
+ import type { LivePlan } from 'ultimabase/internal';
4
+ export declare const invalid: (path: string, message: string) => PublicError;
5
+ /**
6
+ * Refuses an order that reads another row and a selection that reaches a
7
+ * model which is not live; returns the nested rows each root row follows.
8
+ */
9
+ export declare function checked(exposed: readonly ExposedModel[], model: string, plan: Omit<LivePlan, 'where' | 'filter' | 'rights'>, path: string): number;
10
+ //# sourceMappingURL=checked.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"checked.d.ts","sourceRoot":"","sources":["../src/checked.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAA;AAC9C,OAAO,EAAE,WAAW,EAAE,MAAM,YAAY,CAAA;AACxC,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,qBAAqB,CAAA;AAInD,eAAO,MAAM,OAAO,SAAU,MAAM,WAAW,MAAM,KAAG,WAGrD,CAAA;AAEH;;;GAGG;AACH,wBAAgB,OAAO,CACtB,OAAO,EAAE,SAAS,YAAY,EAAE,EAChC,KAAK,EAAE,MAAM,EACb,IAAI,EAAE,IAAI,CAAC,QAAQ,EAAE,OAAO,GAAG,QAAQ,GAAG,QAAQ,CAAC,EACnD,IAAI,EAAE,MAAM,GACV,MAAM,CAmCR"}
@@ -0,0 +1,46 @@
1
+ import { PublicError } from 'ultimabase';
2
+ import { liveSchema } from 'ultimabase/internal';
3
+ import { modelsOf, stepOf } from './reach.js';
4
+ export const invalid = (path, message) => new PublicError('VALIDATION_FAILED', message, {
5
+ details: { issues: [{ path: [path], message }] },
6
+ });
7
+ /**
8
+ * Refuses an order that reads another row and a selection that reaches a
9
+ * model which is not live; returns the nested rows each root row follows.
10
+ */
11
+ export function checked(exposed, model, plan, path) {
12
+ for (const order of plan.orderBy) {
13
+ if (order.through ?? order.count ?? order.relevance) {
14
+ throw invalid('orderBy', `A live list sorts by ${model}'s own fields: ${order.field} reads another row.`);
15
+ }
16
+ }
17
+ const models = modelsOf(liveSchema());
18
+ const live = (target, at) => {
19
+ const used = exposed.find(({ schema }) => schema.name === target);
20
+ if (!used?.used)
21
+ throw invalid(at, `${target} is not live.`);
22
+ };
23
+ let rows = 0;
24
+ for (const [name, inner] of Object.entries(plan.include ?? {})) {
25
+ const step = stepOf(models, model, name);
26
+ if (step === undefined)
27
+ continue;
28
+ const at = `${path}.${name}`;
29
+ live(step.target, at);
30
+ const below = checked(exposed, step.target, { ...inner, orderBy: [] }, at);
31
+ if (!step.many) {
32
+ rows += below;
33
+ continue;
34
+ }
35
+ if (inner.take === undefined) {
36
+ throw invalid(at, `A live ${name} list needs a take.`);
37
+ }
38
+ rows += Math.abs(inner.take) * (1 + below);
39
+ }
40
+ for (const name of Object.keys(plan.count ?? {})) {
41
+ const step = stepOf(models, model, name);
42
+ if (step !== undefined)
43
+ live(step.target, `${path}._count.${name}`);
44
+ }
45
+ return rows;
46
+ }
@@ -0,0 +1,34 @@
1
+ import type { Caller } from 'ultimabase';
2
+ import type { Recorded } 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 ComputedOptions = {
7
+ /** The function's result for `caller`, shaped as its transport sends it. */
8
+ readonly compute: (caller: Caller) => Promise<unknown>;
9
+ readonly send: (message: LiveMessage) => void;
10
+ /** How long a wake waits for quiet, in milliseconds. */
11
+ readonly debounce: number;
12
+ /** Runs once a run kept its reads and expiry. */
13
+ readonly onRun: () => void;
14
+ /** Runs once an error closed it, after it sent so. */
15
+ readonly onFail: () => void;
16
+ };
17
+ export declare class Computed {
18
+ #private;
19
+ /** What the last run read. */
20
+ reads: readonly Recorded[];
21
+ /** When the last run stops holding, as a caller that expires. */
22
+ expires: Date | undefined;
23
+ constructor(options: ComputedOptions, caller: Caller);
24
+ /** Runs it a first time; an error is thrown, as a refused subscription. */
25
+ start(): Promise<void>;
26
+ /** Wakes it when a write in `batch` may change what it read. */
27
+ apply(batch: Batch, loader: Loader, missed: boolean): Promise<void>;
28
+ /** Runs it again at once as `caller`, whose rights changed. */
29
+ replan(caller: Caller): Promise<void>;
30
+ /** Sends the last result again, as after messages were dropped. */
31
+ resend(): void;
32
+ close(): void;
33
+ }
34
+ //# sourceMappingURL=computed.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"computed.d.ts","sourceRoot":"","sources":["../src/computed.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,YAAY,CAAA;AACxC,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,qBAAqB,CAAA;AAEnD,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,cAAc,CAAA;AACzC,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,eAAe,CAAA;AAChD,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,YAAY,CAAA;AAMxC,MAAM,MAAM,eAAe,GAAG;IAC7B,4EAA4E;IAC5E,QAAQ,CAAC,OAAO,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,OAAO,CAAC,OAAO,CAAC,CAAA;IACtD,QAAQ,CAAC,IAAI,EAAE,CAAC,OAAO,EAAE,WAAW,KAAK,IAAI,CAAA;IAC7C,wDAAwD;IACxD,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;IACzB,iDAAiD;IACjD,QAAQ,CAAC,KAAK,EAAE,MAAM,IAAI,CAAA;IAC1B,sDAAsD;IACtD,QAAQ,CAAC,MAAM,EAAE,MAAM,IAAI,CAAA;CAC3B,CAAA;AAED,qBAAa,QAAQ;;IAGpB,8BAA8B;IAC9B,KAAK,EAAE,SAAS,QAAQ,EAAE,CAAK;IAC/B,iEAAiE;IACjE,OAAO,EAAE,IAAI,GAAG,SAAS,CAAA;IASzB,YAAY,OAAO,EAAE,eAAe,EAAE,MAAM,EAAE,MAAM,EAGnD;IAED,2EAA2E;IACrE,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAG3B;IAED,gEAAgE;IAC1D,KAAK,CAAC,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CAGxE;IAED,+DAA+D;IACzD,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAG1C;IAED,mEAAmE;IACnE,MAAM,IAAI,IAAI,CAKb;IAED,KAAK,IAAI,IAAI,CAIZ;CA8DD"}
@@ -0,0 +1,118 @@
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 { recording } from 'ultimabase/internal';
7
+ import { anyStale } from './stale.js';
8
+ /** The longest a run waits after the first wake, however many follow. */
9
+ const longest = 1000;
10
+ export class Computed {
11
+ #options;
12
+ #caller;
13
+ /** What the last run read. */
14
+ reads = [];
15
+ /** When the last run stops holding, as a caller that expires. */
16
+ expires;
17
+ #revision = 0;
18
+ #last;
19
+ #first;
20
+ #timer;
21
+ #running;
22
+ #again = false;
23
+ #closed = false;
24
+ constructor(options, caller) {
25
+ this.#options = options;
26
+ this.#caller = caller;
27
+ }
28
+ /** Runs it a first time; an error is thrown, as a refused subscription. */
29
+ async start() {
30
+ const ran = await recording(async () => this.#options.compute(this.#caller));
31
+ this.#kept(ran);
32
+ }
33
+ /** Wakes it when a write in `batch` may change what it read. */
34
+ async apply(batch, loader, missed) {
35
+ if (this.#closed)
36
+ return;
37
+ if (missed || (await anyStale(this.reads, batch, loader)))
38
+ this.#wake();
39
+ }
40
+ /** Runs it again at once as `caller`, whose rights changed. */
41
+ async replan(caller) {
42
+ this.#caller = caller;
43
+ return this.#rerun();
44
+ }
45
+ /** Sends the last result again, as after messages were dropped. */
46
+ resend() {
47
+ if (this.#closed)
48
+ return;
49
+ this.#revision += 1;
50
+ const value = this.#last;
51
+ this.#options.send({ kind: 'value', revision: this.#revision, value });
52
+ }
53
+ close() {
54
+ this.#closed = true;
55
+ clearTimeout(this.#timer);
56
+ this.#timer = undefined;
57
+ }
58
+ #wake() {
59
+ const now = Date.now();
60
+ this.#first ??= now;
61
+ clearTimeout(this.#timer);
62
+ const wait = Math.min(this.#options.debounce, this.#first + longest - now);
63
+ this.#timer = setTimeout(() => {
64
+ this.#rerun().catch(() => undefined);
65
+ }, Math.max(wait, 0));
66
+ this.#timer.unref();
67
+ }
68
+ async #rerun() {
69
+ clearTimeout(this.#timer);
70
+ this.#timer = undefined;
71
+ this.#first = undefined;
72
+ if (this.#closed)
73
+ return;
74
+ if (this.#running !== undefined) {
75
+ this.#again = true;
76
+ return this.#running;
77
+ }
78
+ this.#running = this.#run();
79
+ try {
80
+ await this.#running;
81
+ }
82
+ finally {
83
+ this.#running = undefined;
84
+ }
85
+ if (!this.#again)
86
+ return;
87
+ this.#again = false;
88
+ return this.#rerun();
89
+ }
90
+ async #run() {
91
+ let ran;
92
+ try {
93
+ ran = await recording(async () => this.#options.compute(this.#caller));
94
+ }
95
+ catch (error) {
96
+ if (this.#closed)
97
+ return;
98
+ this.close();
99
+ this.#options.send({ kind: 'closed', error });
100
+ return this.#options.onFail();
101
+ }
102
+ if (!this.#closed)
103
+ this.#kept(ran);
104
+ }
105
+ /** Keeps a run's reads and sends its result, unless the last was equal. */
106
+ #kept(ran) {
107
+ this.reads = ran.reads;
108
+ this.expires = ran.expires;
109
+ this.#options.onRun();
110
+ if (this.#revision > 0 && isDeepStrictEqual(ran.result, this.#last)) {
111
+ return;
112
+ }
113
+ this.#last = ran.result;
114
+ this.#revision += 1;
115
+ const value = ran.result;
116
+ this.#options.send({ kind: 'value', revision: this.#revision, value });
117
+ }
118
+ }
@@ -0,0 +1,13 @@
1
+ import type { Changed } from 'ultimabase';
2
+ import type { Settings } from './viewer.ts';
3
+ import { Viewer } from './viewer.ts';
4
+ export declare class Engine {
5
+ #private;
6
+ constructor(settings: Settings, refresh: number);
7
+ /** A viewer for `request`'s caller, kept until it closes. */
8
+ connect(request: Request): Promise<Viewer>;
9
+ changed(changes: readonly Changed[]): void;
10
+ missed(): void;
11
+ stop(): Promise<void>;
12
+ }
13
+ //# sourceMappingURL=engine.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"engine.d.ts","sourceRoot":"","sources":["../src/engine.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,YAAY,CAAA;AAIzC,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAA;AAC3C,OAAO,EAAE,MAAM,EAAE,MAAM,aAAa,CAAA;AAwBpC,qBAAa,MAAM;;IAUlB,YAAY,QAAQ,EAAE,QAAQ,EAAE,OAAO,EAAE,MAAM,EAM9C;IAED,6DAA6D;IACvD,OAAO,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,MAAM,CAAC,CAa/C;IAED,OAAO,CAAC,OAAO,EAAE,SAAS,OAAO,EAAE,GAAG,IAAI,CAGzC;IAED,MAAM,IAAI,IAAI,CAGb;IAEK,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAO1B;CAsCD"}
package/dist/engine.js ADDED
@@ -0,0 +1,99 @@
1
+ import { liveSchema, recording } from 'ultimabase/internal';
2
+ import { batchOf } from './changes.js';
3
+ import { Loader } from './reads.js';
4
+ import { Viewer } from './viewer.js';
5
+ /** How long changes wait, so the writes close together share one message. */
6
+ const gather = 50;
7
+ /** How many viewers follow a flush at once. */
8
+ const concurrency = 8;
9
+ /** Runs `task` on each of `items`, `limit` at a time. */
10
+ async function bounded(items, limit, task) {
11
+ let next = 0;
12
+ const worker = async () => {
13
+ while (next < items.length) {
14
+ const item = items[next];
15
+ next += 1;
16
+ if (item !== undefined)
17
+ await task(item);
18
+ }
19
+ };
20
+ await Promise.all(Array.from({ length: limit }, worker));
21
+ }
22
+ export class Engine {
23
+ #settings;
24
+ #viewers = new Set();
25
+ #refresh;
26
+ #changes = [];
27
+ #missed = false;
28
+ #stopped = false;
29
+ #timer;
30
+ #chain = Promise.resolve();
31
+ constructor(settings, refresh) {
32
+ this.#settings = settings;
33
+ this.#refresh = setInterval(() => {
34
+ this.#chain = this.#chain.then(async () => this.#rebuild());
35
+ }, refresh);
36
+ this.#refresh.unref();
37
+ }
38
+ /** A viewer for `request`'s caller, kept until it closes. */
39
+ async connect(request) {
40
+ const { identify } = this.#settings;
41
+ const identified = await recording(async () => identify(request));
42
+ const viewer = new Viewer(this.#settings, request, identified, {
43
+ onClose: () => this.#viewers.delete(viewer),
44
+ onDue: () => {
45
+ this.#chain = this.#chain.then(async () => this.#guarded(viewer, async () => viewer.rebuild()));
46
+ },
47
+ });
48
+ this.#viewers.add(viewer);
49
+ return viewer;
50
+ }
51
+ changed(changes) {
52
+ this.#changes.push(...changes);
53
+ this.#schedule();
54
+ }
55
+ missed() {
56
+ this.#missed = true;
57
+ this.#schedule();
58
+ }
59
+ async stop() {
60
+ this.#stopped = true;
61
+ clearInterval(this.#refresh);
62
+ clearTimeout(this.#timer);
63
+ this.#timer = undefined;
64
+ for (const viewer of this.#viewers)
65
+ viewer.close();
66
+ await this.#chain;
67
+ }
68
+ #schedule() {
69
+ // A change the adapter delivers once the application stopped is late.
70
+ if (this.#stopped || this.#timer !== undefined)
71
+ return;
72
+ this.#timer = setTimeout(() => {
73
+ this.#timer = undefined;
74
+ this.#chain = this.#chain.then(async () => this.#flush());
75
+ }, gather);
76
+ }
77
+ async #flush() {
78
+ const changes = this.#changes;
79
+ const missed = this.#missed;
80
+ this.#changes = [];
81
+ this.#missed = false;
82
+ const batch = batchOf(changes, liveSchema());
83
+ const loader = new Loader();
84
+ await bounded([...this.#viewers], concurrency, async (viewer) => this.#guarded(viewer, async () => viewer.flush(batch, loader, missed)));
85
+ }
86
+ async #rebuild() {
87
+ await bounded([...this.#viewers], concurrency, async (viewer) => this.#guarded(viewer, async () => viewer.rebuild()));
88
+ }
89
+ /** A viewer that fails is logged and closed; the others go on. */
90
+ async #guarded(viewer, task) {
91
+ try {
92
+ await task();
93
+ }
94
+ catch (error) {
95
+ this.#settings.logger.error({ err: error }, 'A live viewer failed.');
96
+ viewer.end();
97
+ }
98
+ }
99
+ }
@@ -0,0 +1,40 @@
1
+ import type { PluginOf, PluginTypes, UseOf } from 'ultimabase';
2
+ import type { Viewer } from './viewer.ts';
3
+ export type { LiveMessage, Removal, Upsert } from './messages.ts';
4
+ export type { LiveHandle, Viewer } from './viewer.ts';
5
+ export type RealtimeOptions = {
6
+ /** The most rows a live list follows, nested rows included; 1000. */
7
+ readonly maxLive?: number;
8
+ /** The most live reads one connection holds; 100. */
9
+ readonly maxSubscriptions?: number;
10
+ /** The most rows one connection follows in all, nested rows included; 10 000. */
11
+ readonly maxConnectionRows?: number;
12
+ /** How often, in milliseconds, each caller is built again; 5 minutes. */
13
+ readonly refresh?: number;
14
+ };
15
+ /** What the realtime plugin offers transports. */
16
+ export interface RealtimeApi {
17
+ /** Follows `request`'s caller; its live reads close with it. */
18
+ connect(this: void, request: Request): Promise<Viewer>;
19
+ /** Whether `model`'s `use` lists `realtime()`. */
20
+ live(this: void, model: string): boolean;
21
+ /** Whether function `name`'s `use` lists `realtime()`, `plugin.name` for a plugin's. */
22
+ liveFunction(this: void, name: string): boolean;
23
+ }
24
+ /** The realtime plugin's types (D-57). */
25
+ export interface Realtime extends PluginTypes {
26
+ readonly name: 'realtime';
27
+ readonly appOptions: RealtimeOptions;
28
+ readonly with: {
29
+ readonly maxLive?: number;
30
+ };
31
+ /** A live function's wait for quiet after a write, in milliseconds; 50. */
32
+ readonly functionWith: {
33
+ readonly debounce?: number;
34
+ };
35
+ readonly queryOnly: true;
36
+ readonly api: RealtimeApi;
37
+ }
38
+ export declare const realtime: UseOf<Realtime>;
39
+ export declare const realtimePlugin: PluginOf<Realtime>;
40
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,QAAQ,EAAE,WAAW,EAAE,KAAK,EAAE,MAAM,YAAY,CAAA;AAI9D,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,aAAa,CAAA;AAGzC,YAAY,EAAE,WAAW,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,eAAe,CAAA;AACjE,YAAY,EAAE,UAAU,EAAE,MAAM,EAAE,MAAM,aAAa,CAAA;AAErD,MAAM,MAAM,eAAe,GAAG;IAC7B,qEAAqE;IACrE,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAA;IACzB,qDAAqD;IACrD,QAAQ,CAAC,gBAAgB,CAAC,EAAE,MAAM,CAAA;IAClC,iFAAiF;IACjF,QAAQ,CAAC,iBAAiB,CAAC,EAAE,MAAM,CAAA;IACnC,yEAAyE;IACzE,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAA;CACzB,CAAA;AAED,kDAAkD;AAClD,MAAM,WAAW,WAAW;IAC3B,gEAAgE;IAChE,OAAO,CAAC,IAAI,EAAE,IAAI,EAAE,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,MAAM,CAAC,CAAA;IACtD,kDAAkD;IAClD,IAAI,CAAC,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAA;IACxC,wFAAwF;IACxF,YAAY,CAAC,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAA;CAC/C;AAED,0CAA0C;AAC1C,MAAM,WAAW,QAAS,SAAQ,WAAW;IAC5C,QAAQ,CAAC,IAAI,EAAE,UAAU,CAAA;IACzB,QAAQ,CAAC,UAAU,EAAE,eAAe,CAAA;IACpC,QAAQ,CAAC,IAAI,EAAE;QAAE,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAA;KAAE,CAAA;IAC5C,2EAA2E;IAC3E,QAAQ,CAAC,YAAY,EAAE;QAAE,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;KAAE,CAAA;IACrD,QAAQ,CAAC,SAAS,EAAE,IAAI,CAAA;IACxB,QAAQ,CAAC,GAAG,EAAE,WAAW,CAAA;CACzB;AAsDD,eAAO,MAAM,QAAQ,EAAE,KAAK,CAAC,QAAQ,CAA0B,CAAA;AAC/D,eAAO,MAAM,cAAc,EAAE,QAAQ,CAAC,QAAQ,CAA6B,CAAA"}