@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.
@@ -1,178 +1,185 @@
1
- import 'reflect-metadata';
2
- import { CONTROLLER_META, PARAMS_META, ROUTES_META, type ParamMetadata } from '@carno.js/core';
3
- import type { Dependency } from '../graph/types';
4
- import { LIVE_META, type LiveMeta } from '../metadata';
5
- import { dependencyContext } from './dependency-context';
6
- import type {
7
- LiveExecutionContext,
8
- LiveInputs,
9
- LiveResource,
10
- LiveResourceExecutor
11
- } from './types';
12
-
13
- /**
14
- * Verbs that may carry @Live. The real criterion is idempotence, not the verb:
15
- * subscribing means re-running the handler whenever the data changes, and
16
- * re-running a write duplicates the side effect. GET and POST are the two the
17
- * web uses for reading; a PUT that only reads is an abuse of the protocol and
18
- * is not worth the API surface.
19
- */
20
- const ALLOWED_METHODS = new Set(['get', 'post']);
21
-
22
- /**
23
- * Parameters that would make the handler depend on a caller request rather
24
- * than its declared, replayable inputs. Middleware still receives a synthetic
25
- * request during every compute and may use it as a guard.
26
- */
27
- const FORBIDDEN_PARAMS: Record<string, string> = {
28
- req: '@Req()',
29
- ctx: '@Ctx()',
30
- header: '@Header()',
31
- locals: '@Locals()'
32
- };
33
-
34
- export class LiveValidationError extends Error {
35
- constructor(message: string) {
36
- super(message);
37
- this.name = 'LiveValidationError';
38
- }
39
- }
40
-
41
- interface RouteInfoLike {
42
- method: string;
43
- path: string;
44
- handlerName: string;
45
- }
46
-
47
- export class ResourceRegistry {
48
- private readonly resources = new Map<string, LiveResource>();
49
-
50
- /**
51
- * Scan a controller for @Live handlers and validate them.
52
- *
53
- * Validation runs at registration, which is bootstrap time: the core
54
- * compiles everything at startup, so a misdeclared resource fails the boot
55
- * instead of failing the first subscription in production.
56
- */
57
- register(
58
- ControllerClass: new (...args: any[]) => any,
59
- instance: any,
60
- executor: LiveResourceExecutor
61
- ): void {
62
- const routes: RouteInfoLike[] = Reflect.getMetadata(ROUTES_META, ControllerClass) || [];
63
- const controllerMeta: { path?: string } = Reflect.getMetadata(CONTROLLER_META, ControllerClass) || {};
64
- const prefix = controllerMeta.path ?? '';
65
-
66
- for (const route of routes) {
67
- const meta: LiveMeta | undefined = Reflect.getMetadata(
68
- LIVE_META,
69
- ControllerClass,
70
- route.handlerName
71
- );
72
-
73
- if (!meta) {
74
- continue;
75
- }
76
-
77
- const id = `${ControllerClass.name}.${route.handlerName}`;
78
- const where = `${ControllerClass.name}.${route.handlerName}()`;
79
-
80
- if (!ALLOWED_METHODS.has(route.method)) {
81
- throw new LiveValidationError(
82
- `${where} is decorated with @Live() on @${route.method.toUpperCase()}(). ` +
83
- `Subscribing means re-running the handler whenever the data changes, so it has ` +
84
- `to be idempotent. Only @Get() and @Post() may be live.`
85
- );
86
- }
87
-
88
- const params: ParamMetadata[] =
89
- Reflect.getMetadata(PARAMS_META, ControllerClass, route.handlerName) || [];
90
-
91
- for (const type of ['req', 'ctx', 'header', 'locals']) {
92
- const param = params.find(candidate => candidate.type === type);
93
- const forbidden = param ? FORBIDDEN_PARAMS[param.type] : undefined;
94
-
95
- if (forbidden) {
96
- throw new LiveValidationError(
97
- `${where} uses ${forbidden}, which is not a replayable live input. ` +
98
- `A live resource handler must be a pure function of its declared inputs.`
99
- );
100
- }
101
-
102
- }
103
-
104
- if (route.method === 'get' && params.some(param => param.type === 'body')) {
105
- throw new LiveValidationError(
106
- `${where} uses @Body() on @Get(). A GET subscription carries no body; ` +
107
- `declare the route as @Post() or read the value from @Query().`
108
- );
109
- }
110
-
111
- if (meta.key !== undefined && (typeof meta.key !== 'string' || meta.key === '')) {
112
- throw new LiveValidationError(`${where} declares an empty @Live({ key }).`);
113
- }
114
-
115
- if (this.resources.has(id)) {
116
- throw new LiveValidationError(`Live resource "${id}" is already registered.`);
117
- }
118
-
119
- let resource: LiveResource;
120
-
121
- resource = {
122
- id,
123
- controllerClass: ControllerClass,
124
- controllerName: ControllerClass.name,
125
- handlerName: route.handlerName,
126
- meta,
127
- params,
128
- invoke: (inputs: LiveInputs, context: LiveExecutionContext = {}) =>
129
- executor(instance, resource, inputs, context),
130
- httpPath: joinRoutePath(prefix, route.path),
131
- httpMethod: route.method.toUpperCase()
132
- };
133
-
134
- this.resources.set(id, resource);
135
- }
136
- }
137
-
138
- get(id: string): LiveResource | undefined {
139
- return this.resources.get(id);
140
- }
141
-
142
- ids(): string[] {
143
- return [...this.resources.keys()];
144
- }
145
-
146
- /** Every live route, as the HTTP layer addresses it. */
147
- livePaths(): { method: string; path: string; resourceId: string }[] {
148
- return [...this.resources.values()].map(resource => ({
149
- method: resource.httpMethod,
150
- path: resource.httpPath,
151
- resourceId: resource.id
152
- }));
153
- }
154
-
155
- /** Run the handler and report what it read. */
156
- async compute(
157
- resource: LiveResource,
158
- inputs: LiveInputs,
159
- context: LiveExecutionContext = {}
160
- ): Promise<{ data: unknown; deps: Dependency[] }> {
161
- const { result, deps } = await dependencyContext.run(collector => {
162
- for (const key of resource.meta.dependsOn) {
163
- collector.add({ key, columns: null });
164
- }
165
-
166
- return resource.invoke(inputs, context);
167
- });
168
-
169
- return { data: result, deps };
170
- }
171
- }
172
-
173
- /** Same join the core router does: collapse the slashes, keep the root. */
174
- export function joinRoutePath(prefix: string, path: string): string {
175
- const joined = `${prefix}${path}`.replace(/\/{2,}/g, '/');
176
-
177
- return joined.length > 1 ? joined.replace(/\/$/, '') : (joined || '/');
178
- }
1
+ import 'reflect-metadata';
2
+ import { CONTROLLER_META, PARAMS_META, ROUTES_META, type ParamMetadata } from '@carno.js/core';
3
+ import type { Dependency } from '../graph/types';
4
+ import { LIVE_META, type LiveMeta, type LiveShared } from '../metadata';
5
+ import { dependencyContext } from './dependency-context';
6
+ import type {
7
+ LiveExecutionContext,
8
+ LiveInputs,
9
+ LiveResource,
10
+ LiveResourceExecutor
11
+ } from './types';
12
+
13
+ /**
14
+ * Verbs that may carry @Live. The real criterion is idempotence, not the verb:
15
+ * subscribing means re-running the handler whenever the data changes, and
16
+ * re-running a write duplicates the side effect. GET and POST are the two the
17
+ * web uses for reading; a PUT that only reads is an abuse of the protocol and
18
+ * is not worth the API surface.
19
+ */
20
+ const ALLOWED_METHODS = new Set(['get', 'post']);
21
+
22
+ /**
23
+ * Parameters that would make the handler depend on a caller request rather
24
+ * than its declared, replayable inputs. Middleware still receives a synthetic
25
+ * request during every compute and may use it as a guard.
26
+ */
27
+ const FORBIDDEN_PARAMS: Record<string, string> = {
28
+ req: '@Req()',
29
+ ctx: '@Ctx()',
30
+ header: '@Header()',
31
+ locals: '@Locals()'
32
+ };
33
+
34
+ export class LiveValidationError extends Error {
35
+ constructor(message: string) {
36
+ super(message);
37
+ this.name = 'LiveValidationError';
38
+ }
39
+ }
40
+
41
+ interface RouteInfoLike {
42
+ method: string;
43
+ path: string;
44
+ handlerName: string;
45
+ }
46
+
47
+ export class ResourceRegistry {
48
+ private readonly resources = new Map<string, LiveResource>();
49
+
50
+ /**
51
+ * Scan a controller for @Live handlers and validate them.
52
+ *
53
+ * Validation runs at registration, which is bootstrap time: the core
54
+ * compiles everything at startup, so a misdeclared resource fails the boot
55
+ * instead of failing the first subscription in production.
56
+ */
57
+ register(
58
+ ControllerClass: new (...args: any[]) => any,
59
+ instance: any,
60
+ executor: LiveResourceExecutor
61
+ ): void {
62
+ const routes: RouteInfoLike[] = Reflect.getMetadata(ROUTES_META, ControllerClass) || [];
63
+ const controllerMeta: { path?: string } = Reflect.getMetadata(CONTROLLER_META, ControllerClass) || {};
64
+ const prefix = controllerMeta.path ?? '';
65
+
66
+ for (const route of routes) {
67
+ const meta: LiveMeta | undefined = Reflect.getMetadata(
68
+ LIVE_META,
69
+ ControllerClass,
70
+ route.handlerName
71
+ );
72
+
73
+ if (!meta) {
74
+ continue;
75
+ }
76
+
77
+ const id = `${ControllerClass.name}.${route.handlerName}`;
78
+ const where = `${ControllerClass.name}.${route.handlerName}()`;
79
+
80
+ if (!ALLOWED_METHODS.has(route.method)) {
81
+ throw new LiveValidationError(
82
+ `${where} is decorated with @Live() on @${route.method.toUpperCase()}(). ` +
83
+ `Subscribing means re-running the handler whenever the data changes, so it has ` +
84
+ `to be idempotent. Only @Get() and @Post() may be live.`
85
+ );
86
+ }
87
+
88
+ const params: ParamMetadata[] =
89
+ Reflect.getMetadata(PARAMS_META, ControllerClass, route.handlerName) || [];
90
+
91
+ for (const type of ['req', 'ctx', 'header', 'locals']) {
92
+ const param = params.find(candidate => candidate.type === type);
93
+ const forbidden = param ? FORBIDDEN_PARAMS[param.type] : undefined;
94
+
95
+ if (forbidden) {
96
+ throw new LiveValidationError(
97
+ `${where} uses ${forbidden}, which is not a replayable live input. ` +
98
+ `A live resource handler must be a pure function of its declared inputs.`
99
+ );
100
+ }
101
+
102
+ }
103
+
104
+ if (route.method === 'get' && params.some(param => param.type === 'body')) {
105
+ throw new LiveValidationError(
106
+ `${where} uses @Body() on @Get(). A GET subscription carries no body; ` +
107
+ `declare the route as @Post() or read the value from @Query().`
108
+ );
109
+ }
110
+
111
+ if (meta.key !== undefined && (typeof meta.key !== 'string' || meta.key === '')) {
112
+ throw new LiveValidationError(`${where} declares an empty @Live({ key }).`);
113
+ }
114
+
115
+ if (this.resources.has(id)) {
116
+ throw new LiveValidationError(`Live resource "${id}" is already registered.`);
117
+ }
118
+
119
+ let resource: LiveResource;
120
+
121
+ resource = {
122
+ id,
123
+ controllerClass: ControllerClass,
124
+ controllerName: ControllerClass.name,
125
+ handlerName: route.handlerName,
126
+ meta,
127
+ params,
128
+ invoke: (inputs: LiveInputs, context: LiveExecutionContext = {}) =>
129
+ executor(instance, resource, inputs, context),
130
+ httpPath: joinRoutePath(prefix, route.path),
131
+ httpMethod: route.method.toUpperCase()
132
+ };
133
+
134
+ this.resources.set(id, resource);
135
+ }
136
+ }
137
+
138
+ get(id: string): LiveResource | undefined {
139
+ return this.resources.get(id);
140
+ }
141
+
142
+ ids(): string[] {
143
+ return [...this.resources.keys()];
144
+ }
145
+
146
+ /** Ids declared with this sharing mode, in registration order. */
147
+ idsShared(shared: LiveShared): string[] {
148
+ return [...this.resources.values()]
149
+ .filter(resource => resource.meta.shared === shared)
150
+ .map(resource => resource.id);
151
+ }
152
+
153
+ /** Every live route, as the HTTP layer addresses it. */
154
+ livePaths(): { method: string; path: string; resourceId: string }[] {
155
+ return [...this.resources.values()].map(resource => ({
156
+ method: resource.httpMethod,
157
+ path: resource.httpPath,
158
+ resourceId: resource.id
159
+ }));
160
+ }
161
+
162
+ /** Run the handler and report what it read. */
163
+ async compute(
164
+ resource: LiveResource,
165
+ inputs: LiveInputs,
166
+ context: LiveExecutionContext = {}
167
+ ): Promise<{ data: unknown; deps: Dependency[] }> {
168
+ const { result, deps } = await dependencyContext.run(collector => {
169
+ for (const key of resource.meta.dependsOn) {
170
+ collector.add({ key, columns: null });
171
+ }
172
+
173
+ return resource.invoke(inputs, context);
174
+ });
175
+
176
+ return { data: result, deps };
177
+ }
178
+ }
179
+
180
+ /** Same join the core router does: collapse the slashes, keep the root. */
181
+ export function joinRoutePath(prefix: string, path: string): string {
182
+ const joined = `${prefix}${path}`.replace(/\/{2,}/g, '/');
183
+
184
+ return joined.length > 1 ? joined.replace(/\/$/, '') : (joined || '/');
185
+ }
@@ -0,0 +1,58 @@
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
+
15
+ /** Resource ids listed inline before the message collapses to a count. */
16
+ const MAX_LISTED = 8;
17
+
18
+ export interface DefaultScopeWarningInput {
19
+ /** Ids of the resources that resolved to `shared: 'private'`. */
20
+ privateResourceIds: string[];
21
+ /** False as soon as the application passes any resolver of its own. */
22
+ usingDefaultResolver: boolean;
23
+ /** `LiveConfig.maxInstancesPerNode`, the ceiling this default runs into. */
24
+ maxInstancesPerNode: number;
25
+ }
26
+
27
+ /**
28
+ * The warning text, or null when there is nothing to warn about.
29
+ *
30
+ * Returned rather than printed so the decision is testable without capturing
31
+ * the console.
32
+ */
33
+ export function defaultScopeWarning(input: DefaultScopeWarningInput): string | null {
34
+ if (!input.usingDefaultResolver || input.privateResourceIds.length === 0) {
35
+ return null;
36
+ }
37
+
38
+ const count = input.privateResourceIds.length;
39
+ const listed = input.privateResourceIds.slice(0, MAX_LISTED).join(', ');
40
+ const rest = count - Math.min(count, MAX_LISTED);
41
+ const names = rest > 0 ? `${listed} and ${rest} more` : listed;
42
+ const noun = count === 1 ? 'live resource is' : 'live resources are';
43
+
44
+ return [
45
+ '[carno:live] No `scopeResolver` was passed to LivePlugin.create(), so the default',
46
+ 'ConnectionScopeResolver keys every instance by connection id.',
47
+ ` ${count} ${noun} private (the @Live() default) and will get one instance per`,
48
+ ' connection rather than one per user:',
49
+ ` ${names}`,
50
+ ' Two tabs of the same user are two instances, and N viewers of the same data are N',
51
+ ` computes, N diffs and N queries — against the ceiling of ${input.maxInstancesPerNode} instances per node`,
52
+ ' (LiveConfig.maxInstancesPerNode), past which subscriptions are refused.',
53
+ ' Fix: pass a `scopeResolver` whose principal is a user id, or declare the resources',
54
+ ' that are genuinely shared as @Live({ shared: \'public\' }) or @Live({ shared: \'tenant\' }).',
55
+ ' To keep per-connection instances and silence this, pass',
56
+ ' `scopeResolver: new ConnectionScopeResolver()` explicitly.'
57
+ ].join('\n');
58
+ }