@classytic/arc 2.17.1 → 2.18.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/{BaseController-CgHomZ4d.mjs → BaseController-DPwe89qs.mjs} +1 -1
- package/dist/auth/index.d.mts +1 -1
- package/dist/cli/commands/describe.d.mts +1 -1
- package/dist/cli/commands/docs.mjs +1 -1
- package/dist/cli/commands/init.mjs +1 -1
- package/dist/cli/index.mjs +1 -1
- package/dist/context/index.d.mts +54 -1
- package/dist/context/index.mjs +65 -2
- package/dist/core/index.d.mts +2 -2
- package/dist/core/index.mjs +3 -3
- package/dist/{core-D8tpQ4ZL.mjs → core-B41eZxyM.mjs} +8 -5
- package/dist/{createActionRouter-BHUMb1-y.mjs → createActionRouter-CRaIwB5S.mjs} +2 -2
- package/dist/{createAggregationRouter-BYVvhfc5.mjs → createAggregationRouter-n3O6WdLO.mjs} +1 -1
- package/dist/{createApp-CzCEEsEm.mjs → createApp-DZvrb587.mjs} +29 -7
- package/dist/docs/index.d.mts +1 -1
- package/dist/docs/index.mjs +1 -1
- package/dist/{eventPlugin-CnJCUyM0.mjs → eventPlugin-Dxp9JlKK.mjs} +1 -1
- package/dist/events/index.d.mts +48 -0
- package/dist/events/index.mjs +27 -5
- package/dist/factory/index.d.mts +1 -1
- package/dist/factory/index.mjs +1 -1
- package/dist/{fastify-augmentation-7LIR0nDv.d.mts → fastify-augmentation-CL48B6gW.d.mts} +1 -1
- package/dist/hooks/index.d.mts +1 -1
- package/dist/{index-C1JSBan-.d.mts → index-C000faom.d.mts} +10 -1
- package/dist/{index-CruZir1o.d.mts → index-CNiryUMN.d.mts} +1 -1
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +4 -4
- package/dist/{init-DYzfniuX.mjs → init-DQ_rY1J7.mjs} +13 -13
- package/dist/integrations/event-gateway.d.mts +1 -1
- package/dist/integrations/index.d.mts +2 -2
- package/dist/integrations/mcp/index.d.mts +43 -3
- package/dist/integrations/mcp/index.mjs +102 -2
- package/dist/integrations/mcp/testing.d.mts +1 -1
- package/dist/integrations/mcp/testing.mjs +1 -1
- package/dist/integrations/streamline.d.mts +8 -1
- package/dist/integrations/streamline.mjs +34 -3
- package/dist/integrations/websocket-pushref-redis.d.mts +71 -0
- package/dist/integrations/websocket-pushref-redis.mjs +205 -0
- package/dist/integrations/websocket-redis.d.mts +1 -1
- package/dist/integrations/websocket.d.mts +3 -2
- package/dist/integrations/websocket.mjs +1142 -23
- package/dist/{mcpPlugin-C6FViZXw.mjs → mcpPlugin-BrepZ0cV.mjs} +3 -3
- package/dist/middleware/index.d.mts +1 -1
- package/dist/{openapi-DKDs9NfD.mjs → openapi-CLZx9bwS.mjs} +1 -1
- package/dist/pipeline/index.d.mts +1 -1
- package/dist/plugins/index.d.mts +17 -3
- package/dist/plugins/index.mjs +44 -3
- package/dist/plugins/tracing-entry.d.mts +1 -1
- package/dist/plugins/tracing-entry.mjs +1 -1
- package/dist/presets/filesUpload.d.mts +2 -2
- package/dist/presets/index.d.mts +1 -1
- package/dist/presets/multiTenant.d.mts +1 -1
- package/dist/presets/search.d.mts +1 -1
- package/dist/pushref-registry-Bq_-EVsC.d.mts +435 -0
- package/dist/registry/index.d.mts +1 -1
- package/dist/{requestContext-SSaaTgW8.mjs → requestContext-DDLS3NVU.mjs} +26 -1
- package/dist/{routerShared-nCyJGOKX.mjs → routerShared-Dl0A77Ba.mjs} +1 -1
- package/dist/{schemaIR-DAOvBWgT.mjs → schemaIR-Bk6Tns82.mjs} +17 -5
- package/dist/testing/index.d.mts +2 -2
- package/dist/testing/index.mjs +1 -1
- package/dist/testing/storageContract.d.mts +1 -1
- package/dist/types/index.d.mts +1 -1
- package/dist/types/storage.d.mts +1 -1
- package/dist/{types-BRmS8TNa.d.mts → types-DEEWwQFR.d.mts} +1 -1
- package/dist/{types-CoMjNmkF.d.mts → types-DH-7WO64.d.mts} +1 -1
- package/dist/utils/index.d.mts +1 -1
- package/dist/websocket-BZMUP7Gr.d.mts +456 -0
- package/package.json +12 -1
- package/dist/websocket-BJ0w-3Dt.d.mts +0 -186
- /package/dist/{storage-HcAgP5Ni.d.mts → storage-BFmamN5m.d.mts} +0 -0
- /package/dist/{tracing-bRqqUxFO.d.mts → tracing-BYQ3hsbM.d.mts} +0 -0
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { p as isArcError, t as ArcError } from "./errors-j4aJm1Wg.mjs";
|
|
2
|
-
import { t as BaseController } from "./BaseController-
|
|
2
|
+
import { t as BaseController } from "./BaseController-DPwe89qs.mjs";
|
|
3
3
|
import { L as normalizePermissionResult } from "./permissions-B1Qy55fg.mjs";
|
|
4
4
|
import { t as executePipeline } from "./pipe-DiCyvyPN.mjs";
|
|
5
|
-
import { f as resolvePipelineSteps } from "./routerShared-
|
|
5
|
+
import { f as resolvePipelineSteps } from "./routerShared-Dl0A77Ba.mjs";
|
|
6
6
|
import { r as validateAggregations } from "./validate-DFIP2VrM.mjs";
|
|
7
7
|
import { n as executeAggregation } from "./buildHandler-C_SkBNF3.mjs";
|
|
8
8
|
import { t as pluralize } from "./pluralize-B9M8xvy-.mjs";
|
|
9
9
|
import { t as resolveActionPermission } from "./actionPermissions-Bjmvn7Eb.mjs";
|
|
10
|
-
import { i as shouldRejectAdditionalProperties, r as schemaIRToZodShape, t as normalizeSchemaIR } from "./schemaIR-
|
|
10
|
+
import { i as shouldRejectAdditionalProperties, r as schemaIRToZodShape, t as normalizeSchemaIR } from "./schemaIR-Bk6Tns82.mjs";
|
|
11
11
|
import { createHash, randomUUID } from "node:crypto";
|
|
12
12
|
import { isHttpError, toErrorContract } from "@classytic/repo-core/errors";
|
|
13
13
|
import fp from "fastify-plugin";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { I as MiddlewareHandler, L as RequestWithExtras, Q as MiddlewareConfig } from "../index-
|
|
1
|
+
import { I as MiddlewareHandler, L as RequestWithExtras, Q as MiddlewareConfig } from "../index-C000faom.mjs";
|
|
2
2
|
import { RouteHandlerMethod } from "fastify";
|
|
3
3
|
|
|
4
4
|
//#region src/middleware/middleware.d.ts
|
|
@@ -2,7 +2,7 @@ import { t as getUserRoles } from "./types-DV9WDfeg.mjs";
|
|
|
2
2
|
import { n as convertRouteSchema } from "./schemaConverter-De34B1ZG.mjs";
|
|
3
3
|
import { t as pluralize } from "./pluralize-B9M8xvy-.mjs";
|
|
4
4
|
import { t as resolveActionPermission } from "./actionPermissions-Bjmvn7Eb.mjs";
|
|
5
|
-
import { t as buildActionBodySchema } from "./createActionRouter-
|
|
5
|
+
import { t as buildActionBodySchema } from "./createActionRouter-CRaIwB5S.mjs";
|
|
6
6
|
import fp from "fastify-plugin";
|
|
7
7
|
//#region src/docs/openapi/canonical-schemas.ts
|
|
8
8
|
/**
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Ft as OperationFilter, It as PipelineConfig, Lt as PipelineContext, Mt as Guard, Nt as Interceptor, Pt as NextFunction, Rt as PipelineStep, yt as IControllerResponse, zt as Transform } from "../index-
|
|
1
|
+
import { Ft as OperationFilter, It as PipelineConfig, Lt as PipelineContext, Mt as Guard, Nt as Interceptor, Pt as NextFunction, Rt as PipelineStep, yt as IControllerResponse, zt as Transform } from "../index-C000faom.mjs";
|
|
2
2
|
|
|
3
3
|
//#region src/pipeline/guard.d.ts
|
|
4
4
|
interface GuardOptions {
|
package/dist/plugins/index.d.mts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { Kt as AnyRecord, Q as MiddlewareConfig, dt as RouteDefinition, mt as RouteSchemaOptions, nt as PresetHook } from "../index-
|
|
2
|
-
import { a as arcCorePlugin, i as _default, n as ArcCorePluginOptions, r as PluginMeta, t as ArcCore } from "../fastify-augmentation-
|
|
1
|
+
import { Kt as AnyRecord, Q as MiddlewareConfig, dt as RouteDefinition, mt as RouteSchemaOptions, nt as PresetHook } from "../index-C000faom.mjs";
|
|
2
|
+
import { a as arcCorePlugin, i as _default, n as ArcCorePluginOptions, r as PluginMeta, t as ArcCore } from "../fastify-augmentation-CL48B6gW.mjs";
|
|
3
3
|
import { _ as HealthOptions, a as MetricsCollector, c as metricsPlugin, d as ssePlugin, f as CachingOptions, g as HealthCheck, h as cachingPlugin, i as MetricEntry, l as SSEOptions, m as _default$1, n as _default$7, o as MetricsOptions, p as CachingRule, r as versioningPlugin, s as _default$4, t as VersioningOptions, u as _default$6, v as _default$3, y as healthPlugin } from "../versioning-5H4930tj.mjs";
|
|
4
4
|
import { i as errorHandlerPlugin, n as ErrorMapper, r as defaultIsDuplicateKeyError, t as ErrorHandlerOptions } from "../errorHandler-DuqTqeqn.mjs";
|
|
5
|
-
import { t as TracingOptions } from "../tracing-
|
|
5
|
+
import { t as TracingOptions } from "../tracing-BYQ3hsbM.mjs";
|
|
6
6
|
import * as _$node_stream0 from "node:stream";
|
|
7
7
|
import { PaginatedResult } from "@classytic/repo-core/pagination";
|
|
8
8
|
import { FastifyInstance, FastifyPluginAsync } from "fastify";
|
|
@@ -121,11 +121,25 @@ interface RequestIdOptions {
|
|
|
121
121
|
generator?: () => string;
|
|
122
122
|
/** Whether to set response header (default: true) */
|
|
123
123
|
setResponseHeader?: boolean;
|
|
124
|
+
/**
|
|
125
|
+
* Whether to read and forward W3C Trace Context headers (`traceparent`,
|
|
126
|
+
* `tracestate`). When enabled, valid incoming headers are stored in
|
|
127
|
+
* `request.traceContext` and echoed in the response. Default: `true`.
|
|
128
|
+
*/
|
|
129
|
+
propagateTraceContext?: boolean;
|
|
124
130
|
}
|
|
125
131
|
declare module "fastify" {
|
|
126
132
|
interface FastifyRequest {
|
|
127
133
|
/** Unique request identifier for tracing */
|
|
128
134
|
requestId: string;
|
|
135
|
+
/**
|
|
136
|
+
* W3C Trace Context parsed from the incoming `traceparent` header.
|
|
137
|
+
* `undefined` when the header was absent or malformed.
|
|
138
|
+
*/
|
|
139
|
+
traceContext?: {
|
|
140
|
+
traceparent: string;
|
|
141
|
+
tracestate?: string;
|
|
142
|
+
};
|
|
129
143
|
}
|
|
130
144
|
}
|
|
131
145
|
declare const requestIdPlugin: FastifyPluginAsync<RequestIdOptions>;
|
package/dist/plugins/index.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { p as MUTATION_OPERATIONS } from "../constants-TrJVIJl0.mjs";
|
|
2
2
|
import { c as getOrgId } from "../types-Bi0r0vjG.mjs";
|
|
3
|
-
import {
|
|
3
|
+
import { n as requestContext } from "../requestContext-DDLS3NVU.mjs";
|
|
4
4
|
import { t as hasEvents } from "../typeGuards-BzkXkvVv.mjs";
|
|
5
5
|
import { t as HookSystem } from "../HookSystem-Cmf7-Etp.mjs";
|
|
6
6
|
import { t as ResourceRegistry } from "../ResourceRegistry-CzSzVK1m.mjs";
|
|
@@ -34,8 +34,14 @@ const arcCorePlugin = async (fastify, opts = {}) => {
|
|
|
34
34
|
fastify.addHook("preHandler", (request, _reply, done) => {
|
|
35
35
|
const store = requestContext.get();
|
|
36
36
|
if (store) {
|
|
37
|
-
|
|
37
|
+
const req = request;
|
|
38
|
+
store.user = req.user ?? null;
|
|
38
39
|
store.organizationId = request.scope?.kind === "member" ? request.scope.organizationId : request.scope?.kind === "elevated" ? request.scope.organizationId : void 0;
|
|
40
|
+
const tc = req.traceContext;
|
|
41
|
+
if (tc?.traceparent) {
|
|
42
|
+
store.traceparent = tc.traceparent;
|
|
43
|
+
if (tc.tracestate) store.tracestate = tc.tracestate;
|
|
44
|
+
}
|
|
39
45
|
}
|
|
40
46
|
done();
|
|
41
47
|
});
|
|
@@ -410,9 +416,28 @@ var health_default = fp(healthPlugin, {
|
|
|
410
416
|
* console.log(request.id); // UUID
|
|
411
417
|
* });
|
|
412
418
|
*/
|
|
419
|
+
/** W3C traceparent format: `version-traceid-parentid-flags` */
|
|
420
|
+
const TRACEPARENT_RE = /^([0-9a-f]{2})-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/i;
|
|
421
|
+
/** W3C spec: version ff is reserved and invalid; trace/parent IDs must not be all zeros */
|
|
422
|
+
const TRACEID_ZEROS = "0".repeat(32);
|
|
423
|
+
const PARENTID_ZEROS = "0".repeat(16);
|
|
424
|
+
/** tracestate: printable ASCII, <= 512 bytes */
|
|
425
|
+
const TRACESTATE_MAX = 512;
|
|
426
|
+
function isValidTraceparent(tp) {
|
|
427
|
+
const m = TRACEPARENT_RE.exec(tp);
|
|
428
|
+
if (!m) return false;
|
|
429
|
+
const version = m[1] ?? "";
|
|
430
|
+
const traceId = m[2] ?? "";
|
|
431
|
+
const parentId = m[3] ?? "";
|
|
432
|
+
if (version.toLowerCase() === "ff") return false;
|
|
433
|
+
if (traceId.toLowerCase() === TRACEID_ZEROS) return false;
|
|
434
|
+
if (parentId.toLowerCase() === PARENTID_ZEROS) return false;
|
|
435
|
+
return true;
|
|
436
|
+
}
|
|
413
437
|
const requestIdPlugin = async (fastify, opts = {}) => {
|
|
414
|
-
const { header = "x-request-id", generator = randomUUID, setResponseHeader = true } = opts;
|
|
438
|
+
const { header = "x-request-id", generator = randomUUID, setResponseHeader = true, propagateTraceContext = true } = opts;
|
|
415
439
|
if (!fastify.hasRequestDecorator("requestId")) fastify.decorateRequest("requestId", "");
|
|
440
|
+
if (!fastify.hasRequestDecorator("traceContext")) fastify.decorateRequest("traceContext", void 0);
|
|
416
441
|
fastify.addHook("onRequest", async (request, reply) => {
|
|
417
442
|
const incomingId = request.headers[header];
|
|
418
443
|
const sanitized = typeof incomingId === "string" ? incomingId.trim() : "";
|
|
@@ -420,6 +445,22 @@ const requestIdPlugin = async (fastify, opts = {}) => {
|
|
|
420
445
|
request.id = requestId;
|
|
421
446
|
request.requestId = requestId;
|
|
422
447
|
if (setResponseHeader) reply.header(header, requestId);
|
|
448
|
+
if (propagateTraceContext) {
|
|
449
|
+
const rawTraceparent = request.headers["traceparent"];
|
|
450
|
+
const rawTracestate = request.headers["tracestate"];
|
|
451
|
+
if (typeof rawTraceparent === "string") {
|
|
452
|
+
const tp = rawTraceparent.trim();
|
|
453
|
+
if (isValidTraceparent(tp)) {
|
|
454
|
+
const ts = typeof rawTracestate === "string" && rawTracestate.length > 0 && rawTracestate.length <= TRACESTATE_MAX ? rawTracestate : void 0;
|
|
455
|
+
request.traceContext = {
|
|
456
|
+
traceparent: tp,
|
|
457
|
+
...ts ? { tracestate: ts } : {}
|
|
458
|
+
};
|
|
459
|
+
reply.header("traceparent", tp);
|
|
460
|
+
if (ts) reply.header("tracestate", ts);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
}
|
|
423
464
|
});
|
|
424
465
|
fastify.log?.debug?.("Request ID plugin registered");
|
|
425
466
|
};
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { a as traced, i as isTracingAvailable, n as _default, r as createSpan, t as TracingOptions } from "../tracing-
|
|
1
|
+
import { a as traced, i as isTracingAvailable, n as _default, r as createSpan, t as TracingOptions } from "../tracing-BYQ3hsbM.mjs";
|
|
2
2
|
export { type TracingOptions, createSpan, isTracingAvailable, traced, _default as tracingPlugin };
|
|
@@ -58,7 +58,7 @@ try {
|
|
|
58
58
|
function createTracerProvider(options) {
|
|
59
59
|
if (!isAvailable || !NodeTracerProvider || !BatchSpanProcessor || !OTLPTraceExporter) return null;
|
|
60
60
|
const { serviceName = "@classytic/arc", serviceVersion, exporterUrl = "http://localhost:4318/v1/traces" } = options;
|
|
61
|
-
const resolvedVersion = serviceVersion ?? "2.
|
|
61
|
+
const resolvedVersion = serviceVersion ?? "2.18.1";
|
|
62
62
|
const exporter = new OTLPTraceExporter({ url: exporterUrl });
|
|
63
63
|
const provider = new NodeTracerProvider({ resource: { attributes: {
|
|
64
64
|
"service.name": serviceName,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { rt as PresetResult } from "../index-
|
|
1
|
+
import { rt as PresetResult } from "../index-C000faom.mjs";
|
|
2
2
|
import { i as RequestScope } from "../types-CfapW2t8.mjs";
|
|
3
3
|
import { c as PermissionCheck } from "../fields-DqZLp5d6.mjs";
|
|
4
|
-
import { a as StorageReadResult, i as StorageReadRange, n as StorageContext, o as StorageUploadInput, r as StorageFile, t as Storage } from "../storage-
|
|
4
|
+
import { a as StorageReadResult, i as StorageReadRange, n as StorageContext, o as StorageUploadInput, r as StorageFile, t as Storage } from "../storage-BFmamN5m.mjs";
|
|
5
5
|
|
|
6
6
|
//#region src/presets/filesUpload.d.ts
|
|
7
7
|
interface FilesUploadPresetRoutes {
|
package/dist/presets/index.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Kt as AnyRecord, bt as IRequestContext, d as PaginationResult, rt as PresetResult, st as ResourceConfig, yt as IControllerResponse } from "../index-
|
|
1
|
+
import { Kt as AnyRecord, bt as IRequestContext, d as PaginationResult, rt as PresetResult, st as ResourceConfig, yt as IControllerResponse } from "../index-C000faom.mjs";
|
|
2
2
|
import { FilesUploadPresetOptions, FilesUploadPresetPermissions, FilesUploadPresetRoutes, filesUploadPreset } from "./filesUpload.mjs";
|
|
3
3
|
import { MultiTenantOptions, TenantFieldSpec, multiTenantPreset } from "./multiTenant.mjs";
|
|
4
4
|
import { SearchHandler, SearchPresetOptions, SearchRouteConfig, searchPreset } from "./search.mjs";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { dt as RouteDefinition, ft as RouteMcpConfig, ht as ControllerHandler, rt as PresetResult } from "../index-
|
|
1
|
+
import { dt as RouteDefinition, ft as RouteMcpConfig, ht as ControllerHandler, rt as PresetResult } from "../index-C000faom.mjs";
|
|
2
2
|
import { c as PermissionCheck } from "../fields-DqZLp5d6.mjs";
|
|
3
3
|
|
|
4
4
|
//#region src/presets/search.d.ts
|
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
//#region src/integrations/websocket/dead-queue.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Per-connection dead queue — ring buffer holding the last N outbound
|
|
4
|
+
* critical messages so a reconnecting client can RESUME and have any
|
|
5
|
+
* messages missed during the disconnect replayed in order.
|
|
6
|
+
*
|
|
7
|
+
* Modelled on Mattermost's `deadQueueSize = 128` ring (web_conn.go). Only
|
|
8
|
+
* `send()`-class (critical, ordered) frames go in; `sendRealtime()` frames
|
|
9
|
+
* are by definition droppable so replaying them on resume gives the user
|
|
10
|
+
* a stale snapshot — better to wait for the next live update.
|
|
11
|
+
*
|
|
12
|
+
* Each entry carries the monotonic sequence number assigned by the
|
|
13
|
+
* envelope wrapper. Resume semantics:
|
|
14
|
+
*
|
|
15
|
+
* client → { type: 'resume', lastSeq: N, pushRef: '...' }
|
|
16
|
+
* server → if (N+1 is still in the ring): replay (N+1..tail)
|
|
17
|
+
* else: { type: 'resume_gap' }
|
|
18
|
+
*
|
|
19
|
+
* The gap response forces the host to do a fresh sync — losing 128+
|
|
20
|
+
* messages mid-disconnect is rare enough that a re-fetch is acceptable
|
|
21
|
+
* UX; trying to "patch over" a lost message silently is the bad path.
|
|
22
|
+
*/
|
|
23
|
+
interface DeadQueueEntry {
|
|
24
|
+
seq: number;
|
|
25
|
+
/** Already-serialized envelope JSON ready to write to the socket. */
|
|
26
|
+
payload: string;
|
|
27
|
+
}
|
|
28
|
+
declare class DeadQueue {
|
|
29
|
+
private buffer;
|
|
30
|
+
private head;
|
|
31
|
+
private count;
|
|
32
|
+
private capacity;
|
|
33
|
+
constructor(capacity?: number);
|
|
34
|
+
push(entry: DeadQueueEntry): void;
|
|
35
|
+
/**
|
|
36
|
+
* Return entries with seq strictly greater than `lastSeq`, in order,
|
|
37
|
+
* OR `null` if the gap is too large (entry with seq = lastSeq + 1 has
|
|
38
|
+
* already been evicted from the ring — caller should signal `resume_gap`).
|
|
39
|
+
*/
|
|
40
|
+
drainAfter(lastSeq: number): DeadQueueEntry[] | null;
|
|
41
|
+
size(): number;
|
|
42
|
+
/** Highest seq currently in the ring (or 0 if empty). */
|
|
43
|
+
highestSeq(): number;
|
|
44
|
+
clear(): void;
|
|
45
|
+
/**
|
|
46
|
+
* Return all entries in chronological order. Used by the registry's
|
|
47
|
+
* `persist()` to flush the live ring to the external store. Allocates
|
|
48
|
+
* a fresh array — caller owns it (mutations don't affect the ring).
|
|
49
|
+
*/
|
|
50
|
+
snapshot(): DeadQueueEntry[];
|
|
51
|
+
}
|
|
52
|
+
//#endregion
|
|
53
|
+
//#region src/integrations/websocket/envelope.d.ts
|
|
54
|
+
/**
|
|
55
|
+
* Per-pushRef envelope state. One instance per logical session — when
|
|
56
|
+
* a client reconnects under the same pushRef within TTL, the same
|
|
57
|
+
* EnvelopeWriter is rehydrated so its `nextSeq` counter and dead queue
|
|
58
|
+
* round-trip across reconnects.
|
|
59
|
+
*
|
|
60
|
+
* The constructor accepts `{ nextSeq }` for the rehydration path —
|
|
61
|
+
* `PushRefRegistry.hydrateEnvelope` passes the persisted counter so
|
|
62
|
+
* the next wrap() picks up where the previous socket left off (no
|
|
63
|
+
* seq collisions, no replay gaps).
|
|
64
|
+
*/
|
|
65
|
+
declare class EnvelopeWriter {
|
|
66
|
+
private nextSeq;
|
|
67
|
+
private dead;
|
|
68
|
+
constructor(deadQueue: DeadQueue, opts?: {
|
|
69
|
+
nextSeq?: number;
|
|
70
|
+
});
|
|
71
|
+
/**
|
|
72
|
+
* Wrap a payload, assign next seq, persist to dead queue. Returns the
|
|
73
|
+
* JSON-stringified envelope ready for the socket.
|
|
74
|
+
*/
|
|
75
|
+
wrap(payload: unknown): string;
|
|
76
|
+
/**
|
|
77
|
+
* Replay entries strictly greater than `lastSeq`. Returns either the
|
|
78
|
+
* array of payloads to write, OR `null` if the gap is unrecoverable
|
|
79
|
+
* (caller emits `resume_gap`).
|
|
80
|
+
*/
|
|
81
|
+
drainAfter(lastSeq: number): string[] | null;
|
|
82
|
+
highestSeq(): number;
|
|
83
|
+
/** Peek the next sequence to be assigned (without consuming). */
|
|
84
|
+
peekNextSeq(): number;
|
|
85
|
+
/** Snapshot the dead queue contents for persistence. */
|
|
86
|
+
snapshotDeadQueue(): DeadQueueEntry[];
|
|
87
|
+
}
|
|
88
|
+
//#endregion
|
|
89
|
+
//#region src/integrations/websocket/pushref-registry.d.ts
|
|
90
|
+
/** Stable principal string. Format: `u:<userId>`, `c:<clientId>`, `anon`, or `conn:<pushRef>`. */
|
|
91
|
+
type Principal = string;
|
|
92
|
+
/**
|
|
93
|
+
* Outcome of `claim()`. Note the `generation` and `supersededActive`
|
|
94
|
+
* fields — they exist so the connection layer can detect AND fence a
|
|
95
|
+
* stale active connection elsewhere in the cluster:
|
|
96
|
+
*
|
|
97
|
+
* - `generation` — monotonic per-pushRef counter. Bumped on
|
|
98
|
+
* every successful claim. Lets the connection
|
|
99
|
+
* tag its outbound writes with its own
|
|
100
|
+
* generation, so the OLD live connection (if
|
|
101
|
+
* any) can recognize it's been superseded.
|
|
102
|
+
*
|
|
103
|
+
* - `supersededActive` — true when the claim happened while the
|
|
104
|
+
* previous entry was still `active`. Triggers
|
|
105
|
+
* the fence path: caller closes any local
|
|
106
|
+
* socket bound to this pushRef AND publishes
|
|
107
|
+
* a cluster-wide fence so other nodes do too.
|
|
108
|
+
*
|
|
109
|
+
* Without this, two sockets in different processes can hold live
|
|
110
|
+
* EnvelopeWriters for the same pushRef and race nextSeq + persist
|
|
111
|
+
* (the 2.17.2 review's HIGH-2 finding). The fence mechanism makes
|
|
112
|
+
* "newest claim wins" a hard invariant — the old socket is closed at
|
|
113
|
+
* 4011 before any further writes can land.
|
|
114
|
+
*/
|
|
115
|
+
type ClaimOutcome = {
|
|
116
|
+
outcome: "new";
|
|
117
|
+
envelope?: EnvelopeWriter;
|
|
118
|
+
generation: number;
|
|
119
|
+
} | {
|
|
120
|
+
outcome: "resumed";
|
|
121
|
+
envelope?: EnvelopeWriter;
|
|
122
|
+
generation: number;
|
|
123
|
+
supersededActive: boolean;
|
|
124
|
+
} | {
|
|
125
|
+
outcome: "rejected";
|
|
126
|
+
reason: "principal_mismatch" | "capacity";
|
|
127
|
+
};
|
|
128
|
+
/**
|
|
129
|
+
* Snapshot of a single registry entry — what gets persisted in an
|
|
130
|
+
* external store. `nextSeq` and `deadQueue` are tracked separately so
|
|
131
|
+
* a reconnecting client picks up exactly where it left off (no seq
|
|
132
|
+
* collisions, no replay gaps).
|
|
133
|
+
*/
|
|
134
|
+
interface SerializedEntry {
|
|
135
|
+
principal: Principal;
|
|
136
|
+
nextSeq: number;
|
|
137
|
+
deadQueue: DeadQueueEntry[];
|
|
138
|
+
expiresAt: number;
|
|
139
|
+
active: boolean;
|
|
140
|
+
envelopeMode: "raw" | "seq";
|
|
141
|
+
deadQueueSize: number;
|
|
142
|
+
/**
|
|
143
|
+
* Monotonic per-pushRef generation counter. Incremented on every
|
|
144
|
+
* successful claim (new or resumed). The connection layer stamps
|
|
145
|
+
* its writes with the generation in force at handshake time so a
|
|
146
|
+
* stale live connection on another node can be fenced — its
|
|
147
|
+
* generation is now strictly less than the latest.
|
|
148
|
+
*/
|
|
149
|
+
generation: number;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Persistence contract for cross-instance pushRef state.
|
|
153
|
+
*
|
|
154
|
+
* Implementations MUST guarantee atomic claim semantics — concurrent
|
|
155
|
+
* claims for the same pushRef from different nodes resolve to exactly
|
|
156
|
+
* one `new` / `resumed` outcome; the loser sees `rejected`.
|
|
157
|
+
*
|
|
158
|
+
* `stage` and `bumpActive` are mutation primitives the registry uses
|
|
159
|
+
* for the offline-staging and reconnect paths; implementations may
|
|
160
|
+
* optimize them (e.g. Redis HSET partial updates) but the default
|
|
161
|
+
* implementation just reads the entry, mutates, writes back.
|
|
162
|
+
*/
|
|
163
|
+
/** Outcome of a fenced write — `stale` means the caller's generation lost. */
|
|
164
|
+
type FencedWriteOutcome = "ok" | "missing" | "stale";
|
|
165
|
+
/**
|
|
166
|
+
* Partial snapshot used by `releaseIfGen` / `persistIfGen` to flush the
|
|
167
|
+
* live envelope's nextSeq + dead queue back to the store atomically.
|
|
168
|
+
* Optional — caller may skip when no live envelope state exists (raw mode).
|
|
169
|
+
*/
|
|
170
|
+
interface EnvelopeSnapshot {
|
|
171
|
+
nextSeq: number;
|
|
172
|
+
deadQueue: DeadQueueEntry[];
|
|
173
|
+
}
|
|
174
|
+
interface PushRefStore {
|
|
175
|
+
get(pushRef: string): Promise<SerializedEntry | undefined>;
|
|
176
|
+
set(pushRef: string, entry: SerializedEntry): Promise<void>;
|
|
177
|
+
delete(pushRef: string): Promise<void>;
|
|
178
|
+
/**
|
|
179
|
+
* Generation-fenced release. The 2.17.2 review surfaced this race:
|
|
180
|
+
* after Node A is fenced by Node B's claim (generation = N+1), Node
|
|
181
|
+
* A's close handler still fires `release(pushRef)` synchronously.
|
|
182
|
+
* Without a generation check the release silently mutates Node B's
|
|
183
|
+
* entry — marks it inactive, overwrites dead queue with Node A's
|
|
184
|
+
* stale snapshot.
|
|
185
|
+
*
|
|
186
|
+
* The atomic contract:
|
|
187
|
+
* - If `stored.generation === expectedGeneration`: mark inactive,
|
|
188
|
+
* write snapshot (if provided), extend expiresAt — return `ok`.
|
|
189
|
+
* - If different: no-op, return `stale`.
|
|
190
|
+
* - If entry is absent: return `missing`.
|
|
191
|
+
*
|
|
192
|
+
* Implementations route this to native CAS — Redis uses a Lua script
|
|
193
|
+
* so the test + write is single-roundtrip; Memory uses sync compare
|
|
194
|
+
* inside the event-loop turn.
|
|
195
|
+
*/
|
|
196
|
+
releaseIfGen(args: {
|
|
197
|
+
pushRef: string;
|
|
198
|
+
expectedGeneration: number;
|
|
199
|
+
snapshot?: EnvelopeSnapshot;
|
|
200
|
+
expiresAt: number;
|
|
201
|
+
}): Promise<FencedWriteOutcome>;
|
|
202
|
+
/**
|
|
203
|
+
* Generation-fenced persist. Used by the room manager after each
|
|
204
|
+
* critical send to round-trip the live envelope state to the store
|
|
205
|
+
* so a reconnect to ANY node picks up the latest dead queue. Same
|
|
206
|
+
* CAS contract as `releaseIfGen` — stale writes from a fenced
|
|
207
|
+
* connection are dropped.
|
|
208
|
+
*/
|
|
209
|
+
persistIfGen(args: {
|
|
210
|
+
pushRef: string;
|
|
211
|
+
expectedGeneration: number;
|
|
212
|
+
snapshot: EnvelopeSnapshot;
|
|
213
|
+
expiresAt: number;
|
|
214
|
+
}): Promise<FencedWriteOutcome>;
|
|
215
|
+
/**
|
|
216
|
+
* Atomic offline-stage for a single payload. Used by the plugin's
|
|
217
|
+
* `app.ws.send({pushRef})` path when no live socket is attached —
|
|
218
|
+
* the payload is wrapped (seq + timestamp) into the dead queue so a
|
|
219
|
+
* reconnect's RESUME replays it.
|
|
220
|
+
*
|
|
221
|
+
* Why this can't be `get + mutate + set`:
|
|
222
|
+
*
|
|
223
|
+
* The 2.17.2 review (HIGH) — between the read and the write, another
|
|
224
|
+
* node can claim the pushRef and become the new active owner. A
|
|
225
|
+
* non-atomic stage would then overwrite the freshly-claimed active
|
|
226
|
+
* entry with the stale "inactive + old snapshot" view, losing the
|
|
227
|
+
* new owner's generation bump and any frames it had already written.
|
|
228
|
+
*
|
|
229
|
+
* The atomic contract — performed under a single Lua EVAL on Redis,
|
|
230
|
+
* synchronously within the event-loop turn on Memory:
|
|
231
|
+
*
|
|
232
|
+
* 1. Read the entry. If missing → return 'missing'.
|
|
233
|
+
* 2. If `active === true` → return 'active' (caller routes to live
|
|
234
|
+
* delivery instead; staging is for OFFLINE sessions only).
|
|
235
|
+
* 3. If `envelopeMode !== 'seq'` → return 'raw' (no envelope means
|
|
236
|
+
* nothing to stage into).
|
|
237
|
+
* 4. Compute seq = entry.nextSeq, bump nextSeq.
|
|
238
|
+
* 5. Wrap the payload: `{seq, t, msg: payload}` and JSON-encode.
|
|
239
|
+
* 6. Append `{seq, payload: encoded}` to deadQueue. Trim to ring size.
|
|
240
|
+
* 7. Extend expiresAt. Write back.
|
|
241
|
+
*
|
|
242
|
+
* The wrap step happens INSIDE the atomic primitive because seq has
|
|
243
|
+
* to come from the just-read entry — the registry layer cannot
|
|
244
|
+
* pre-compute it without re-introducing the very race we're closing.
|
|
245
|
+
*/
|
|
246
|
+
stageIfInactive(args: {
|
|
247
|
+
pushRef: string; /** Raw payload (NOT pre-wrapped). The store assigns seq + wraps atomically. */
|
|
248
|
+
payload: unknown; /** Server timestamp (ms) — passed in so tests can pin the clock. */
|
|
249
|
+
timestamp: number;
|
|
250
|
+
expiresAt: number;
|
|
251
|
+
}): Promise<"ok" | "missing" | "active" | "raw">;
|
|
252
|
+
/**
|
|
253
|
+
* Atomic claim. Implementations route to native primitives:
|
|
254
|
+
* - Memory: single-threaded check-and-set
|
|
255
|
+
* - Redis: Lua script (EVAL) testing principal then HMSET
|
|
256
|
+
* - SQL: upsert with check constraint
|
|
257
|
+
*
|
|
258
|
+
* `mintFresh()` constructs a SerializedEntry for the new-claim path —
|
|
259
|
+
* called only when no existing entry is found and the store has decided
|
|
260
|
+
* to create one. (Lets the registry layer own the envelope-mint policy.)
|
|
261
|
+
*/
|
|
262
|
+
claim(args: {
|
|
263
|
+
pushRef: string;
|
|
264
|
+
principal: Principal;
|
|
265
|
+
now: number;
|
|
266
|
+
ttlMs: number;
|
|
267
|
+
mintFresh: () => SerializedEntry;
|
|
268
|
+
maxEntries: number;
|
|
269
|
+
countNonExpired: () => Promise<number>;
|
|
270
|
+
}): Promise<{
|
|
271
|
+
outcome: "new" | "resumed" | "rejected";
|
|
272
|
+
entry?: SerializedEntry;
|
|
273
|
+
/**
|
|
274
|
+
* `true` when the previous entry was still marked active at the
|
|
275
|
+
* moment of claim — caller MUST fence the previous owner so two
|
|
276
|
+
* live sockets can't race the same envelope state.
|
|
277
|
+
*/
|
|
278
|
+
supersededActive?: boolean;
|
|
279
|
+
}>;
|
|
280
|
+
/** Periodic GC — remove inactive entries past TTL. Returns count removed. */
|
|
281
|
+
gc(now: number): Promise<number>;
|
|
282
|
+
/** Total entries (active + inactive). */
|
|
283
|
+
size(): Promise<number>;
|
|
284
|
+
/** Cleanup any background resources (subscriptions, timers). */
|
|
285
|
+
close?(): Promise<void>;
|
|
286
|
+
}
|
|
287
|
+
interface PushRefRegistryOptions {
|
|
288
|
+
ttlMs?: number;
|
|
289
|
+
maxEntries?: number;
|
|
290
|
+
/** Inject the store implementation. Defaults to `MemoryPushRefStore`. */
|
|
291
|
+
store?: PushRefStore;
|
|
292
|
+
/** Optional clock injection — tests can pin time. */
|
|
293
|
+
now?: () => number;
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Default in-memory store — the previous single-instance registry's
|
|
297
|
+
* behavior, repackaged behind the `PushRefStore` interface so the
|
|
298
|
+
* registry layer doesn't care whether it's local or distributed.
|
|
299
|
+
*
|
|
300
|
+
* All methods are technically synchronous but return promises to
|
|
301
|
+
* conform to the interface. Cost: one microtask per call. Negligible.
|
|
302
|
+
*/
|
|
303
|
+
declare class MemoryPushRefStore implements PushRefStore {
|
|
304
|
+
private entries;
|
|
305
|
+
get(pushRef: string): Promise<SerializedEntry | undefined>;
|
|
306
|
+
set(pushRef: string, entry: SerializedEntry): Promise<void>;
|
|
307
|
+
delete(pushRef: string): Promise<void>;
|
|
308
|
+
claim(args: {
|
|
309
|
+
pushRef: string;
|
|
310
|
+
principal: Principal;
|
|
311
|
+
now: number;
|
|
312
|
+
ttlMs: number;
|
|
313
|
+
mintFresh: () => SerializedEntry;
|
|
314
|
+
maxEntries: number;
|
|
315
|
+
countNonExpired: () => Promise<number>;
|
|
316
|
+
}): Promise<{
|
|
317
|
+
outcome: "new" | "resumed" | "rejected";
|
|
318
|
+
entry?: SerializedEntry;
|
|
319
|
+
supersededActive?: boolean;
|
|
320
|
+
}>;
|
|
321
|
+
releaseIfGen(args: {
|
|
322
|
+
pushRef: string;
|
|
323
|
+
expectedGeneration: number;
|
|
324
|
+
snapshot?: EnvelopeSnapshot;
|
|
325
|
+
expiresAt: number;
|
|
326
|
+
}): Promise<FencedWriteOutcome>;
|
|
327
|
+
persistIfGen(args: {
|
|
328
|
+
pushRef: string;
|
|
329
|
+
expectedGeneration: number;
|
|
330
|
+
snapshot: EnvelopeSnapshot;
|
|
331
|
+
expiresAt: number;
|
|
332
|
+
}): Promise<FencedWriteOutcome>;
|
|
333
|
+
stageIfInactive(args: {
|
|
334
|
+
pushRef: string;
|
|
335
|
+
payload: unknown;
|
|
336
|
+
timestamp: number;
|
|
337
|
+
expiresAt: number;
|
|
338
|
+
}): Promise<"ok" | "missing" | "active" | "raw">;
|
|
339
|
+
gc(now: number): Promise<number>;
|
|
340
|
+
size(): Promise<number>;
|
|
341
|
+
/** Test-only inspection — not in the PushRefStore interface. */
|
|
342
|
+
inspect(pushRef: string): Readonly<SerializedEntry> | undefined;
|
|
343
|
+
private evictOldestInactive;
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Registry — the high-level interface connection.ts calls. Owns the
|
|
347
|
+
* envelope-mint policy (envelope writers are LIVE objects with mutable
|
|
348
|
+
* dead queues — they can't be reconstructed from a serialized snapshot
|
|
349
|
+
* exactly because the `nextSeq` counter and ring buffer must round-trip
|
|
350
|
+
* with full fidelity).
|
|
351
|
+
*
|
|
352
|
+
* The hot path:
|
|
353
|
+
*
|
|
354
|
+
* 1. `claim(pushRef, principal, deadQueueSize)` resolves the
|
|
355
|
+
* cross-connection state. On `new`, an EnvelopeWriter is minted
|
|
356
|
+
* around a fresh DeadQueue. On `resumed`, the EnvelopeWriter is
|
|
357
|
+
* reconstructed from the persisted nextSeq + deadQueue contents.
|
|
358
|
+
*
|
|
359
|
+
* 2. The connection lifecycle calls `release()` on close — starts the
|
|
360
|
+
* TTL countdown.
|
|
361
|
+
*
|
|
362
|
+
* 3. `stageForResume()` writes a payload into the orphan envelope
|
|
363
|
+
* when no live socket is attached (the offline-send path).
|
|
364
|
+
*
|
|
365
|
+
* 4. `persist()` flushes the in-memory EnvelopeWriter back to the
|
|
366
|
+
* store after each write — keeps the store in sync so a reconnect
|
|
367
|
+
* to a different node sees the latest dead queue. For the memory
|
|
368
|
+
* store this is a no-op; for Redis it's a HMSET write.
|
|
369
|
+
*/
|
|
370
|
+
declare class PushRefRegistry {
|
|
371
|
+
private store;
|
|
372
|
+
private ttlMs;
|
|
373
|
+
private maxEntries;
|
|
374
|
+
private now;
|
|
375
|
+
/**
|
|
376
|
+
* Live envelope writers indexed by pushRef. Stays in sync with the
|
|
377
|
+
* store via persist() after every wrap(). On `claim('resumed')`,
|
|
378
|
+
* rehydrated from the store's snapshot.
|
|
379
|
+
*/
|
|
380
|
+
private liveEnvelopes;
|
|
381
|
+
constructor(opts?: PushRefRegistryOptions);
|
|
382
|
+
claim(pushRef: string, principal: Principal, opts: {
|
|
383
|
+
envelopeMode: "raw" | "seq";
|
|
384
|
+
deadQueueSize: number;
|
|
385
|
+
}): Promise<ClaimOutcome>;
|
|
386
|
+
/**
|
|
387
|
+
* Mark the connection inactive (TTL countdown starts). Caller MUST
|
|
388
|
+
* pass the generation they captured at claim time — the store does
|
|
389
|
+
* a compare-and-set and silently no-ops if the entry has since been
|
|
390
|
+
* superseded by a higher-generation claim (i.e. another node took
|
|
391
|
+
* ownership while our close handler was pending).
|
|
392
|
+
*
|
|
393
|
+
* This closes the race the 2.17.2 review surfaced: previously, a
|
|
394
|
+
* stale release could overwrite Node B's freshly-claimed entry with
|
|
395
|
+
* Node A's outdated snapshot.
|
|
396
|
+
*
|
|
397
|
+
* Returns the outcome for callers that want to log fenced writes.
|
|
398
|
+
*/
|
|
399
|
+
release(pushRef: string, expectedGeneration: number): Promise<FencedWriteOutcome>;
|
|
400
|
+
evict(pushRef: string): Promise<void>;
|
|
401
|
+
/**
|
|
402
|
+
* Stage a payload into the orphan envelope when no live socket is
|
|
403
|
+
* attached. Atomic — routes through `store.stageIfInactive` which
|
|
404
|
+
* does the read + wrap + write under a single Lua EVAL (Redis) or
|
|
405
|
+
* synchronous event-loop turn (Memory). This closes the 2.17.2
|
|
406
|
+
* stage-vs-claim race where a get-then-set could overwrite a freshly
|
|
407
|
+
* claimed higher-generation entry with the stale inactive snapshot.
|
|
408
|
+
*
|
|
409
|
+
* Returns `true` only when the payload was actually staged. `false`
|
|
410
|
+
* for any of:
|
|
411
|
+
* - entry missing (unknown / GC'd pushRef)
|
|
412
|
+
* - entry is currently active (route via room manager instead)
|
|
413
|
+
* - envelope mode is `'raw'` (no dead queue to stage into)
|
|
414
|
+
*/
|
|
415
|
+
stageForResume(pushRef: string, payload: unknown): Promise<boolean>;
|
|
416
|
+
/**
|
|
417
|
+
* Generation-fenced persist. Called by the room-manager hook after
|
|
418
|
+
* every wrap() so the persisted snapshot stays in sync with the live
|
|
419
|
+
* envelope. Caller passes the connection's generation; a stale
|
|
420
|
+
* persist (after the connection was fenced) silently no-ops at the
|
|
421
|
+
* store level — never overwrites the newer owner's state.
|
|
422
|
+
*
|
|
423
|
+
* Returns the outcome for callers that want to log fenced writes.
|
|
424
|
+
*/
|
|
425
|
+
persist(pushRef: string, expectedGeneration: number): Promise<FencedWriteOutcome>;
|
|
426
|
+
size(): Promise<number>;
|
|
427
|
+
gc(): Promise<number>;
|
|
428
|
+
close(): Promise<void>;
|
|
429
|
+
/** Diagnostics — read-only. */
|
|
430
|
+
inspect(pushRef: string): Promise<Readonly<SerializedEntry> | undefined>;
|
|
431
|
+
/** Reconstruct an EnvelopeWriter from a persisted snapshot. */
|
|
432
|
+
private hydrateEnvelope;
|
|
433
|
+
}
|
|
434
|
+
//#endregion
|
|
435
|
+
export { PushRefRegistry as a, SerializedEntry as c, Principal as i, EnvelopeWriter as l, FencedWriteOutcome as n, PushRefRegistryOptions as o, MemoryPushRefStore as r, PushRefStore as s, EnvelopeSnapshot as t };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { J as CrudRouteKey, R as RegisterOptions, V as ResourceDefinition, at as ResolvedTenantPurge, k as IntrospectionPluginOptions, p as RegistryEntry, z as ResourceRegistry } from "../index-
|
|
1
|
+
import { J as CrudRouteKey, R as RegisterOptions, V as ResourceDefinition, at as ResolvedTenantPurge, k as IntrospectionPluginOptions, p as RegistryEntry, z as ResourceRegistry } from "../index-C000faom.mjs";
|
|
2
2
|
import { FastifyPluginAsync } from "fastify";
|
|
3
3
|
import { TenantPurgeProgress, TenantPurgeStrategy } from "@classytic/repo-core/repository";
|
|
4
4
|
|