@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
package/dist/LiveEngine.d.ts
CHANGED
|
@@ -45,6 +45,16 @@ export declare class LiveEngine {
|
|
|
45
45
|
private readonly scopes;
|
|
46
46
|
/** connectionId → instanceId → decision. Cleared when an `auth:` key fires. */
|
|
47
47
|
private readonly authorized;
|
|
48
|
+
/**
|
|
49
|
+
* Permits for concurrent computes, and whoever is queued for one.
|
|
50
|
+
*
|
|
51
|
+
* Every compute runs the resource's route and so its queries, against a
|
|
52
|
+
* database pool far smaller than any fan-out. This caps them where the
|
|
53
|
+
* query actually happens, so overlapping flushes and the dirty-retry path
|
|
54
|
+
* are held to the same bound as a flush.
|
|
55
|
+
*/
|
|
56
|
+
private inFlight;
|
|
57
|
+
private readonly waiting;
|
|
48
58
|
private flushTimer;
|
|
49
59
|
private unsubscribeBus;
|
|
50
60
|
private recomputes;
|
|
@@ -78,6 +88,10 @@ export declare class LiveEngine {
|
|
|
78
88
|
private sendState;
|
|
79
89
|
private onInvalidation;
|
|
80
90
|
private flush;
|
|
91
|
+
/** A permit to run one compute, now or when one frees up. */
|
|
92
|
+
private acquirePermit;
|
|
93
|
+
/** Hand the permit straight to the next waiter, so the count never dips. */
|
|
94
|
+
private releasePermit;
|
|
81
95
|
private recompute;
|
|
82
96
|
private runCompute;
|
|
83
97
|
private broadcast;
|
package/dist/LiveEngine.js
CHANGED
|
@@ -30,6 +30,16 @@ class LiveEngine {
|
|
|
30
30
|
this.scopes = new Map();
|
|
31
31
|
/** connectionId → instanceId → decision. Cleared when an `auth:` key fires. */
|
|
32
32
|
this.authorized = new Map();
|
|
33
|
+
/**
|
|
34
|
+
* Permits for concurrent computes, and whoever is queued for one.
|
|
35
|
+
*
|
|
36
|
+
* Every compute runs the resource's route and so its queries, against a
|
|
37
|
+
* database pool far smaller than any fan-out. This caps them where the
|
|
38
|
+
* query actually happens, so overlapping flushes and the dirty-retry path
|
|
39
|
+
* are held to the same bound as a flush.
|
|
40
|
+
*/
|
|
41
|
+
this.inFlight = 0;
|
|
42
|
+
this.waiting = [];
|
|
33
43
|
this.flushTimer = null;
|
|
34
44
|
this.unsubscribeBus = null;
|
|
35
45
|
this.recomputes = 0;
|
|
@@ -53,6 +63,15 @@ class LiveEngine {
|
|
|
53
63
|
clearTimeout(instance.dropTimer);
|
|
54
64
|
}
|
|
55
65
|
}
|
|
66
|
+
// Let whoever is queued for a permit through: they finish the compute
|
|
67
|
+
// they already started, rather than awaiting a release that stopping
|
|
68
|
+
// means will never come. Each one is counted as it goes, so the
|
|
69
|
+
// release it makes on the way out balances and a restarted engine
|
|
70
|
+
// does not begin below zero.
|
|
71
|
+
while (this.waiting.length > 0) {
|
|
72
|
+
this.inFlight++;
|
|
73
|
+
this.waiting.shift()();
|
|
74
|
+
}
|
|
56
75
|
}
|
|
57
76
|
async subscribe(connectionId, sid, resourceId, inputs, scope, clientHash) {
|
|
58
77
|
const resource = this.resources.get(resourceId);
|
|
@@ -317,7 +336,17 @@ class LiveEngine {
|
|
|
317
336
|
}
|
|
318
337
|
}
|
|
319
338
|
async createInstance(instanceId, resource, inputs, scope) {
|
|
320
|
-
|
|
339
|
+
// The same permit as a recompute: a burst of first subscriptions is
|
|
340
|
+
// as many queries as a fan-out is, and hits the same pool.
|
|
341
|
+
await this.acquirePermit();
|
|
342
|
+
let data;
|
|
343
|
+
let deps;
|
|
344
|
+
try {
|
|
345
|
+
({ data, deps } = await this.resources.compute(resource, inputs, { scope }));
|
|
346
|
+
}
|
|
347
|
+
finally {
|
|
348
|
+
this.releasePermit();
|
|
349
|
+
}
|
|
321
350
|
this.recomputes++;
|
|
322
351
|
this.graph.setDependencies(instanceId, deps);
|
|
323
352
|
const instance = {
|
|
@@ -387,15 +416,43 @@ class LiveEngine {
|
|
|
387
416
|
const batch = [...this.pending];
|
|
388
417
|
this.pending.clear();
|
|
389
418
|
this.metrics.instances(this.instances.size);
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
419
|
+
// A bounded pool, not `Promise.all` over a slice of five hundred. The
|
|
420
|
+
// permits taken in `runCompute` are what cap the queries; this is what
|
|
421
|
+
// keeps a fan-out of fifty thousand from materializing fifty thousand
|
|
422
|
+
// pending promises just to hold them.
|
|
423
|
+
const workers = Math.min(Math.max(1, this.config.maxConcurrentRecomputes), batch.length);
|
|
424
|
+
const yieldEvery = Math.max(1, this.config.fanoutQueueThreshold);
|
|
425
|
+
let next = 0;
|
|
426
|
+
let finished = 0;
|
|
427
|
+
const run = async () => {
|
|
428
|
+
while (next < batch.length) {
|
|
429
|
+
await this.recompute(batch[next++]);
|
|
430
|
+
finished++;
|
|
431
|
+
if (finished % yieldEvery === 0) {
|
|
432
|
+
// Yield so a large fan-out does not monopolize the loop
|
|
433
|
+
// and stall unrelated requests.
|
|
434
|
+
await new Promise(resolve => setTimeout(resolve, 0));
|
|
435
|
+
}
|
|
397
436
|
}
|
|
437
|
+
};
|
|
438
|
+
await Promise.all(Array.from({ length: workers }, () => run()));
|
|
439
|
+
}
|
|
440
|
+
/** A permit to run one compute, now or when one frees up. */
|
|
441
|
+
acquirePermit() {
|
|
442
|
+
if (this.inFlight < Math.max(1, this.config.maxConcurrentRecomputes)) {
|
|
443
|
+
this.inFlight++;
|
|
444
|
+
return Promise.resolve();
|
|
398
445
|
}
|
|
446
|
+
return new Promise(resolve => this.waiting.push(resolve));
|
|
447
|
+
}
|
|
448
|
+
/** Hand the permit straight to the next waiter, so the count never dips. */
|
|
449
|
+
releasePermit() {
|
|
450
|
+
const next = this.waiting.shift();
|
|
451
|
+
if (next) {
|
|
452
|
+
next();
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
this.inFlight--;
|
|
399
456
|
}
|
|
400
457
|
recompute(instanceId) {
|
|
401
458
|
const instance = this.instances.get(instanceId);
|
|
@@ -418,6 +475,9 @@ class LiveEngine {
|
|
|
418
475
|
return instance.computing;
|
|
419
476
|
}
|
|
420
477
|
async runCompute(instance) {
|
|
478
|
+
// Taken before the clock starts, so `live.recompute.ms` keeps meaning
|
|
479
|
+
// the compute rather than the wait for a free connection.
|
|
480
|
+
await this.acquirePermit();
|
|
421
481
|
const startedAt = performance.now();
|
|
422
482
|
let data;
|
|
423
483
|
let deps;
|
|
@@ -432,6 +492,11 @@ class LiveEngine {
|
|
|
432
492
|
await this.broadcast(instance, sid => ({ t: 'stale', sid, reason: error.message }));
|
|
433
493
|
return;
|
|
434
494
|
}
|
|
495
|
+
finally {
|
|
496
|
+
// Only the compute is held: the hash, the diff and the fan-out
|
|
497
|
+
// below need no connection.
|
|
498
|
+
this.releasePermit();
|
|
499
|
+
}
|
|
435
500
|
this.recomputes++;
|
|
436
501
|
this.graph.setDependencies(instance.id, deps);
|
|
437
502
|
const hash = (0, hash_1.fnv1a64)((0, canonical_1.canonical)(data));
|
package/dist/LivePlugin.d.ts
CHANGED
|
@@ -13,6 +13,15 @@ export interface LivePluginOptions {
|
|
|
13
13
|
* builder, so a second plugin silently wins and orphans the first.
|
|
14
14
|
*/
|
|
15
15
|
gateways?: (new (...args: any[]) => any)[];
|
|
16
|
+
/**
|
|
17
|
+
* Turns a handshake into the principal and tenant that key an instance.
|
|
18
|
+
*
|
|
19
|
+
* Left out, the default `ConnectionScopeResolver` makes the connection id
|
|
20
|
+
* the principal, so every `private` resource gets one instance per
|
|
21
|
+
* connection and the boot logs say so. Passing
|
|
22
|
+
* `new ConnectionScopeResolver()` explicitly is how an application states
|
|
23
|
+
* that per-connection instances are what it wants, and silences that.
|
|
24
|
+
*/
|
|
16
25
|
scopeResolver?: LiveScopeResolver;
|
|
17
26
|
/**
|
|
18
27
|
* Decides whether a connection may hold a subscription, and is re-asked
|
package/dist/LivePlugin.js
CHANGED
|
@@ -26,6 +26,7 @@ const SocketTransport_1 = require("./transport/SocketTransport");
|
|
|
26
26
|
const SseTransport_1 = require("./transport/SseTransport");
|
|
27
27
|
const sse_routes_1 = require("./transport/sse-routes");
|
|
28
28
|
const protocol_1 = require("./shared/protocol");
|
|
29
|
+
const scope_warning_1 = require("./scope-warning");
|
|
29
30
|
class LivePlugin {
|
|
30
31
|
static create(options) {
|
|
31
32
|
const config = (0, config_1.resolveLiveConfig)(options.config);
|
|
@@ -122,6 +123,17 @@ class LivePlugin {
|
|
|
122
123
|
resources.register(ControllerClass, container.get(ControllerClass), routeExecutor);
|
|
123
124
|
}
|
|
124
125
|
teachEtag?.(resources.livePaths());
|
|
126
|
+
// Both halves of the per-connection trap are known only here: the
|
|
127
|
+
// resolver comes from the options, the resources from the scan the
|
|
128
|
+
// loop above just finished.
|
|
129
|
+
const scopeWarning = (0, scope_warning_1.defaultScopeWarning)({
|
|
130
|
+
privateResourceIds: resources.idsShared('private'),
|
|
131
|
+
usingDefaultResolver: options.scopeResolver === undefined,
|
|
132
|
+
maxInstancesPerNode: config.maxInstancesPerNode
|
|
133
|
+
});
|
|
134
|
+
if (scopeWarning) {
|
|
135
|
+
console.warn(scopeWarning);
|
|
136
|
+
}
|
|
125
137
|
if (options.pgNotify) {
|
|
126
138
|
const driver = orm_1.Orm.getInstance().driverInstance;
|
|
127
139
|
const deliver = (events) => {
|
package/dist/config.d.ts
CHANGED
|
@@ -13,8 +13,19 @@ export interface LiveConfig {
|
|
|
13
13
|
unsubGraceMs: number;
|
|
14
14
|
/** Consecutive back-pressured sends before collapsing to a snapshot. */
|
|
15
15
|
maxPendingPatches: number;
|
|
16
|
-
/**
|
|
16
|
+
/** Recomputes finished in one run before the loop is yielded back. */
|
|
17
17
|
fanoutQueueThreshold: number;
|
|
18
|
+
/**
|
|
19
|
+
* Recomputes allowed to run at once, across every path.
|
|
20
|
+
*
|
|
21
|
+
* Each one executes the resource's route, and so its queries. Raising it
|
|
22
|
+
* past the database pool buys no parallelism -- the driver queues the
|
|
23
|
+
* excess either way -- it only puts live queries in front of the ordinary
|
|
24
|
+
* HTTP requests competing for the same connections. The default sits well
|
|
25
|
+
* under Bun's pool default of 10; raise it with the pool, not with the
|
|
26
|
+
* fan-out.
|
|
27
|
+
*/
|
|
28
|
+
maxConcurrentRecomputes: number;
|
|
18
29
|
/** Ceiling on live instances held by a single connection. */
|
|
19
30
|
maxInstancesPerConnection: number;
|
|
20
31
|
/** Ceiling on live instances held by this process. */
|
package/dist/config.js
CHANGED
|
@@ -9,6 +9,18 @@ import type { Dependency, InvalidationEvent } from './types';
|
|
|
9
9
|
export declare class DependencyGraph {
|
|
10
10
|
private readonly byKey;
|
|
11
11
|
private readonly byInstance;
|
|
12
|
+
/**
|
|
13
|
+
* Registered row keys grouped by the key that contains them, so a write
|
|
14
|
+
* that names a whole table finds its rows instead of being searched for.
|
|
15
|
+
*
|
|
16
|
+
* Without it, resolving a table event means walking every key in the
|
|
17
|
+
* graph: at the configured `maxInstancesPerNode` that is a five-millisecond
|
|
18
|
+
* scan, run synchronously once per event of the batch, with every HTTP
|
|
19
|
+
* request in the process waiting behind it. Table events are not the rare
|
|
20
|
+
* case either -- a write degrades to its table key unless its WHERE clause
|
|
21
|
+
* is a literal primary-key match.
|
|
22
|
+
*/
|
|
23
|
+
private readonly byParent;
|
|
12
24
|
/** Replace every dependency held by this instance. */
|
|
13
25
|
setDependencies(instanceId: string, deps: Dependency[]): void;
|
|
14
26
|
/** Forget the instance entirely. */
|
|
@@ -23,5 +35,18 @@ export declare class DependencyGraph {
|
|
|
23
35
|
resolve(event: InvalidationEvent): string[];
|
|
24
36
|
keyCount(): number;
|
|
25
37
|
instanceCount(): number;
|
|
38
|
+
/** Keys that currently hold indexed rows. Zero when the graph is empty. */
|
|
39
|
+
parentCount(): number;
|
|
40
|
+
/**
|
|
41
|
+
* Record a row key's holders under the key that contains it.
|
|
42
|
+
*
|
|
43
|
+
* A table key has no separator and so no parent; it is reached directly.
|
|
44
|
+
* The holder map is stored rather than the key because it is what
|
|
45
|
+
* `resolve` actually needs, and because its identity is stable: it is
|
|
46
|
+
* created once in `setDependencies` and dropped only in `remove`.
|
|
47
|
+
*/
|
|
48
|
+
private index;
|
|
49
|
+
private unindex;
|
|
26
50
|
private collect;
|
|
51
|
+
private collectFrom;
|
|
27
52
|
}
|
|
@@ -13,6 +13,18 @@ class DependencyGraph {
|
|
|
13
13
|
constructor() {
|
|
14
14
|
this.byKey = new Map();
|
|
15
15
|
this.byInstance = new Map();
|
|
16
|
+
/**
|
|
17
|
+
* Registered row keys grouped by the key that contains them, so a write
|
|
18
|
+
* that names a whole table finds its rows instead of being searched for.
|
|
19
|
+
*
|
|
20
|
+
* Without it, resolving a table event means walking every key in the
|
|
21
|
+
* graph: at the configured `maxInstancesPerNode` that is a five-millisecond
|
|
22
|
+
* scan, run synchronously once per event of the batch, with every HTTP
|
|
23
|
+
* request in the process waiting behind it. Table events are not the rare
|
|
24
|
+
* case either -- a write degrades to its table key unless its WHERE clause
|
|
25
|
+
* is a literal primary-key match.
|
|
26
|
+
*/
|
|
27
|
+
this.byParent = new Map();
|
|
16
28
|
}
|
|
17
29
|
/** Replace every dependency held by this instance. */
|
|
18
30
|
setDependencies(instanceId, deps) {
|
|
@@ -27,6 +39,7 @@ class DependencyGraph {
|
|
|
27
39
|
if (!holders) {
|
|
28
40
|
holders = new Map();
|
|
29
41
|
this.byKey.set(dep.key, holders);
|
|
42
|
+
this.index(dep.key, holders);
|
|
30
43
|
}
|
|
31
44
|
if (!holders.has(instanceId)) {
|
|
32
45
|
holders.set(instanceId, dep.columns === null ? null : new Set(dep.columns));
|
|
@@ -60,6 +73,7 @@ class DependencyGraph {
|
|
|
60
73
|
holders.delete(instanceId);
|
|
61
74
|
if (holders.size === 0) {
|
|
62
75
|
this.byKey.delete(key);
|
|
76
|
+
this.unindex(key, holders);
|
|
63
77
|
}
|
|
64
78
|
}
|
|
65
79
|
this.byInstance.delete(instanceId);
|
|
@@ -76,11 +90,14 @@ class DependencyGraph {
|
|
|
76
90
|
for (const key of (0, dep_key_1.ancestorsOf)(event.key)) {
|
|
77
91
|
this.collect(key, event.columns, matched);
|
|
78
92
|
}
|
|
79
|
-
|
|
93
|
+
// A key with a `#` is already a row: it has no descendants, and the
|
|
94
|
+
// ancestor pass above has covered its table.
|
|
80
95
|
if (!event.key.includes('#')) {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
96
|
+
const rows = this.byParent.get(event.key);
|
|
97
|
+
if (rows) {
|
|
98
|
+
// Holder maps, not keys: the key would only be looked up again.
|
|
99
|
+
for (const holders of rows) {
|
|
100
|
+
this.collectFrom(holders, event.columns, matched);
|
|
84
101
|
}
|
|
85
102
|
}
|
|
86
103
|
}
|
|
@@ -92,11 +109,53 @@ class DependencyGraph {
|
|
|
92
109
|
instanceCount() {
|
|
93
110
|
return this.byInstance.size;
|
|
94
111
|
}
|
|
112
|
+
/** Keys that currently hold indexed rows. Zero when the graph is empty. */
|
|
113
|
+
parentCount() {
|
|
114
|
+
return this.byParent.size;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Record a row key's holders under the key that contains it.
|
|
118
|
+
*
|
|
119
|
+
* A table key has no separator and so no parent; it is reached directly.
|
|
120
|
+
* The holder map is stored rather than the key because it is what
|
|
121
|
+
* `resolve` actually needs, and because its identity is stable: it is
|
|
122
|
+
* created once in `setDependencies` and dropped only in `remove`.
|
|
123
|
+
*/
|
|
124
|
+
index(key, holders) {
|
|
125
|
+
const separator = key.indexOf('#');
|
|
126
|
+
if (separator === -1) {
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const parent = key.slice(0, separator);
|
|
130
|
+
let rows = this.byParent.get(parent);
|
|
131
|
+
if (!rows) {
|
|
132
|
+
rows = new Set();
|
|
133
|
+
this.byParent.set(parent, rows);
|
|
134
|
+
}
|
|
135
|
+
rows.add(holders);
|
|
136
|
+
}
|
|
137
|
+
unindex(key, holders) {
|
|
138
|
+
const separator = key.indexOf('#');
|
|
139
|
+
if (separator === -1) {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const parent = key.slice(0, separator);
|
|
143
|
+
const rows = this.byParent.get(parent);
|
|
144
|
+
if (!rows) {
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
rows.delete(holders);
|
|
148
|
+
if (rows.size === 0) {
|
|
149
|
+
this.byParent.delete(parent);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
95
152
|
collect(key, writtenColumns, into) {
|
|
96
153
|
const holders = this.byKey.get(key);
|
|
97
|
-
if (
|
|
98
|
-
|
|
154
|
+
if (holders) {
|
|
155
|
+
this.collectFrom(holders, writtenColumns, into);
|
|
99
156
|
}
|
|
157
|
+
}
|
|
158
|
+
collectFrom(holders, writtenColumns, into) {
|
|
100
159
|
for (const [instanceId, readColumns] of holders) {
|
|
101
160
|
if (intersects(readColumns, writtenColumns)) {
|
|
102
161
|
into.add(instanceId);
|
package/dist/index.d.ts
CHANGED
|
@@ -15,6 +15,8 @@ export type { LiveConfig } from './config';
|
|
|
15
15
|
export { ConnectionScopeResolver } from './transport/scope-resolver';
|
|
16
16
|
export type { LiveHandshake, LiveScopeResolver } from './transport/scope-resolver';
|
|
17
17
|
export type { LiveExecutionContext, LiveInputs, LiveResourceExecutor, LiveScope } from './resource/types';
|
|
18
|
+
export { defaultScopeWarning } from './scope-warning';
|
|
19
|
+
export type { DefaultScopeWarningInput } from './scope-warning';
|
|
18
20
|
export { AllowAllAuthorizer, authKeysOf, isAuthKey } from './auth/authorizer';
|
|
19
21
|
export type { LiveAuthorizationRequest, LiveAuthorizer } from './auth/authorizer';
|
|
20
22
|
export { InProcessBus } from './bus/InProcessBus';
|
package/dist/index.js
CHANGED
|
@@ -14,7 +14,8 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
|
14
14
|
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
15
|
};
|
|
16
16
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
-
exports.
|
|
17
|
+
exports.readHydrationPayload = exports.toHydrateMap = exports.hydrationKey = exports.LiveRouteExecutionError = exports.prefetchLive = exports.pathMatcher = exports.LiveETagMiddleware = exports.createSseRoutes = exports.SseTransport = exports.FanTransport = exports.routeIndex = exports.SseClientTransport = exports.PollingTransport = exports.LadderTransport = exports.WebSocketTransport = exports.LiveSlot = exports.liveIdentity = exports.liveStoreOf = exports.liveStore = exports.resourceIdOf = exports.normalizeLiveInputs = exports.fnv1a64 = exports.NonSerializableInputError = exports.canonical = exports.PatchEngine = exports.tableOfKey = exports.PgListener = exports.eventsFromPayload = exports.PgNotifyEmitter = exports.chunkEvents = exports.PgNotifyBus = exports.WriteDuringComputeError = exports.tableKey = exports.rowKey = exports.ancestorsOf = exports.InProcessBus = exports.isAuthKey = exports.authKeysOf = exports.AllowAllAuthorizer = exports.defaultScopeWarning = exports.ConnectionScopeResolver = exports.resolveLiveConfig = exports.DEFAULT_LIVE_CONFIG = exports.LiveMetrics = exports.LiveEngine = exports.LiveService = exports.closeLiveRuntime = exports.LivePlugin = exports.LIVE_META = exports.Live = void 0;
|
|
18
|
+
exports.HYDRATION_ATTRIBUTE = void 0;
|
|
18
19
|
require("reflect-metadata");
|
|
19
20
|
// Decorator and metadata
|
|
20
21
|
var Live_1 = require("./decorators/Live");
|
|
@@ -40,6 +41,8 @@ Object.defineProperty(exports, "resolveLiveConfig", { enumerable: true, get: fun
|
|
|
40
41
|
// Scope
|
|
41
42
|
var scope_resolver_1 = require("./transport/scope-resolver");
|
|
42
43
|
Object.defineProperty(exports, "ConnectionScopeResolver", { enumerable: true, get: function () { return scope_resolver_1.ConnectionScopeResolver; } });
|
|
44
|
+
var scope_warning_1 = require("./scope-warning");
|
|
45
|
+
Object.defineProperty(exports, "defaultScopeWarning", { enumerable: true, get: function () { return scope_warning_1.defaultScopeWarning; } });
|
|
43
46
|
// Authorization
|
|
44
47
|
var authorizer_1 = require("./auth/authorizer");
|
|
45
48
|
Object.defineProperty(exports, "AllowAllAuthorizer", { enumerable: true, get: function () { return authorizer_1.AllowAllAuthorizer; } });
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import 'reflect-metadata';
|
|
2
2
|
import type { Dependency } from '../graph/types';
|
|
3
|
+
import { type LiveShared } from '../metadata';
|
|
3
4
|
import type { LiveExecutionContext, LiveInputs, LiveResource, LiveResourceExecutor } from './types';
|
|
4
5
|
export declare class LiveValidationError extends Error {
|
|
5
6
|
constructor(message: string);
|
|
@@ -16,6 +17,8 @@ export declare class ResourceRegistry {
|
|
|
16
17
|
register(ControllerClass: new (...args: any[]) => any, instance: any, executor: LiveResourceExecutor): void;
|
|
17
18
|
get(id: string): LiveResource | undefined;
|
|
18
19
|
ids(): string[];
|
|
20
|
+
/** Ids declared with this sharing mode, in registration order. */
|
|
21
|
+
idsShared(shared: LiveShared): string[];
|
|
19
22
|
/** Every live route, as the HTTP layer addresses it. */
|
|
20
23
|
livePaths(): {
|
|
21
24
|
method: string;
|
|
@@ -99,6 +99,12 @@ class ResourceRegistry {
|
|
|
99
99
|
ids() {
|
|
100
100
|
return [...this.resources.keys()];
|
|
101
101
|
}
|
|
102
|
+
/** Ids declared with this sharing mode, in registration order. */
|
|
103
|
+
idsShared(shared) {
|
|
104
|
+
return [...this.resources.values()]
|
|
105
|
+
.filter(resource => resource.meta.shared === shared)
|
|
106
|
+
.map(resource => resource.id);
|
|
107
|
+
}
|
|
102
108
|
/** Every live route, as the HTTP layer addresses it. */
|
|
103
109
|
livePaths() {
|
|
104
110
|
return [...this.resources.values()].map(resource => ({
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one trap the default configuration sets, reported at boot.
|
|
3
|
+
*
|
|
4
|
+
* `@Live()` defaults to `shared: 'private'` and `ConnectionScopeResolver`
|
|
5
|
+
* makes the connection id the principal, so with neither configured the
|
|
6
|
+
* instance identity carries a connection id: two tabs of one user are two
|
|
7
|
+
* instances, and N viewers of the same data are N computes, N diffs and N
|
|
8
|
+
* queries. That is the safe default — nothing can leak between connections —
|
|
9
|
+
* but it scales in connections rather than in data, and it is silent.
|
|
10
|
+
*
|
|
11
|
+
* Both halves are known at bootstrap, so this is a fact rather than a
|
|
12
|
+
* heuristic: no sampling of a live instance rate is needed to state it.
|
|
13
|
+
*/
|
|
14
|
+
export interface DefaultScopeWarningInput {
|
|
15
|
+
/** Ids of the resources that resolved to `shared: 'private'`. */
|
|
16
|
+
privateResourceIds: string[];
|
|
17
|
+
/** False as soon as the application passes any resolver of its own. */
|
|
18
|
+
usingDefaultResolver: boolean;
|
|
19
|
+
/** `LiveConfig.maxInstancesPerNode`, the ceiling this default runs into. */
|
|
20
|
+
maxInstancesPerNode: number;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* The warning text, or null when there is nothing to warn about.
|
|
24
|
+
*
|
|
25
|
+
* Returned rather than printed so the decision is testable without capturing
|
|
26
|
+
* the console.
|
|
27
|
+
*/
|
|
28
|
+
export declare function defaultScopeWarning(input: DefaultScopeWarningInput): string | null;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* The one trap the default configuration sets, reported at boot.
|
|
4
|
+
*
|
|
5
|
+
* `@Live()` defaults to `shared: 'private'` and `ConnectionScopeResolver`
|
|
6
|
+
* makes the connection id the principal, so with neither configured the
|
|
7
|
+
* instance identity carries a connection id: two tabs of one user are two
|
|
8
|
+
* instances, and N viewers of the same data are N computes, N diffs and N
|
|
9
|
+
* queries. That is the safe default — nothing can leak between connections —
|
|
10
|
+
* but it scales in connections rather than in data, and it is silent.
|
|
11
|
+
*
|
|
12
|
+
* Both halves are known at bootstrap, so this is a fact rather than a
|
|
13
|
+
* heuristic: no sampling of a live instance rate is needed to state it.
|
|
14
|
+
*/
|
|
15
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
16
|
+
exports.defaultScopeWarning = defaultScopeWarning;
|
|
17
|
+
/** Resource ids listed inline before the message collapses to a count. */
|
|
18
|
+
const MAX_LISTED = 8;
|
|
19
|
+
/**
|
|
20
|
+
* The warning text, or null when there is nothing to warn about.
|
|
21
|
+
*
|
|
22
|
+
* Returned rather than printed so the decision is testable without capturing
|
|
23
|
+
* the console.
|
|
24
|
+
*/
|
|
25
|
+
function defaultScopeWarning(input) {
|
|
26
|
+
if (!input.usingDefaultResolver || input.privateResourceIds.length === 0) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
const count = input.privateResourceIds.length;
|
|
30
|
+
const listed = input.privateResourceIds.slice(0, MAX_LISTED).join(', ');
|
|
31
|
+
const rest = count - Math.min(count, MAX_LISTED);
|
|
32
|
+
const names = rest > 0 ? `${listed} and ${rest} more` : listed;
|
|
33
|
+
const noun = count === 1 ? 'live resource is' : 'live resources are';
|
|
34
|
+
return [
|
|
35
|
+
'[carno:live] No `scopeResolver` was passed to LivePlugin.create(), so the default',
|
|
36
|
+
'ConnectionScopeResolver keys every instance by connection id.',
|
|
37
|
+
` ${count} ${noun} private (the @Live() default) and will get one instance per`,
|
|
38
|
+
' connection rather than one per user:',
|
|
39
|
+
` ${names}`,
|
|
40
|
+
' Two tabs of the same user are two instances, and N viewers of the same data are N',
|
|
41
|
+
` computes, N diffs and N queries — against the ceiling of ${input.maxInstancesPerNode} instances per node`,
|
|
42
|
+
' (LiveConfig.maxInstancesPerNode), past which subscriptions are refused.',
|
|
43
|
+
' Fix: pass a `scopeResolver` whose principal is a user id, or declare the resources',
|
|
44
|
+
' that are genuinely shared as @Live({ shared: \'public\' }) or @Live({ shared: \'tenant\' }).',
|
|
45
|
+
' To keep per-connection instances and silence this, pass',
|
|
46
|
+
' `scopeResolver: new ConnectionScopeResolver()` explicitly.'
|
|
47
|
+
].join('\n');
|
|
48
|
+
}
|
|
@@ -5,6 +5,30 @@
|
|
|
5
5
|
* the instance id and the content hash are derived from it, so a divergence
|
|
6
6
|
* silently breaks subscription dedupe and the hydration handshake instead of
|
|
7
7
|
* failing loudly.
|
|
8
|
+
*
|
|
9
|
+
* It also runs on every recompute, over the whole payload, which makes it the
|
|
10
|
+
* most expensive step of a recompute that changes nothing. Building the string
|
|
11
|
+
* in JavaScript loses to `JSON.stringify` by roughly an order of magnitude, so
|
|
12
|
+
* the common case does not build it: one walk normalizes the value into the
|
|
13
|
+
* shape `JSON.stringify` would already render canonically -- keys sorted,
|
|
14
|
+
* nothing unserializable left in it -- and the native serializer does the rest.
|
|
15
|
+
*
|
|
16
|
+
* What that walk has to do, and `JSON.stringify` cannot:
|
|
17
|
+
*
|
|
18
|
+
* - order object keys, which is the whole point;
|
|
19
|
+
* - refuse values with no agreed wire form, which `JSON.stringify` accepts
|
|
20
|
+
* silently (a Date becomes a string, a Map becomes `{}`, a NaN becomes null).
|
|
21
|
+
*
|
|
22
|
+
* What it deliberately leaves to `JSON.stringify`, which already agrees:
|
|
23
|
+
* string escaping, number formatting including negative zero, dropping
|
|
24
|
+
* undefined properties, and rendering an array hole as null.
|
|
25
|
+
*
|
|
26
|
+
* The escape hatch is `writeCanonical` below. A JavaScript object cannot hold
|
|
27
|
+
* an integer-like key anywhere but the front -- `{ '': 1, '1': 2 }` always
|
|
28
|
+
* enumerates as `1` then `''` -- so an object carrying one cannot be emitted
|
|
29
|
+
* in lexicographic order at all. Those values fall back to building the string
|
|
30
|
+
* here, where the order is ours to choose. Both paths are held to the same
|
|
31
|
+
* output by the differential test.
|
|
8
32
|
*/
|
|
9
33
|
export declare class NonSerializableInputError extends Error {
|
|
10
34
|
readonly path: string;
|