@carno.js/live 1.8.0 → 1.8.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/dist/LiveEngine.d.ts +14 -0
- package/dist/LiveEngine.js +73 -8
- package/dist/LivePlugin.d.ts +9 -0
- package/dist/LivePlugin.js +12 -0
- package/dist/config.d.ts +12 -1
- package/dist/config.js +1 -0
- package/dist/graph/DependencyGraph.d.ts +25 -0
- package/dist/graph/DependencyGraph.js +65 -6
- package/dist/index.d.ts +2 -0
- package/dist/index.js +4 -1
- package/dist/resource/ResourceRegistry.d.ts +3 -0
- package/dist/resource/ResourceRegistry.js +6 -0
- package/dist/scope-warning.d.ts +28 -0
- package/dist/scope-warning.js +48 -0
- package/dist/shared/canonical.d.ts +24 -0
- package/dist/shared/canonical.js +209 -19
- package/package.json +2 -2
- package/src/LiveEngine.ts +806 -730
- package/src/LivePlugin.ts +276 -253
- package/src/config.ts +61 -49
- package/src/graph/DependencyGraph.ts +223 -147
- package/src/index.ts +83 -81
- package/src/resource/ResourceRegistry.ts +185 -178
- package/src/scope-warning.ts +58 -0
- package/src/shared/canonical.ts +301 -63
- package/test/canonical-equivalence.test.ts +290 -0
- package/test/dependency-graph-index.test.ts +206 -0
- package/test/recompute-concurrency.test.ts +183 -0
- package/test/scope-warning.test.ts +157 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { DependencyGraph } from '../src/graph/DependencyGraph';
|
|
3
|
+
import type { Dependency, InvalidationEvent } from '../src/graph/types';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `resolve` used to answer a table event by scanning every key in the graph
|
|
7
|
+
* with `startsWith`. The scan was replaced by an index, and an index can be
|
|
8
|
+
* wrong in two ways the old code could not: a missed insert silently drops an
|
|
9
|
+
* invalidation, and a missed delete silently leaks memory. Both are checked
|
|
10
|
+
* here against a model of the graph kept outside it.
|
|
11
|
+
*/
|
|
12
|
+
class Model {
|
|
13
|
+
private readonly deps = new Map<string, Dependency[]>();
|
|
14
|
+
|
|
15
|
+
set(instanceId: string, deps: Dependency[]): void {
|
|
16
|
+
if (deps.length === 0) {
|
|
17
|
+
this.deps.delete(instanceId);
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
this.deps.set(instanceId, deps);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
remove(instanceId: string): void {
|
|
25
|
+
this.deps.delete(instanceId);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The same answer, worked out by the rule rather than by an index. */
|
|
29
|
+
resolve(event: InvalidationEvent): string[] {
|
|
30
|
+
const separator = event.key.indexOf('#');
|
|
31
|
+
const table = separator === -1 ? null : event.key.slice(0, separator);
|
|
32
|
+
const descendantPrefix = `${event.key}#`;
|
|
33
|
+
const matched: string[] = [];
|
|
34
|
+
|
|
35
|
+
for (const [instanceId, deps] of this.deps) {
|
|
36
|
+
for (const key of new Set(deps.map(dep => dep.key))) {
|
|
37
|
+
const concerned = key === event.key
|
|
38
|
+
|| key === table
|
|
39
|
+
|| (separator === -1 && key.startsWith(descendantPrefix));
|
|
40
|
+
|
|
41
|
+
if (concerned && intersects(columnsFor(deps, key), event.columns)) {
|
|
42
|
+
matched.push(instanceId);
|
|
43
|
+
break;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return matched;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Mirrors how setDependencies merges repeated deps: null wins, else union. */
|
|
53
|
+
function columnsFor(deps: Dependency[], key: string): string[] | null {
|
|
54
|
+
const union = new Set<string>();
|
|
55
|
+
|
|
56
|
+
for (const dep of deps) {
|
|
57
|
+
if (dep.key !== key) {
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (dep.columns === null) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
for (const column of dep.columns) {
|
|
66
|
+
union.add(column);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return [...union];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function intersects(read: string[] | null, written: string[] | null): boolean {
|
|
74
|
+
if (read === null || written === null) {
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return written.some(column => read.includes(column));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function agree(graph: DependencyGraph, model: Model, event: InvalidationEvent): void {
|
|
82
|
+
expect(graph.resolve(event).sort()).toEqual(model.resolve(event).sort());
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Deterministic PRNG, so a failure is reproducible from its seed alone. */
|
|
86
|
+
function rng(seed: number): () => number {
|
|
87
|
+
let state = seed >>> 0;
|
|
88
|
+
|
|
89
|
+
return () => {
|
|
90
|
+
state = (state * 1664525 + 1013904223) >>> 0;
|
|
91
|
+
return state / 0x100000000;
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const TABLES = ['orm:users', 'orm:tasks', 'orm:user', 'app:poc:tasks'];
|
|
96
|
+
const COLUMNS = [null, ['id'], ['id', 'name'], ['name'], ['last_seen_at']];
|
|
97
|
+
|
|
98
|
+
describe('the table-event index answers exactly what the scan answered', () => {
|
|
99
|
+
test('on a table whose name is a prefix of another', () => {
|
|
100
|
+
const graph = new DependencyGraph();
|
|
101
|
+
graph.setDependencies('user-row', [{ key: 'orm:user#1', columns: null }]);
|
|
102
|
+
graph.setDependencies('users-row', [{ key: 'orm:users#1', columns: null }]);
|
|
103
|
+
|
|
104
|
+
// `orm:users#1`.startsWith('orm:user#') is false, and the index must
|
|
105
|
+
// agree: the parent of that key is `orm:users`, not `orm:user`.
|
|
106
|
+
expect(graph.resolve({ key: 'orm:user', columns: null })).toEqual(['user-row']);
|
|
107
|
+
expect(graph.resolve({ key: 'orm:users', columns: null })).toEqual(['users-row']);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('on a row id that itself contains the separator', () => {
|
|
111
|
+
const graph = new DependencyGraph();
|
|
112
|
+
graph.setDependencies('odd', [{ key: 'orm:users#4#2', columns: null }]);
|
|
113
|
+
|
|
114
|
+
expect(graph.resolve({ key: 'orm:users', columns: null })).toEqual(['odd']);
|
|
115
|
+
expect(graph.resolve({ key: 'orm:users#4', columns: null })).toEqual([]);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('on a manual dependsOn key outside the orm namespace', () => {
|
|
119
|
+
const graph = new DependencyGraph();
|
|
120
|
+
graph.setDependencies('scoped', [{ key: 'app:poc:tasks#7', columns: null }]);
|
|
121
|
+
|
|
122
|
+
expect(graph.resolve({ key: 'app:poc:tasks', columns: null })).toEqual(['scoped']);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('still filters by column on the descendants it now finds by index', () => {
|
|
126
|
+
const graph = new DependencyGraph();
|
|
127
|
+
graph.setDependencies('detail', [{ key: 'orm:users#42', columns: ['id', 'name'] }]);
|
|
128
|
+
|
|
129
|
+
expect(graph.resolve({ key: 'orm:users', columns: ['last_seen_at'] })).toEqual([]);
|
|
130
|
+
expect(graph.resolve({ key: 'orm:users', columns: ['name'] })).toEqual(['detail']);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('drops a row from the table event once its last holder is gone', () => {
|
|
134
|
+
const graph = new DependencyGraph();
|
|
135
|
+
graph.setDependencies('a', [{ key: 'orm:users#42', columns: null }]);
|
|
136
|
+
graph.setDependencies('b', [{ key: 'orm:users#42', columns: null }]);
|
|
137
|
+
|
|
138
|
+
graph.remove('a');
|
|
139
|
+
expect(graph.resolve({ key: 'orm:users', columns: null })).toEqual(['b']);
|
|
140
|
+
|
|
141
|
+
graph.remove('b');
|
|
142
|
+
expect(graph.resolve({ key: 'orm:users', columns: null })).toEqual([]);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('follows an instance that moves from one row to another', () => {
|
|
146
|
+
const graph = new DependencyGraph();
|
|
147
|
+
graph.setDependencies('i', [{ key: 'orm:users#1', columns: null }]);
|
|
148
|
+
graph.setDependencies('i', [{ key: 'orm:users#2', columns: null }]);
|
|
149
|
+
|
|
150
|
+
expect(graph.resolve({ key: 'orm:users#1', columns: null })).toEqual([]);
|
|
151
|
+
expect(graph.resolve({ key: 'orm:users#2', columns: null })).toEqual(['i']);
|
|
152
|
+
expect(graph.resolve({ key: 'orm:users', columns: null })).toEqual(['i']);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('under 3000 random writes and removals across 6 seeds', () => {
|
|
156
|
+
for (let seed = 1; seed <= 6; seed++) {
|
|
157
|
+
const next = rng(seed * 7919);
|
|
158
|
+
const graph = new DependencyGraph();
|
|
159
|
+
const model = new Model();
|
|
160
|
+
const live: string[] = [];
|
|
161
|
+
|
|
162
|
+
for (let step = 0; step < 500; step++) {
|
|
163
|
+
const instanceId = `i${Math.floor(next() * 40)}`;
|
|
164
|
+
|
|
165
|
+
if (live.includes(instanceId) && next() < 0.3) {
|
|
166
|
+
graph.remove(instanceId);
|
|
167
|
+
model.remove(instanceId);
|
|
168
|
+
live.splice(live.indexOf(instanceId), 1);
|
|
169
|
+
} else {
|
|
170
|
+
const deps: Dependency[] = [];
|
|
171
|
+
const count = 1 + Math.floor(next() * 3);
|
|
172
|
+
|
|
173
|
+
for (let d = 0; d < count; d++) {
|
|
174
|
+
const table = TABLES[Math.floor(next() * TABLES.length)];
|
|
175
|
+
const row = next() < 0.7 ? `#${Math.floor(next() * 12)}` : '';
|
|
176
|
+
deps.push({
|
|
177
|
+
key: `${table}${row}`,
|
|
178
|
+
columns: COLUMNS[Math.floor(next() * COLUMNS.length)]
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
graph.setDependencies(instanceId, deps);
|
|
183
|
+
model.set(instanceId, deps);
|
|
184
|
+
if (!live.includes(instanceId)) {
|
|
185
|
+
live.push(instanceId);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
for (const table of TABLES) {
|
|
190
|
+
agree(graph, model, { key: table, columns: null });
|
|
191
|
+
agree(graph, model, { key: table, columns: ['name'] });
|
|
192
|
+
agree(graph, model, { key: `${table}#${Math.floor(next() * 12)}`, columns: null });
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Every instance removed must leave the graph empty, index included.
|
|
197
|
+
for (const instanceId of [...live]) {
|
|
198
|
+
graph.remove(instanceId);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
expect(graph.instanceCount()).toBe(0);
|
|
202
|
+
expect(graph.keyCount()).toBe(0);
|
|
203
|
+
expect(graph.parentCount()).toBe(0);
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
});
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { Controller, Get, Query } from '@carno.js/core';
|
|
3
|
+
import { InProcessBus } from '../src/bus/InProcessBus';
|
|
4
|
+
import { resolveLiveConfig } from '../src/config';
|
|
5
|
+
import { Live } from '../src/decorators/Live';
|
|
6
|
+
import { DependencyGraph } from '../src/graph/DependencyGraph';
|
|
7
|
+
import { SubscriptionRegistry } from '../src/graph/SubscriptionRegistry';
|
|
8
|
+
import { LiveEngine, type LiveTransport } from '../src/LiveEngine';
|
|
9
|
+
import { dependencyContext } from '../src/resource/dependency-context';
|
|
10
|
+
import { ResourceRegistry } from '../src/resource/ResourceRegistry';
|
|
11
|
+
import type { ServerMessage } from '../src/shared/protocol';
|
|
12
|
+
import { directResourceExecutor } from './resource-registry-helper';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Watches how many computes are in flight at once.
|
|
16
|
+
*
|
|
17
|
+
* Every compute runs the resource's route and so its queries, against a
|
|
18
|
+
* database pool of ten by default. The engine used to start a whole slice of
|
|
19
|
+
* five hundred at a time, which does not make them finish sooner -- the driver
|
|
20
|
+
* queues the excess -- but does put hundreds of live queries in front of every
|
|
21
|
+
* ordinary HTTP request waiting for the same pool.
|
|
22
|
+
*/
|
|
23
|
+
class Concurrency {
|
|
24
|
+
current = 0;
|
|
25
|
+
peak = 0;
|
|
26
|
+
total = 0;
|
|
27
|
+
|
|
28
|
+
async run<T>(work: () => Promise<T>): Promise<T> {
|
|
29
|
+
this.current++;
|
|
30
|
+
this.total++;
|
|
31
|
+
this.peak = Math.max(this.peak, this.current);
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
return await work();
|
|
35
|
+
} finally {
|
|
36
|
+
this.current--;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
reset(): void {
|
|
41
|
+
this.current = 0;
|
|
42
|
+
this.peak = 0;
|
|
43
|
+
this.total = 0;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const probe = new Concurrency();
|
|
48
|
+
|
|
49
|
+
@Controller('/probe')
|
|
50
|
+
class ProbeController {
|
|
51
|
+
@Get('/')
|
|
52
|
+
@Live({ shared: 'public' })
|
|
53
|
+
async read(@Query('q') q?: string) {
|
|
54
|
+
dependencyContext.current()?.add({ key: 'orm:probe', columns: null });
|
|
55
|
+
|
|
56
|
+
// Asynchronous on purpose: a compute that never yields cannot overlap
|
|
57
|
+
// another, and would make any limit look respected.
|
|
58
|
+
return await probe.run(async () => {
|
|
59
|
+
await new Promise(resolve => setTimeout(resolve, 1));
|
|
60
|
+
return { q: q ?? '' };
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
class NullTransport implements LiveTransport {
|
|
66
|
+
send(): number {
|
|
67
|
+
return 1;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function build(overrides: Record<string, unknown> = {}) {
|
|
72
|
+
const resources = new ResourceRegistry();
|
|
73
|
+
resources.register(ProbeController, new ProbeController(), directResourceExecutor);
|
|
74
|
+
|
|
75
|
+
const bus = new InProcessBus();
|
|
76
|
+
const engine = new LiveEngine(
|
|
77
|
+
resources,
|
|
78
|
+
new DependencyGraph(),
|
|
79
|
+
new SubscriptionRegistry(),
|
|
80
|
+
bus,
|
|
81
|
+
new NullTransport(),
|
|
82
|
+
resolveLiveConfig({ coalesceMs: 1, unsubGraceMs: 5000, ...overrides })
|
|
83
|
+
);
|
|
84
|
+
engine.start();
|
|
85
|
+
|
|
86
|
+
return { engine, bus };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Subscribe `count` distinct instances of the one resource. */
|
|
90
|
+
async function subscribeMany(engine: LiveEngine, count: number): Promise<void> {
|
|
91
|
+
const pending: Promise<void>[] = [];
|
|
92
|
+
|
|
93
|
+
for (let i = 0; i < count; i++) {
|
|
94
|
+
pending.push(engine.subscribe(
|
|
95
|
+
'c1',
|
|
96
|
+
`s${i}`,
|
|
97
|
+
'ProbeController.read',
|
|
98
|
+
{ params: {}, query: { q: String(i) } },
|
|
99
|
+
{}
|
|
100
|
+
));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
await Promise.all(pending);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const settle = () => new Promise(resolve => setTimeout(resolve, 400));
|
|
107
|
+
|
|
108
|
+
describe('recompute concurrency', () => {
|
|
109
|
+
test('a fan-out never runs more computes at once than the limit allows', async () => {
|
|
110
|
+
const { engine, bus } = build({ maxConcurrentRecomputes: 3 });
|
|
111
|
+
await subscribeMany(engine, 40);
|
|
112
|
+
|
|
113
|
+
probe.reset();
|
|
114
|
+
bus.publish([{ key: 'orm:probe', columns: null }]);
|
|
115
|
+
await settle();
|
|
116
|
+
|
|
117
|
+
expect(probe.peak).toBeLessThanOrEqual(3);
|
|
118
|
+
// And every instance was still recomputed, not merely throttled away.
|
|
119
|
+
expect(probe.total).toBe(40);
|
|
120
|
+
expect(probe.current).toBe(0);
|
|
121
|
+
engine.stop();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('the limit holds across two flushes overlapping', async () => {
|
|
125
|
+
const { engine, bus } = build({ maxConcurrentRecomputes: 2, coalesceMs: 1 });
|
|
126
|
+
await subscribeMany(engine, 30);
|
|
127
|
+
|
|
128
|
+
probe.reset();
|
|
129
|
+
bus.publish([{ key: 'orm:probe', columns: null }]);
|
|
130
|
+
// A second batch while the first is still draining: the cap belongs to
|
|
131
|
+
// the engine, not to one flush, so two flushes cannot double it.
|
|
132
|
+
await new Promise(resolve => setTimeout(resolve, 5));
|
|
133
|
+
bus.publish([{ key: 'orm:probe', columns: null }]);
|
|
134
|
+
await settle();
|
|
135
|
+
|
|
136
|
+
expect(probe.peak).toBeLessThanOrEqual(2);
|
|
137
|
+
expect(probe.current).toBe(0);
|
|
138
|
+
engine.stop();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('a burst of first subscriptions is bounded too', async () => {
|
|
142
|
+
const { engine } = build({ maxConcurrentRecomputes: 3 });
|
|
143
|
+
|
|
144
|
+
probe.reset();
|
|
145
|
+
await subscribeMany(engine, 25);
|
|
146
|
+
|
|
147
|
+
// createInstance runs the same query against the same pool; leaving it
|
|
148
|
+
// out would have left the cap open on the path a deploy hits hardest.
|
|
149
|
+
expect(probe.peak).toBeLessThanOrEqual(3);
|
|
150
|
+
expect(probe.total).toBe(25);
|
|
151
|
+
engine.stop();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('the default keeps room in the pool for ordinary requests', () => {
|
|
155
|
+
// Bun's SQL pool defaults to ten connections. A default at or above
|
|
156
|
+
// that would let a fan-out take every one of them.
|
|
157
|
+
expect(resolveLiveConfig().maxConcurrentRecomputes).toBeLessThan(10);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('stopping releases whoever was queued for a permit', async () => {
|
|
161
|
+
const { engine, bus } = build({ maxConcurrentRecomputes: 1 });
|
|
162
|
+
await subscribeMany(engine, 12);
|
|
163
|
+
|
|
164
|
+
probe.reset();
|
|
165
|
+
bus.publish([{ key: 'orm:probe', columns: null }]);
|
|
166
|
+
await new Promise(resolve => setTimeout(resolve, 3));
|
|
167
|
+
engine.stop();
|
|
168
|
+
await settle();
|
|
169
|
+
|
|
170
|
+
// Nothing is left holding a permit or waiting for one forever.
|
|
171
|
+
expect(probe.current).toBe(0);
|
|
172
|
+
expect(probe.total).toBe(12);
|
|
173
|
+
|
|
174
|
+
// And the permit accounting balanced, so restarting still bounds.
|
|
175
|
+
probe.reset();
|
|
176
|
+
engine.start();
|
|
177
|
+
bus.publish([{ key: 'orm:probe', columns: null }]);
|
|
178
|
+
await settle();
|
|
179
|
+
|
|
180
|
+
expect(probe.peak).toBeLessThanOrEqual(1);
|
|
181
|
+
engine.stop();
|
|
182
|
+
});
|
|
183
|
+
});
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
import { Controller, Get, createTestHarness } from '@carno.js/core';
|
|
3
|
+
import { Live } from '../src/decorators/Live';
|
|
4
|
+
import { LivePlugin } from '../src/LivePlugin';
|
|
5
|
+
import { ResourceRegistry } from '../src/resource/ResourceRegistry';
|
|
6
|
+
import { closeLiveRuntime } from '../src/runtime';
|
|
7
|
+
import { defaultScopeWarning } from '../src/scope-warning';
|
|
8
|
+
import { ConnectionScopeResolver } from '../src/transport/scope-resolver';
|
|
9
|
+
import { directResourceExecutor } from './resource-registry-helper';
|
|
10
|
+
|
|
11
|
+
@Controller('/mixed')
|
|
12
|
+
class MixedController {
|
|
13
|
+
@Get('/inbox')
|
|
14
|
+
@Live()
|
|
15
|
+
inbox() {
|
|
16
|
+
return [];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
@Get('/me')
|
|
20
|
+
@Live({ shared: 'private' })
|
|
21
|
+
me() {
|
|
22
|
+
return {};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
@Get('/catalogue')
|
|
26
|
+
@Live({ shared: 'public' })
|
|
27
|
+
catalogue() {
|
|
28
|
+
return [];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
@Get('/org')
|
|
32
|
+
@Live({ shared: 'tenant' })
|
|
33
|
+
org() {
|
|
34
|
+
return [];
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function registry(): ResourceRegistry {
|
|
39
|
+
const resources = new ResourceRegistry();
|
|
40
|
+
resources.register(MixedController, new MixedController(), directResourceExecutor);
|
|
41
|
+
|
|
42
|
+
return resources;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe('ResourceRegistry.idsShared', () => {
|
|
46
|
+
test('counts an undeclared @Live() as private', () => {
|
|
47
|
+
expect(registry().idsShared('private')).toEqual([
|
|
48
|
+
'MixedController.inbox',
|
|
49
|
+
'MixedController.me'
|
|
50
|
+
]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('separates the shared modes', () => {
|
|
54
|
+
expect(registry().idsShared('public')).toEqual(['MixedController.catalogue']);
|
|
55
|
+
expect(registry().idsShared('tenant')).toEqual(['MixedController.org']);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('the default-scope boot warning', () => {
|
|
60
|
+
test('names the private resources and the node ceiling', () => {
|
|
61
|
+
const warning = defaultScopeWarning({
|
|
62
|
+
privateResourceIds: registry().idsShared('private'),
|
|
63
|
+
usingDefaultResolver: true,
|
|
64
|
+
maxInstancesPerNode: 50000
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
expect(warning).toContain('MixedController.inbox');
|
|
68
|
+
expect(warning).toContain('MixedController.me');
|
|
69
|
+
expect(warning).toContain('50000');
|
|
70
|
+
expect(warning).toContain('scopeResolver');
|
|
71
|
+
// The modes that do not scale in connections stay out of it.
|
|
72
|
+
expect(warning).not.toContain('MixedController.catalogue');
|
|
73
|
+
expect(warning).not.toContain('MixedController.org');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('says nothing once the application brings its own resolver', () => {
|
|
77
|
+
expect(defaultScopeWarning({
|
|
78
|
+
privateResourceIds: ['A.list'],
|
|
79
|
+
usingDefaultResolver: false,
|
|
80
|
+
maxInstancesPerNode: 50000
|
|
81
|
+
})).toBeNull();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('says nothing when no resource is private', () => {
|
|
85
|
+
expect(defaultScopeWarning({
|
|
86
|
+
privateResourceIds: [],
|
|
87
|
+
usingDefaultResolver: true,
|
|
88
|
+
maxInstancesPerNode: 50000
|
|
89
|
+
})).toBeNull();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('collapses a long list to a count', () => {
|
|
93
|
+
const ids = Array.from({ length: 12 }, (_, index) => `C.r${index}`);
|
|
94
|
+
const warning = defaultScopeWarning({
|
|
95
|
+
privateResourceIds: ids,
|
|
96
|
+
usingDefaultResolver: true,
|
|
97
|
+
maxInstancesPerNode: 50000
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
expect(warning).toContain('C.r7');
|
|
101
|
+
expect(warning).not.toContain('C.r8');
|
|
102
|
+
expect(warning).toContain('and 4 more');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('agrees in number with a single resource', () => {
|
|
106
|
+
const warning = defaultScopeWarning({
|
|
107
|
+
privateResourceIds: ['A.list'],
|
|
108
|
+
usingDefaultResolver: true,
|
|
109
|
+
maxInstancesPerNode: 50000
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
expect(warning).toContain('1 live resource is private');
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
@Controller('/inbox')
|
|
117
|
+
class InboxController {
|
|
118
|
+
@Get('/')
|
|
119
|
+
@Live()
|
|
120
|
+
read() {
|
|
121
|
+
return { unread: 0 };
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Boot an app and return what it wrote to the console while starting. */
|
|
126
|
+
async function bootLogs(options: { scopeResolver?: ConnectionScopeResolver } = {}): Promise<string> {
|
|
127
|
+
const written: string[] = [];
|
|
128
|
+
const original = console.warn;
|
|
129
|
+
console.warn = (...args: unknown[]) => { written.push(args.map(String).join(' ')); };
|
|
130
|
+
|
|
131
|
+
try {
|
|
132
|
+
const harness = await createTestHarness({
|
|
133
|
+
plugins: [LivePlugin.create({ controllers: [InboxController], ...options })],
|
|
134
|
+
listen: true
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
await harness.close();
|
|
138
|
+
} finally {
|
|
139
|
+
console.warn = original;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return written.join('\n');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
describe('the warning as the application sees it', () => {
|
|
146
|
+
afterEach(async () => {
|
|
147
|
+
await closeLiveRuntime();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('a private resource with no resolver warns at boot', async () => {
|
|
151
|
+
expect(await bootLogs()).toContain('InboxController.read');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('an explicit ConnectionScopeResolver is taken as the answer', async () => {
|
|
155
|
+
expect(await bootLogs({ scopeResolver: new ConnectionScopeResolver() })).toBe('');
|
|
156
|
+
});
|
|
157
|
+
});
|