@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.
@@ -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;
@@ -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
- const { data, deps } = await this.resources.compute(resource, inputs, { scope });
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
- for (let i = 0; i < batch.length; i += this.config.fanoutQueueThreshold) {
391
- const slice = batch.slice(i, i + this.config.fanoutQueueThreshold);
392
- await Promise.all(slice.map(instanceId => this.recompute(instanceId)));
393
- if (i + this.config.fanoutQueueThreshold < batch.length) {
394
- // Yield between slices so a large fan-out does not monopolize
395
- // the loop and stall unrelated requests.
396
- await new Promise(resolve => setTimeout(resolve, 0));
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));
@@ -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
@@ -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
- /** Above this fan-out, recompute is queued instead of run inline. */
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,7 @@ exports.DEFAULT_LIVE_CONFIG = {
9
9
  unsubGraceMs: 5000,
10
10
  maxPendingPatches: 32,
11
11
  fanoutQueueThreshold: 500,
12
+ maxConcurrentRecomputes: 4,
12
13
  maxInstancesPerConnection: 64,
13
14
  maxInstancesPerNode: 50000,
14
15
  ssePath: '/live/sse',
@@ -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
- const descendantPrefix = `${event.key}#`;
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
- for (const key of this.byKey.keys()) {
82
- if (key.startsWith(descendantPrefix)) {
83
- this.collect(key, event.columns, matched);
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 (!holders) {
98
- return;
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.HYDRATION_ATTRIBUTE = 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.ConnectionScopeResolver = exports.resolveLiveConfig = exports.DEFAULT_LIVE_CONFIG = exports.LiveMetrics = exports.LiveEngine = exports.LiveService = exports.closeLiveRuntime = exports.LivePlugin = exports.LIVE_META = exports.Live = void 0;
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;