@classytic/arc 2.17.1 → 2.18.0

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.
Files changed (49) hide show
  1. package/dist/{BaseController-CgHomZ4d.mjs → BaseController-DPwe89qs.mjs} +1 -1
  2. package/dist/cli/commands/docs.mjs +1 -1
  3. package/dist/cli/commands/init.mjs +1 -1
  4. package/dist/cli/index.mjs +1 -1
  5. package/dist/context/index.d.mts +54 -1
  6. package/dist/context/index.mjs +65 -2
  7. package/dist/core/index.mjs +3 -3
  8. package/dist/{core-D8tpQ4ZL.mjs → core-B41eZxyM.mjs} +8 -5
  9. package/dist/{createActionRouter-BHUMb1-y.mjs → createActionRouter-CRaIwB5S.mjs} +2 -2
  10. package/dist/{createAggregationRouter-BYVvhfc5.mjs → createAggregationRouter-n3O6WdLO.mjs} +1 -1
  11. package/dist/{createApp-CzCEEsEm.mjs → createApp-D56t2Dsj.mjs} +7 -2
  12. package/dist/docs/index.mjs +1 -1
  13. package/dist/{eventPlugin-CnJCUyM0.mjs → eventPlugin-Dxp9JlKK.mjs} +1 -1
  14. package/dist/events/index.d.mts +48 -0
  15. package/dist/events/index.mjs +27 -5
  16. package/dist/factory/index.mjs +1 -1
  17. package/dist/index.mjs +4 -4
  18. package/dist/{init-DYzfniuX.mjs → init-DQ_rY1J7.mjs} +13 -13
  19. package/dist/integrations/event-gateway.d.mts +1 -1
  20. package/dist/integrations/index.d.mts +2 -2
  21. package/dist/integrations/mcp/index.d.mts +42 -2
  22. package/dist/integrations/mcp/index.mjs +102 -2
  23. package/dist/integrations/mcp/testing.d.mts +1 -1
  24. package/dist/integrations/mcp/testing.mjs +1 -1
  25. package/dist/integrations/websocket-pushref-redis.d.mts +71 -0
  26. package/dist/integrations/websocket-pushref-redis.mjs +205 -0
  27. package/dist/integrations/websocket-redis.d.mts +1 -1
  28. package/dist/integrations/websocket.d.mts +3 -2
  29. package/dist/integrations/websocket.mjs +1142 -23
  30. package/dist/{mcpPlugin-C6FViZXw.mjs → mcpPlugin-BrepZ0cV.mjs} +3 -3
  31. package/dist/{openapi-DKDs9NfD.mjs → openapi-CLZx9bwS.mjs} +1 -1
  32. package/dist/plugins/index.d.mts +15 -1
  33. package/dist/plugins/index.mjs +44 -3
  34. package/dist/plugins/tracing-entry.d.mts +1 -1
  35. package/dist/plugins/tracing-entry.mjs +1 -1
  36. package/dist/presets/filesUpload.d.mts +1 -1
  37. package/dist/pushref-registry-Bq_-EVsC.d.mts +435 -0
  38. package/dist/{requestContext-SSaaTgW8.mjs → requestContext-DDLS3NVU.mjs} +26 -1
  39. package/dist/{routerShared-nCyJGOKX.mjs → routerShared-Dl0A77Ba.mjs} +1 -1
  40. package/dist/{schemaIR-DAOvBWgT.mjs → schemaIR-Bk6Tns82.mjs} +17 -5
  41. package/dist/testing/index.mjs +1 -1
  42. package/dist/testing/storageContract.d.mts +1 -1
  43. package/dist/types/storage.d.mts +1 -1
  44. package/dist/websocket-BZMUP7Gr.d.mts +456 -0
  45. package/package.json +12 -1
  46. package/dist/websocket-BJ0w-3Dt.d.mts +0 -186
  47. /package/dist/{storage-HcAgP5Ni.d.mts → storage-BFmamN5m.d.mts} +0 -0
  48. /package/dist/{tracing-bRqqUxFO.d.mts → tracing-BYQ3hsbM.d.mts} +0 -0
  49. /package/dist/{types-CoMjNmkF.d.mts → types-D3HV0r5L.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-CgHomZ4d.mjs";
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-nCyJGOKX.mjs";
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-DAOvBWgT.mjs";
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";
@@ -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-BHUMb1-y.mjs";
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
  /**
@@ -2,7 +2,7 @@ import { Kt as AnyRecord, Q as MiddlewareConfig, dt as RouteDefinition, mt as Ro
2
2
  import { a as arcCorePlugin, i as _default, n as ArcCorePluginOptions, r as PluginMeta, t as ArcCore } from "../fastify-augmentation-7LIR0nDv.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-bRqqUxFO.mjs";
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>;
@@ -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 { t as requestContext } from "../requestContext-SSaaTgW8.mjs";
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
- store.user = request.user ?? null;
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-bRqqUxFO.mjs";
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.17.1";
61
+ const resolvedVersion = serviceVersion ?? "2.18.0";
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
1
  import { rt as PresetResult } from "../index-C1JSBan-.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-HcAgP5Ni.mjs";
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 {
@@ -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 };
@@ -64,5 +64,30 @@ const requestContext = {
64
64
  */
65
65
  storage
66
66
  };
67
+ /**
68
+ * Returns the W3C Trace Context headers (`traceparent`, `tracestate`) from
69
+ * the current request context as a plain `Record<string, string>`, suitable
70
+ * for passing to `OutboxWriteOptions.headers` or forwarding to downstream
71
+ * HTTP clients.
72
+ *
73
+ * Returns `undefined` when called outside a request scope or when no trace
74
+ * context was propagated by the upstream caller.
75
+ *
76
+ * @example
77
+ * ```typescript
78
+ * // Propagate trace context through the outbox so downstream consumers
79
+ * // can stitch together distributed traces.
80
+ * const traceHeaders = getTraceHeaders();
81
+ * await outbox.store(event, { headers: traceHeaders });
82
+ * ```
83
+ */
84
+ function getTraceHeaders() {
85
+ const store = requestContext.get();
86
+ if (!store) return void 0;
87
+ const headers = {};
88
+ if (store.traceparent) headers["traceparent"] = store.traceparent;
89
+ if (store.tracestate) headers["tracestate"] = store.tracestate;
90
+ return Object.keys(headers).length > 0 ? headers : void 0;
91
+ }
67
92
  //#endregion
68
- export { requestContext as t };
93
+ export { requestContext as n, getTraceHeaders as t };
@@ -1,7 +1,7 @@
1
1
  import { h as SYSTEM_FIELDS } from "./constants-TrJVIJl0.mjs";
2
2
  import { f as createError, r as ForbiddenError } from "./errors-j4aJm1Wg.mjs";
3
3
  import { b as isElevated, c as getOrgId, g as getUserId, n as PUBLIC_SCOPE, x as isMember } from "./types-Bi0r0vjG.mjs";
4
- import { t as requestContext } from "./requestContext-SSaaTgW8.mjs";
4
+ import { n as requestContext } from "./requestContext-DDLS3NVU.mjs";
5
5
  import { I as evaluateAndApplyPermission, M as applyFieldWritePermissions, P as resolveEffectiveRoles, j as applyFieldReadPermissions } from "./permissions-B1Qy55fg.mjs";
6
6
  import { t as getUserRoles } from "./types-DV9WDfeg.mjs";
7
7
  import { t as executePipeline } from "./pipe-DiCyvyPN.mjs";