@nifrajs/core 1.11.0 → 1.12.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.
- package/dist/causality.d.ts +132 -0
- package/dist/causality.d.ts.map +1 -0
- package/dist/causality.js +380 -0
- package/dist/causality.js.map +1 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/invariants.d.ts +4 -0
- package/dist/invariants.d.ts.map +1 -1
- package/dist/invariants.js +4 -0
- package/dist/invariants.js.map +1 -1
- package/dist/server/server.d.ts +6 -0
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +10 -0
- package/dist/server/server.js.map +1 -1
- package/package.json +6 -1
- package/src/causality.ts +550 -0
- package/src/index.ts +2 -1
- package/src/invariants.ts +4 -0
- package/src/server/server.ts +14 -1
package/src/causality.ts
ADDED
|
@@ -0,0 +1,550 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Framework-neutral execution causality.
|
|
3
|
+
*
|
|
4
|
+
* Traces describe one synchronous/remote observation tree. Causality survives the durable seams a
|
|
5
|
+
* trace cannot: a command commits an outbox event, a workflow resumes tomorrow, a projection
|
|
6
|
+
* consumes that event, and reconciliation repairs drift. This module carries only bounded identity
|
|
7
|
+
* tokens and links; payloads, tenant identifiers, request URLs, and arbitrary attributes cannot enter
|
|
8
|
+
* the graph by construction.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** A node category such as `request`, `command`, `event`, `workflow`, `projection`, or `repair`. */
|
|
12
|
+
export type CausalityKind = string
|
|
13
|
+
|
|
14
|
+
/** A bounded identity within one execution graph. */
|
|
15
|
+
export interface CausalityRef {
|
|
16
|
+
readonly kind: CausalityKind
|
|
17
|
+
readonly id: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Optional OpenTelemetry anchor for the nearest observed ancestor. */
|
|
21
|
+
export interface CausalityTrace {
|
|
22
|
+
readonly traceId: string
|
|
23
|
+
readonly spanId: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The propagation shape carried across commands/events/jobs. */
|
|
27
|
+
export interface CausalityContext {
|
|
28
|
+
readonly executionId: string
|
|
29
|
+
readonly current: CausalityRef
|
|
30
|
+
/** Nearest observed ancestor; downstream observations can attach a real OTel span link to it. */
|
|
31
|
+
readonly trace?: CausalityTrace
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** One immediate parent edge. Relation is a bounded token (`caused`, `emitted`, `projected`, …). */
|
|
35
|
+
export interface CausalityParent extends CausalityRef {
|
|
36
|
+
readonly relation: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** One append-only graph record. It intentionally has no payload or metadata field. */
|
|
40
|
+
export interface CausalityRecord {
|
|
41
|
+
readonly executionId: string
|
|
42
|
+
readonly node: CausalityRef & {
|
|
43
|
+
readonly at: number
|
|
44
|
+
/** Present only when this node itself opened an observation. */
|
|
45
|
+
readonly trace?: CausalityTrace
|
|
46
|
+
}
|
|
47
|
+
readonly parents: readonly CausalityParent[]
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A propagation context plus the graph record a durable adapter should append. */
|
|
51
|
+
export interface CausalityStep {
|
|
52
|
+
readonly context: CausalityContext
|
|
53
|
+
readonly record: CausalityRecord
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface CausalityRecorder<Tx = unknown> {
|
|
57
|
+
/** Idempotently append one node and its immediate incoming edges. */
|
|
58
|
+
record(record: CausalityRecord, tx?: Tx): Promise<"inserted" | "duplicate">
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface CausalityTimelineItem {
|
|
62
|
+
readonly cursor: string
|
|
63
|
+
readonly record: CausalityRecord
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface CausalityTimelinePage {
|
|
67
|
+
readonly items: readonly CausalityTimelineItem[]
|
|
68
|
+
readonly nextCursor?: string
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface CausalityReader {
|
|
72
|
+
timeline(
|
|
73
|
+
executionId: string,
|
|
74
|
+
options?: { readonly after?: string; readonly limit?: number },
|
|
75
|
+
): Promise<CausalityTimelinePage>
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export type CausalityGraphStore<Tx = unknown> = CausalityRecorder<Tx> & CausalityReader
|
|
79
|
+
|
|
80
|
+
export class CausalityConflictError extends Error {
|
|
81
|
+
constructor(
|
|
82
|
+
readonly executionId: string,
|
|
83
|
+
readonly node: CausalityRef,
|
|
84
|
+
) {
|
|
85
|
+
super(`causality conflict: ${node.kind}:${node.id} changed inside execution ${executionId}`)
|
|
86
|
+
this.name = "CausalityConflictError"
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export class CausalityCapacityError extends Error {
|
|
91
|
+
constructor(readonly maxRecords: number) {
|
|
92
|
+
super(`causality store capacity exceeded (${maxRecords} records)`)
|
|
93
|
+
this.name = "CausalityCapacityError"
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface StartCausalityOptions {
|
|
98
|
+
/** Stable graph identity. Generate once at the ingress boundary, then propagate it. */
|
|
99
|
+
readonly executionId: string
|
|
100
|
+
/** Epoch milliseconds. Injectable for deterministic tests. Default `Date.now()`. */
|
|
101
|
+
readonly at?: number
|
|
102
|
+
/** Observation opened for this exact node. */
|
|
103
|
+
readonly trace?: CausalityTrace
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface ContinueCausalityOptions {
|
|
107
|
+
/** Edge relation. Default `caused`. */
|
|
108
|
+
readonly relation?: string
|
|
109
|
+
/** Epoch milliseconds. Injectable for deterministic tests. Default `Date.now()`. */
|
|
110
|
+
readonly at?: number
|
|
111
|
+
/** A new observation opened for this exact node; otherwise the nearest anchor is propagated. */
|
|
112
|
+
readonly trace?: CausalityTrace
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export type CausalityParseResult =
|
|
116
|
+
| { readonly success: true; readonly context: CausalityContext }
|
|
117
|
+
| {
|
|
118
|
+
readonly success: false
|
|
119
|
+
readonly reason: "missing" | "incomplete" | "invalid" | "unknown-field"
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export type CausalityRecordParseResult =
|
|
123
|
+
| { readonly success: true; readonly record: CausalityRecord }
|
|
124
|
+
| {
|
|
125
|
+
readonly success: false
|
|
126
|
+
readonly reason: "incomplete" | "invalid" | "unknown-field"
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export const CAUSALITY_EXECUTION_HEADER = "x-nifra-execution-id"
|
|
130
|
+
export const CAUSALITY_KIND_HEADER = "x-nifra-causality-kind"
|
|
131
|
+
export const CAUSALITY_NODE_HEADER = "x-nifra-causality-id"
|
|
132
|
+
export const CAUSALITY_TRACE_HEADER = "x-nifra-causality-trace"
|
|
133
|
+
|
|
134
|
+
const IDENTITY = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/
|
|
135
|
+
const KIND = /^[a-z][a-z0-9._-]{0,63}$/
|
|
136
|
+
const RELATION = /^[a-z][a-z0-9._-]{0,63}$/
|
|
137
|
+
const TRACE_ID = /^[0-9a-f]{32}$/
|
|
138
|
+
const SPAN_ID = /^[0-9a-f]{16}$/
|
|
139
|
+
const MAX_PARENTS = 16
|
|
140
|
+
const DEFAULT_TIMELINE_LIMIT = 100
|
|
141
|
+
const MAX_TIMELINE_LIMIT = 500
|
|
142
|
+
|
|
143
|
+
function identity(value: string, label: string): string {
|
|
144
|
+
if (!IDENTITY.test(value)) {
|
|
145
|
+
throw new TypeError(`causality: ${label} must be a bounded identity token`)
|
|
146
|
+
}
|
|
147
|
+
return value
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function kind(value: string): string {
|
|
151
|
+
if (!KIND.test(value)) throw new TypeError("causality: kind must be a bounded lowercase token")
|
|
152
|
+
return value
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function relation(value: string): string {
|
|
156
|
+
if (!RELATION.test(value)) {
|
|
157
|
+
throw new TypeError("causality: relation must be a bounded lowercase token")
|
|
158
|
+
}
|
|
159
|
+
return value
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function timestamp(value: number | undefined): number {
|
|
163
|
+
const at = value ?? Date.now()
|
|
164
|
+
if (!Number.isSafeInteger(at) || at < 0) {
|
|
165
|
+
throw new TypeError("causality: at must be a non-negative safe-integer epoch millisecond")
|
|
166
|
+
}
|
|
167
|
+
return at
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function trace(value: CausalityTrace | undefined): CausalityTrace | undefined {
|
|
171
|
+
if (value === undefined) return undefined
|
|
172
|
+
if (!TRACE_ID.test(value.traceId) || !SPAN_ID.test(value.spanId)) {
|
|
173
|
+
throw new TypeError("causality: trace must contain lowercase W3C trace/span ids")
|
|
174
|
+
}
|
|
175
|
+
return Object.freeze({ traceId: value.traceId, spanId: value.spanId })
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function ref(nodeKind: string, id: string): CausalityRef {
|
|
179
|
+
return Object.freeze({ kind: kind(nodeKind), id: identity(id, "node id") })
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function recordOf(value: unknown): Readonly<Record<string, unknown>> | undefined {
|
|
183
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
184
|
+
? (value as Readonly<Record<string, unknown>>)
|
|
185
|
+
: undefined
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function onlyKeys(value: Readonly<Record<string, unknown>>, allowed: ReadonlySet<string>): boolean {
|
|
189
|
+
return Object.keys(value).every((key) => allowed.has(key))
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function buildStep(
|
|
193
|
+
executionId: string,
|
|
194
|
+
current: CausalityRef,
|
|
195
|
+
parents: readonly CausalityParent[],
|
|
196
|
+
at: number,
|
|
197
|
+
nodeTrace: CausalityTrace | undefined,
|
|
198
|
+
propagatedTrace: CausalityTrace | undefined,
|
|
199
|
+
): CausalityStep {
|
|
200
|
+
const node = Object.freeze({
|
|
201
|
+
...current,
|
|
202
|
+
at,
|
|
203
|
+
...(nodeTrace === undefined ? {} : { trace: nodeTrace }),
|
|
204
|
+
})
|
|
205
|
+
const frozenParents = Object.freeze([...parents])
|
|
206
|
+
const context = Object.freeze({
|
|
207
|
+
executionId,
|
|
208
|
+
current,
|
|
209
|
+
...(propagatedTrace === undefined ? {} : { trace: propagatedTrace }),
|
|
210
|
+
})
|
|
211
|
+
const record = Object.freeze({ executionId, node, parents: frozenParents })
|
|
212
|
+
return Object.freeze({ context, record })
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function normalizeRecord(input: CausalityRecord): CausalityRecord {
|
|
216
|
+
const executionId = identity(input.executionId, "executionId")
|
|
217
|
+
if (input.parents.length > MAX_PARENTS) {
|
|
218
|
+
throw new RangeError(`causality: a record cannot have more than ${MAX_PARENTS} parents`)
|
|
219
|
+
}
|
|
220
|
+
const current = ref(input.node.kind, input.node.id)
|
|
221
|
+
const nodeTrace = trace(input.node.trace)
|
|
222
|
+
const parents = input.parents.map((parent) =>
|
|
223
|
+
Object.freeze({
|
|
224
|
+
...ref(parent.kind, parent.id),
|
|
225
|
+
relation: relation(parent.relation),
|
|
226
|
+
}),
|
|
227
|
+
)
|
|
228
|
+
return Object.freeze({
|
|
229
|
+
executionId,
|
|
230
|
+
node: Object.freeze({
|
|
231
|
+
...current,
|
|
232
|
+
at: timestamp(input.node.at),
|
|
233
|
+
...(nodeTrace === undefined ? {} : { trace: nodeTrace }),
|
|
234
|
+
}),
|
|
235
|
+
parents: Object.freeze(parents),
|
|
236
|
+
})
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Start a root execution node at an ingress boundary. */
|
|
240
|
+
export function startCausality(
|
|
241
|
+
nodeKind: CausalityKind,
|
|
242
|
+
id: string,
|
|
243
|
+
options: StartCausalityOptions,
|
|
244
|
+
): CausalityStep {
|
|
245
|
+
const executionId = identity(options.executionId, "executionId")
|
|
246
|
+
const current = ref(nodeKind, id)
|
|
247
|
+
const nodeTrace = trace(options.trace)
|
|
248
|
+
return buildStep(executionId, current, [], timestamp(options.at), nodeTrace, nodeTrace)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Continue one execution from a single immediate parent. */
|
|
252
|
+
export function continueCausality(
|
|
253
|
+
parent: CausalityContext,
|
|
254
|
+
nodeKind: CausalityKind,
|
|
255
|
+
id: string,
|
|
256
|
+
options: ContinueCausalityOptions = {},
|
|
257
|
+
): CausalityStep {
|
|
258
|
+
return joinCausality([parent], nodeKind, id, options)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Join several immediate parents. Cross-execution joins fail closed. */
|
|
262
|
+
export function joinCausality(
|
|
263
|
+
parents: readonly CausalityContext[],
|
|
264
|
+
nodeKind: CausalityKind,
|
|
265
|
+
id: string,
|
|
266
|
+
options: ContinueCausalityOptions = {},
|
|
267
|
+
): CausalityStep {
|
|
268
|
+
if (parents.length === 0 || parents.length > MAX_PARENTS) {
|
|
269
|
+
throw new RangeError(`causality: parents must contain 1 to ${MAX_PARENTS} contexts`)
|
|
270
|
+
}
|
|
271
|
+
const executionId = identity(parents[0]?.executionId ?? "", "executionId")
|
|
272
|
+
if (parents.some((parent) => parent.executionId !== executionId)) {
|
|
273
|
+
throw new Error("causality: joined parents must belong to the same execution")
|
|
274
|
+
}
|
|
275
|
+
const edgeRelation = relation(options.relation ?? "caused")
|
|
276
|
+
const seen = new Set<string>()
|
|
277
|
+
const incoming: CausalityParent[] = []
|
|
278
|
+
for (const parent of parents) {
|
|
279
|
+
const parentRef = ref(parent.current.kind, parent.current.id)
|
|
280
|
+
const key = `${parentRef.kind}\n${parentRef.id}`
|
|
281
|
+
if (seen.has(key)) continue
|
|
282
|
+
seen.add(key)
|
|
283
|
+
incoming.push(Object.freeze({ ...parentRef, relation: edgeRelation }))
|
|
284
|
+
}
|
|
285
|
+
const current = ref(nodeKind, id)
|
|
286
|
+
const nodeTrace = trace(options.trace)
|
|
287
|
+
const propagatedTrace = nodeTrace ?? parents.find((parent) => parent.trace !== undefined)?.trace
|
|
288
|
+
return buildStep(
|
|
289
|
+
executionId,
|
|
290
|
+
current,
|
|
291
|
+
incoming,
|
|
292
|
+
timestamp(options.at),
|
|
293
|
+
nodeTrace,
|
|
294
|
+
propagatedTrace,
|
|
295
|
+
)
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const CONTEXT_FIELDS: ReadonlySet<string> = new Set(["executionId", "current", "trace"])
|
|
299
|
+
const REF_FIELDS: ReadonlySet<string> = new Set(["kind", "id"])
|
|
300
|
+
const TRACE_FIELDS: ReadonlySet<string> = new Set(["traceId", "spanId"])
|
|
301
|
+
const RECORD_FIELDS: ReadonlySet<string> = new Set(["executionId", "node", "parents"])
|
|
302
|
+
const NODE_FIELDS: ReadonlySet<string> = new Set(["kind", "id", "at", "trace"])
|
|
303
|
+
const PARENT_FIELDS: ReadonlySet<string> = new Set(["kind", "id", "relation"])
|
|
304
|
+
|
|
305
|
+
/** Parse an untrusted durable graph record. Unknown fields fail closed at every nesting level. */
|
|
306
|
+
export function parseCausalityRecord(input: unknown): CausalityRecordParseResult {
|
|
307
|
+
const value = recordOf(input)
|
|
308
|
+
if (value === undefined) return { success: false, reason: "invalid" }
|
|
309
|
+
if (!onlyKeys(value, RECORD_FIELDS)) return { success: false, reason: "unknown-field" }
|
|
310
|
+
const node = recordOf(value.node)
|
|
311
|
+
if (node === undefined || !Array.isArray(value.parents)) {
|
|
312
|
+
return { success: false, reason: "incomplete" }
|
|
313
|
+
}
|
|
314
|
+
if (!onlyKeys(node, NODE_FIELDS)) return { success: false, reason: "unknown-field" }
|
|
315
|
+
const nodeTrace =
|
|
316
|
+
value.node !== undefined && node.trace !== undefined ? recordOf(node.trace) : undefined
|
|
317
|
+
if (node.trace !== undefined && nodeTrace === undefined) {
|
|
318
|
+
return { success: false, reason: "invalid" }
|
|
319
|
+
}
|
|
320
|
+
if (nodeTrace !== undefined && !onlyKeys(nodeTrace, TRACE_FIELDS)) {
|
|
321
|
+
return { success: false, reason: "unknown-field" }
|
|
322
|
+
}
|
|
323
|
+
const parents: CausalityParent[] = []
|
|
324
|
+
for (const rawParent of value.parents) {
|
|
325
|
+
const parent = recordOf(rawParent)
|
|
326
|
+
if (parent === undefined) return { success: false, reason: "invalid" }
|
|
327
|
+
if (!onlyKeys(parent, PARENT_FIELDS)) return { success: false, reason: "unknown-field" }
|
|
328
|
+
if (
|
|
329
|
+
typeof parent.kind !== "string" ||
|
|
330
|
+
typeof parent.id !== "string" ||
|
|
331
|
+
typeof parent.relation !== "string"
|
|
332
|
+
) {
|
|
333
|
+
return { success: false, reason: "incomplete" }
|
|
334
|
+
}
|
|
335
|
+
parents.push({ kind: parent.kind, id: parent.id, relation: parent.relation })
|
|
336
|
+
}
|
|
337
|
+
if (
|
|
338
|
+
typeof value.executionId !== "string" ||
|
|
339
|
+
typeof node.kind !== "string" ||
|
|
340
|
+
typeof node.id !== "string" ||
|
|
341
|
+
typeof node.at !== "number" ||
|
|
342
|
+
(nodeTrace !== undefined &&
|
|
343
|
+
(typeof nodeTrace.traceId !== "string" || typeof nodeTrace.spanId !== "string"))
|
|
344
|
+
) {
|
|
345
|
+
return { success: false, reason: "incomplete" }
|
|
346
|
+
}
|
|
347
|
+
try {
|
|
348
|
+
return {
|
|
349
|
+
success: true,
|
|
350
|
+
record: normalizeRecord({
|
|
351
|
+
executionId: value.executionId,
|
|
352
|
+
node: {
|
|
353
|
+
kind: node.kind,
|
|
354
|
+
id: node.id,
|
|
355
|
+
at: node.at,
|
|
356
|
+
...(nodeTrace === undefined
|
|
357
|
+
? {}
|
|
358
|
+
: {
|
|
359
|
+
trace: { traceId: nodeTrace.traceId as string, spanId: nodeTrace.spanId as string },
|
|
360
|
+
}),
|
|
361
|
+
},
|
|
362
|
+
parents,
|
|
363
|
+
}),
|
|
364
|
+
}
|
|
365
|
+
} catch {
|
|
366
|
+
return { success: false, reason: "invalid" }
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** Parse an untrusted JSON causality context. Unknown fields fail closed so payloads cannot hitchhike. */
|
|
371
|
+
export function parseCausalityContext(input: unknown): CausalityParseResult {
|
|
372
|
+
const value = recordOf(input)
|
|
373
|
+
if (value === undefined) return { success: false, reason: "invalid" }
|
|
374
|
+
if (!onlyKeys(value, CONTEXT_FIELDS)) return { success: false, reason: "unknown-field" }
|
|
375
|
+
const current = recordOf(value.current)
|
|
376
|
+
if (current === undefined) return { success: false, reason: "incomplete" }
|
|
377
|
+
if (!onlyKeys(current, REF_FIELDS)) return { success: false, reason: "unknown-field" }
|
|
378
|
+
const rawTrace = value.trace === undefined ? undefined : recordOf(value.trace)
|
|
379
|
+
if (value.trace !== undefined && rawTrace === undefined) {
|
|
380
|
+
return { success: false, reason: "invalid" }
|
|
381
|
+
}
|
|
382
|
+
if (rawTrace !== undefined && !onlyKeys(rawTrace, TRACE_FIELDS)) {
|
|
383
|
+
return { success: false, reason: "unknown-field" }
|
|
384
|
+
}
|
|
385
|
+
if (
|
|
386
|
+
typeof value.executionId !== "string" ||
|
|
387
|
+
typeof current.kind !== "string" ||
|
|
388
|
+
typeof current.id !== "string" ||
|
|
389
|
+
(rawTrace !== undefined &&
|
|
390
|
+
(typeof rawTrace.traceId !== "string" || typeof rawTrace.spanId !== "string"))
|
|
391
|
+
) {
|
|
392
|
+
return { success: false, reason: "incomplete" }
|
|
393
|
+
}
|
|
394
|
+
try {
|
|
395
|
+
const context = Object.freeze({
|
|
396
|
+
executionId: identity(value.executionId, "executionId"),
|
|
397
|
+
current: ref(current.kind, current.id),
|
|
398
|
+
...(rawTrace === undefined
|
|
399
|
+
? {}
|
|
400
|
+
: {
|
|
401
|
+
trace: trace({
|
|
402
|
+
traceId: rawTrace.traceId as string,
|
|
403
|
+
spanId: rawTrace.spanId as string,
|
|
404
|
+
}) as CausalityTrace,
|
|
405
|
+
}),
|
|
406
|
+
})
|
|
407
|
+
return { success: true, context }
|
|
408
|
+
} catch {
|
|
409
|
+
return { success: false, reason: "invalid" }
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** Serialize the propagation context into bounded HTTP headers. */
|
|
414
|
+
export function causalityHeaders(context: CausalityContext): Readonly<Record<string, string>> {
|
|
415
|
+
const parsed = parseCausalityContext(context)
|
|
416
|
+
if (!parsed.success) throw new TypeError(`causality: invalid context (${parsed.reason})`)
|
|
417
|
+
return Object.freeze({
|
|
418
|
+
[CAUSALITY_EXECUTION_HEADER]: parsed.context.executionId,
|
|
419
|
+
[CAUSALITY_KIND_HEADER]: parsed.context.current.kind,
|
|
420
|
+
[CAUSALITY_NODE_HEADER]: parsed.context.current.id,
|
|
421
|
+
...(parsed.context.trace === undefined
|
|
422
|
+
? {}
|
|
423
|
+
: {
|
|
424
|
+
[CAUSALITY_TRACE_HEADER]: `${parsed.context.trace.traceId}-${parsed.context.trace.spanId}`,
|
|
425
|
+
}),
|
|
426
|
+
})
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** Parse the public header convention without ever throwing on hostile input. */
|
|
430
|
+
export function readCausalityHeaders(headers: Headers): CausalityParseResult {
|
|
431
|
+
const executionId = headers.get(CAUSALITY_EXECUTION_HEADER)
|
|
432
|
+
const nodeKind = headers.get(CAUSALITY_KIND_HEADER)
|
|
433
|
+
const nodeId = headers.get(CAUSALITY_NODE_HEADER)
|
|
434
|
+
const encodedTrace = headers.get(CAUSALITY_TRACE_HEADER)
|
|
435
|
+
if (executionId === null && nodeKind === null && nodeId === null && encodedTrace === null) {
|
|
436
|
+
return { success: false, reason: "missing" }
|
|
437
|
+
}
|
|
438
|
+
if (executionId === null || nodeKind === null || nodeId === null) {
|
|
439
|
+
return { success: false, reason: "incomplete" }
|
|
440
|
+
}
|
|
441
|
+
let decodedTrace: CausalityTrace | undefined
|
|
442
|
+
if (encodedTrace !== null) {
|
|
443
|
+
const match = /^([0-9a-f]{32})-([0-9a-f]{16})$/.exec(encodedTrace)
|
|
444
|
+
if (match === null) return { success: false, reason: "invalid" }
|
|
445
|
+
decodedTrace = { traceId: match[1] as string, spanId: match[2] as string }
|
|
446
|
+
}
|
|
447
|
+
return parseCausalityContext({
|
|
448
|
+
executionId,
|
|
449
|
+
current: { kind: nodeKind, id: nodeId },
|
|
450
|
+
...(decodedTrace === undefined ? {} : { trace: decodedTrace }),
|
|
451
|
+
})
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
interface MemoryEntry {
|
|
455
|
+
readonly sequence: number
|
|
456
|
+
readonly canonical: string
|
|
457
|
+
readonly record: CausalityRecord
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
export interface MemoryCausalityStoreOptions {
|
|
461
|
+
/** Global record bound. Default 10,000. */
|
|
462
|
+
readonly maxRecords?: number
|
|
463
|
+
/** In-memory evidence disappears on restart and is rejected in production unless explicitly allowed. */
|
|
464
|
+
readonly allowInProduction?: boolean
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const timelineCursor = (entry: MemoryEntry): string =>
|
|
468
|
+
`${entry.record.node.at.toString(36)}:${entry.sequence.toString(36)}`
|
|
469
|
+
|
|
470
|
+
function timelineLimit(value: number | undefined): number {
|
|
471
|
+
const limit = value ?? DEFAULT_TIMELINE_LIMIT
|
|
472
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_TIMELINE_LIMIT) {
|
|
473
|
+
throw new RangeError(
|
|
474
|
+
`causality: timeline limit must be an integer from 1 to ${MAX_TIMELINE_LIMIT}`,
|
|
475
|
+
)
|
|
476
|
+
}
|
|
477
|
+
return limit
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/** Bounded dev/test graph store. Production callers should provide a durable adapter. */
|
|
481
|
+
export function createMemoryCausalityStore(
|
|
482
|
+
options: MemoryCausalityStoreOptions = {},
|
|
483
|
+
): CausalityGraphStore {
|
|
484
|
+
if (
|
|
485
|
+
typeof process !== "undefined" &&
|
|
486
|
+
process.env.NODE_ENV === "production" &&
|
|
487
|
+
!options.allowInProduction
|
|
488
|
+
) {
|
|
489
|
+
throw new Error(
|
|
490
|
+
"causality: the memory store is not durable; configure a production graph adapter",
|
|
491
|
+
)
|
|
492
|
+
}
|
|
493
|
+
const maxRecords = options.maxRecords ?? 10_000
|
|
494
|
+
if (!Number.isSafeInteger(maxRecords) || maxRecords < 1) {
|
|
495
|
+
throw new RangeError("causality: maxRecords must be a positive safe integer")
|
|
496
|
+
}
|
|
497
|
+
const byNode = new Map<string, MemoryEntry>()
|
|
498
|
+
const byExecution = new Map<string, MemoryEntry[]>()
|
|
499
|
+
let sequence = 0
|
|
500
|
+
|
|
501
|
+
return {
|
|
502
|
+
async record(input) {
|
|
503
|
+
const parsed = parseCausalityRecord(input)
|
|
504
|
+
if (!parsed.success) throw new TypeError(`causality: invalid record (${parsed.reason})`)
|
|
505
|
+
const record = parsed.record
|
|
506
|
+
const key = `${record.executionId}\n${record.node.kind}\n${record.node.id}`
|
|
507
|
+
const canonical = JSON.stringify(record)
|
|
508
|
+
const prior = byNode.get(key)
|
|
509
|
+
if (prior !== undefined) {
|
|
510
|
+
if (prior.canonical !== canonical) {
|
|
511
|
+
throw new CausalityConflictError(record.executionId, record.node)
|
|
512
|
+
}
|
|
513
|
+
return "duplicate"
|
|
514
|
+
}
|
|
515
|
+
if (byNode.size >= maxRecords) throw new CausalityCapacityError(maxRecords)
|
|
516
|
+
const entry = Object.freeze({ sequence: sequence++, canonical, record })
|
|
517
|
+
byNode.set(key, entry)
|
|
518
|
+
const execution = byExecution.get(record.executionId) ?? []
|
|
519
|
+
execution.push(entry)
|
|
520
|
+
byExecution.set(record.executionId, execution)
|
|
521
|
+
return "inserted"
|
|
522
|
+
},
|
|
523
|
+
async timeline(rawExecutionId, query = {}) {
|
|
524
|
+
const executionId = identity(rawExecutionId, "executionId")
|
|
525
|
+
const limit = timelineLimit(query.limit)
|
|
526
|
+
const entries = [...(byExecution.get(executionId) ?? [])].sort(
|
|
527
|
+
(left, right) =>
|
|
528
|
+
left.record.node.at - right.record.node.at || left.sequence - right.sequence,
|
|
529
|
+
)
|
|
530
|
+
let start = 0
|
|
531
|
+
if (query.after !== undefined) {
|
|
532
|
+
const index = entries.findIndex((entry) => timelineCursor(entry) === query.after)
|
|
533
|
+
if (index < 0) throw new TypeError("causality: invalid timeline cursor")
|
|
534
|
+
start = index + 1
|
|
535
|
+
}
|
|
536
|
+
const selected = entries.slice(start, start + limit)
|
|
537
|
+
const hasMore = start + selected.length < entries.length
|
|
538
|
+
return Object.freeze({
|
|
539
|
+
items: Object.freeze(
|
|
540
|
+
selected.map((entry) =>
|
|
541
|
+
Object.freeze({ cursor: timelineCursor(entry), record: entry.record }),
|
|
542
|
+
),
|
|
543
|
+
),
|
|
544
|
+
...(hasMore && selected.length > 0
|
|
545
|
+
? { nextCursor: timelineCursor(selected[selected.length - 1] as MemoryEntry) }
|
|
546
|
+
: {}),
|
|
547
|
+
})
|
|
548
|
+
},
|
|
549
|
+
}
|
|
550
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -12,12 +12,13 @@
|
|
|
12
12
|
* (it shipped at "0.0.0" through 1.0.0 before those guards existed). Kept narrow (`as const`) so
|
|
13
13
|
* consumers can pin it at the type level — see `test/version.test-d.ts`.
|
|
14
14
|
*/
|
|
15
|
-
export const VERSION = "1.
|
|
15
|
+
export const VERSION = "1.12.0" as const
|
|
16
16
|
|
|
17
17
|
export type Version = typeof VERSION
|
|
18
18
|
|
|
19
19
|
export * from "./assurance.ts"
|
|
20
20
|
export * from "./capabilities.ts"
|
|
21
|
+
export * from "./causality.ts"
|
|
21
22
|
export * from "./classification.ts"
|
|
22
23
|
export * from "./diff.ts"
|
|
23
24
|
export { FrameworkError, RouteConfigError, type RouteConfigErrorCode } from "./errors.ts"
|
package/src/invariants.ts
CHANGED
|
@@ -334,6 +334,10 @@ interface RoutePlan {
|
|
|
334
334
|
const FUZZABLE_METHODS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE"])
|
|
335
335
|
|
|
336
336
|
/** Run the contract-derived invariant suite against a reflectable app. Pure; no ambient state. */
|
|
337
|
+
/**
|
|
338
|
+
* @deprecated Use `runAdversarialContract` from `@nifrajs/testing`. The testing package is the
|
|
339
|
+
* authoritative deep contract laboratory and powers CLI L4; this export remains for compatibility.
|
|
340
|
+
*/
|
|
337
341
|
export async function runContractInvariants(
|
|
338
342
|
app: ReflectableApp,
|
|
339
343
|
options: RunInvariantsOptions = {},
|
package/src/server/server.ts
CHANGED
|
@@ -3793,8 +3793,16 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3793
3793
|
* `app.listen(PORT, { reusePort: true })`; see `examples/cluster.ts`. Every process must opt in,
|
|
3794
3794
|
* and all of them must be the same app. Linux balances ~evenly; macOS accepts the flag but may
|
|
3795
3795
|
* favor one socket (fine for dev, measure on Linux for production numbers).
|
|
3796
|
+
*
|
|
3797
|
+
* `hostname` is the bind address, defaulting to Bun's `0.0.0.0` (every interface). Pass
|
|
3798
|
+
* `"127.0.0.1"` to bind loopback only - an admin surface, a sidecar, or anything that must not be
|
|
3799
|
+
* reachable off the box. Omitting it when you meant to restrict is a real exposure, so it is a
|
|
3800
|
+
* first-class option rather than something a caller has to drop down to `Bun.serve` for.
|
|
3796
3801
|
*/
|
|
3797
|
-
listen(
|
|
3802
|
+
listen(
|
|
3803
|
+
port: number,
|
|
3804
|
+
options?: { readonly reusePort?: boolean; readonly hostname?: string },
|
|
3805
|
+
): RunningServer {
|
|
3798
3806
|
if (typeof Bun === "undefined") {
|
|
3799
3807
|
// listen() is the one Bun-specific seam. Off Bun, fail loud + actionable rather
|
|
3800
3808
|
// than letting the Bun.serve call below throw a bare `ReferenceError: Bun is not
|
|
@@ -3820,17 +3828,22 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
3820
3828
|
? undefined
|
|
3821
3829
|
: (getWsRuntime() as WsRuntime).bunHandlers(this.topics as TopicRegistry)
|
|
3822
3830
|
const reusePort = options?.reusePort === true
|
|
3831
|
+
// Spread rather than pass `hostname: undefined` - Bun treats an explicit undefined as a value
|
|
3832
|
+
// on some option paths, and omitting is what selects its 0.0.0.0 default.
|
|
3833
|
+
const bind = options?.hostname === undefined ? {} : { hostname: options.hostname }
|
|
3823
3834
|
const nativeRoutes = wsHandlers === undefined ? this.buildBunNativeRoutes() : undefined
|
|
3824
3835
|
const running = (wsHandlers === undefined
|
|
3825
3836
|
? Bun.serve({
|
|
3826
3837
|
port,
|
|
3827
3838
|
reusePort,
|
|
3839
|
+
...bind,
|
|
3828
3840
|
...(nativeRoutes === undefined ? {} : { routes: nativeRoutes }),
|
|
3829
3841
|
fetch: (req: Request) => this.fetch(req),
|
|
3830
3842
|
})
|
|
3831
3843
|
: Bun.serve<BunWsData>({
|
|
3832
3844
|
port,
|
|
3833
3845
|
reusePort,
|
|
3846
|
+
...bind,
|
|
3834
3847
|
fetch: (req, server) => this.bunFetchWithWebSocket(req, server),
|
|
3835
3848
|
// Bun's `ServerWebSocket<BunWsData>` is runtime-compatible with the handlers' structural
|
|
3836
3849
|
// `BunSocket` view (kept local so `Bun.*` types never leak into the published .d.ts); the
|