@nifrajs/core 1.10.0 → 1.11.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 (63) hide show
  1. package/dist/assurance.d.ts +13 -0
  2. package/dist/assurance.d.ts.map +1 -1
  3. package/dist/assurance.js +20 -0
  4. package/dist/assurance.js.map +1 -1
  5. package/dist/capabilities.d.ts +30 -1
  6. package/dist/capabilities.d.ts.map +1 -1
  7. package/dist/capabilities.js +32 -6
  8. package/dist/capabilities.js.map +1 -1
  9. package/dist/classification.d.ts +20 -0
  10. package/dist/classification.d.ts.map +1 -1
  11. package/dist/classification.js +120 -0
  12. package/dist/classification.js.map +1 -1
  13. package/dist/errors.d.ts +1 -1
  14. package/dist/errors.d.ts.map +1 -1
  15. package/dist/errors.js.map +1 -1
  16. package/dist/idempotency.d.ts +59 -12
  17. package/dist/idempotency.d.ts.map +1 -1
  18. package/dist/idempotency.js +191 -20
  19. package/dist/idempotency.js.map +1 -1
  20. package/dist/index.d.ts +5 -1
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +5 -1
  23. package/dist/index.js.map +1 -1
  24. package/dist/invariants.d.ts +66 -0
  25. package/dist/invariants.d.ts.map +1 -0
  26. package/dist/invariants.js +483 -0
  27. package/dist/invariants.js.map +1 -0
  28. package/dist/ledger.d.ts +154 -0
  29. package/dist/ledger.d.ts.map +1 -0
  30. package/dist/ledger.js +271 -0
  31. package/dist/ledger.js.map +1 -0
  32. package/dist/manifest.d.ts +81 -0
  33. package/dist/manifest.d.ts.map +1 -0
  34. package/dist/manifest.js +394 -0
  35. package/dist/manifest.js.map +1 -0
  36. package/dist/mount.d.ts +18 -0
  37. package/dist/mount.d.ts.map +1 -0
  38. package/dist/mount.js +11 -0
  39. package/dist/mount.js.map +1 -0
  40. package/dist/reflection.d.ts +3 -3
  41. package/dist/reflection.d.ts.map +1 -1
  42. package/dist/reflection.js +5 -5
  43. package/dist/reflection.js.map +1 -1
  44. package/dist/server/context.d.ts +17 -1
  45. package/dist/server/context.d.ts.map +1 -1
  46. package/dist/server/server.d.ts +23 -2
  47. package/dist/server/server.d.ts.map +1 -1
  48. package/dist/server/server.js +179 -26
  49. package/dist/server/server.js.map +1 -1
  50. package/package.json +26 -1
  51. package/src/assurance.ts +33 -0
  52. package/src/capabilities.ts +60 -4
  53. package/src/classification.ts +145 -0
  54. package/src/errors.ts +1 -0
  55. package/src/idempotency.ts +245 -30
  56. package/src/index.ts +5 -1
  57. package/src/invariants.ts +600 -0
  58. package/src/ledger.ts +430 -0
  59. package/src/manifest.ts +543 -0
  60. package/src/mount.ts +24 -0
  61. package/src/reflection.ts +11 -7
  62. package/src/server/context.ts +20 -1
  63. package/src/server/server.ts +254 -30
@@ -10,6 +10,24 @@
10
10
  /** Sensitivity of the data a response carries. Ordered `public` < `pii` < `secret`. */
11
11
  export type DataClassification = "public" | "pii" | "secret"
12
12
 
13
+ /** Field paths use JSON Pointer segments; array items use a `*` segment. */
14
+ export interface ResponseClassification {
15
+ readonly fields: Readonly<Record<string, DataClassification>>
16
+ readonly max: DataClassification
17
+ }
18
+
19
+ const CLASSIFICATION = Symbol.for("nifra.data-classification")
20
+ const JSON_SCHEMA_CLASSIFICATION = "x-nifra-classification"
21
+
22
+ type ClassificationCarrier = {
23
+ readonly [CLASSIFICATION]?: DataClassification
24
+ readonly jsonSchema?: unknown
25
+ }
26
+
27
+ export type ClassifiedSchema<S extends object> = S & {
28
+ readonly [CLASSIFICATION]: DataClassification
29
+ }
30
+
13
31
  /** Total order over classifications; higher = more sensitive. */
14
32
  export const DATA_CLASSIFICATION_RANK: Readonly<Record<DataClassification, number>> = Object.freeze(
15
33
  {
@@ -42,3 +60,130 @@ export function classificationAtLeast(
42
60
  ): boolean {
43
61
  return DATA_CLASSIFICATION_RANK[value] >= DATA_CLASSIFICATION_RANK[floor]
44
62
  }
63
+
64
+ function cloneObject<T extends object>(value: T): T {
65
+ return Object.create(Object.getPrototypeOf(value), Object.getOwnPropertyDescriptors(value)) as T
66
+ }
67
+
68
+ function tagObject<T extends object>(value: T, classification: DataClassification): T {
69
+ const clone = cloneObject(value)
70
+ Object.defineProperty(clone, CLASSIFICATION, {
71
+ configurable: false,
72
+ enumerable: true,
73
+ value: classification,
74
+ writable: false,
75
+ })
76
+ Object.defineProperty(clone, JSON_SCHEMA_CLASSIFICATION, {
77
+ configurable: true,
78
+ enumerable: true,
79
+ value: classification,
80
+ writable: true,
81
+ })
82
+ return clone
83
+ }
84
+
85
+ /**
86
+ * Attach data-classification metadata without changing validation or inferred input/output types.
87
+ * For Nifra/TypeBox carriers the raw JSON Schema node is tagged too, so metadata survives composition
88
+ * through `t.object`, `t.array`, `t.optional`, and unions.
89
+ */
90
+ export function classified<S extends object>(
91
+ schema: S,
92
+ classification: DataClassification,
93
+ ): ClassifiedSchema<S> {
94
+ if (!isDataClassification(classification)) {
95
+ throw new TypeError(`classification: invalid tag ${JSON.stringify(classification)}`)
96
+ }
97
+ const clone = tagObject(schema, classification) as S & ClassificationCarrier
98
+ const raw = (schema as ClassificationCarrier).jsonSchema
99
+ if (raw !== null && typeof raw === "object") {
100
+ Object.defineProperty(clone, "jsonSchema", {
101
+ configurable: true,
102
+ enumerable: true,
103
+ value: tagObject(raw, classification),
104
+ writable: false,
105
+ })
106
+ }
107
+ return clone as ClassifiedSchema<S>
108
+ }
109
+
110
+ const recordOf = (value: unknown): Readonly<Record<string, unknown>> | undefined =>
111
+ value !== null && typeof value === "object"
112
+ ? (value as Readonly<Record<string, unknown>>)
113
+ : undefined
114
+
115
+ const classificationOf = (value: unknown): DataClassification | undefined => {
116
+ const record = recordOf(value)
117
+ const symbolValue = (value as ClassificationCarrier | null)?.[CLASSIFICATION]
118
+ if (isDataClassification(symbolValue)) return symbolValue
119
+ const extension = record?.[JSON_SCHEMA_CLASSIFICATION]
120
+ return isDataClassification(extension) ? extension : undefined
121
+ }
122
+
123
+ const pointerSegment = (value: string): string => value.replaceAll("~", "~0").replaceAll("/", "~1")
124
+
125
+ /** Read field-level metadata from an introspectable response schema. Never invokes its validator. */
126
+ export function reflectClassification(schema: unknown): ResponseClassification | undefined {
127
+ const carrier = recordOf(schema)
128
+ const raw = carrier !== undefined && "jsonSchema" in carrier ? carrier.jsonSchema : schema
129
+ const fields: Record<string, DataClassification> = {}
130
+ const seen = new Set<object>()
131
+ const all: DataClassification[] = []
132
+
133
+ const record = (path: string, value: DataClassification): void => {
134
+ const key = path === "" ? "$" : path
135
+ const previous = fields[key]
136
+ fields[key] = previous === undefined ? value : maxClassification([previous, value])
137
+ all.push(value)
138
+ }
139
+
140
+ const visit = (value: unknown, path: string): void => {
141
+ const object = recordOf(value)
142
+ if (object === undefined) return
143
+ if (seen.has(object as object)) return
144
+ seen.add(object as object)
145
+ const own = classificationOf(value)
146
+ if (own !== undefined) record(path, own)
147
+
148
+ const properties = recordOf(object.properties)
149
+ if (properties !== undefined) {
150
+ for (const [name, child] of Object.entries(properties)) {
151
+ visit(child, `${path}/${pointerSegment(name)}`)
152
+ }
153
+ }
154
+ if (object.items !== undefined) visit(object.items, `${path}/*`)
155
+ if (Array.isArray(object.prefixItems)) {
156
+ for (const [index, child] of object.prefixItems.entries()) visit(child, `${path}/${index}`)
157
+ }
158
+ if (object.additionalProperties !== undefined && object.additionalProperties !== false) {
159
+ visit(object.additionalProperties, `${path}/*`)
160
+ }
161
+ for (const key of ["allOf", "anyOf", "oneOf"] as const) {
162
+ const variants = object[key]
163
+ if (Array.isArray(variants)) for (const child of variants) visit(child, path)
164
+ }
165
+ }
166
+
167
+ visit(raw, "")
168
+ // A validation-only carrier can still carry a root tag even when it has no JSON Schema metadata.
169
+ const carrierTag = classificationOf(schema)
170
+ if (carrierTag !== undefined && all.length === 0) record("", carrierTag)
171
+ if (all.length === 0) return undefined
172
+ return Object.freeze({ fields: Object.freeze(fields), max: maxClassification(all) })
173
+ }
174
+
175
+ /** Merge field metadata with an optional route-level sensitivity fallback. */
176
+ export function routeClassification(
177
+ responseSchema: unknown,
178
+ fallback: DataClassification | undefined,
179
+ ): ResponseClassification | undefined {
180
+ const reflected = reflectClassification(responseSchema)
181
+ if (reflected === undefined && fallback === undefined) return undefined
182
+ return Object.freeze({
183
+ fields: reflected?.fields ?? Object.freeze({}),
184
+ max:
185
+ fallback === undefined
186
+ ? (reflected as ResponseClassification).max
187
+ : maxClassification([fallback, ...(reflected === undefined ? [] : [reflected.max])]),
188
+ })
189
+ }
package/src/errors.ts CHANGED
@@ -25,6 +25,7 @@ export type RouteConfigErrorCode =
25
25
  | "WILDCARD_NOT_LAST"
26
26
  | "INVALID_METHOD"
27
27
  | "INVALID_ASSURANCE"
28
+ | "INVALID_IDEMPOTENCY"
28
29
 
29
30
  /**
30
31
  * Thrown at route registration when a route is misconfigured. This is the
@@ -24,6 +24,33 @@ export const IDEMPOTENT_REPLAY_HEADER = "x-nifra-idempotent-replay"
24
24
 
25
25
  const KEY_MAX_LENGTH = 255
26
26
 
27
+ // A replay is a new HTTP exchange, not a byte-for-byte resurrection of connection state. Session
28
+ // setters and hop-by-hop headers belong only to the winning response. Filter on both write and read:
29
+ // durable stores may still contain records created by an older Nifra version.
30
+ const REPLAY_UNSAFE_RESPONSE_HEADERS: ReadonlySet<string> = new Set([
31
+ "authentication-info",
32
+ "connection",
33
+ "keep-alive",
34
+ "proxy-authenticate",
35
+ "proxy-authentication-info",
36
+ "proxy-authorization",
37
+ "set-cookie",
38
+ "te",
39
+ "trailer",
40
+ "transfer-encoding",
41
+ "upgrade",
42
+ ])
43
+
44
+ function replaySafeHeaders(
45
+ headers: Iterable<readonly [string, string]>,
46
+ ): readonly (readonly [string, string])[] {
47
+ const safe: Array<readonly [string, string]> = []
48
+ for (const [name, value] of headers) {
49
+ if (!REPLAY_UNSAFE_RESPONSE_HEADERS.has(name.toLowerCase())) safe.push([name, value])
50
+ }
51
+ return safe
52
+ }
53
+
27
54
  /** A serialized response held by a store. `body` is base64 so binary payloads round-trip intact. */
28
55
  export interface StoredResponse {
29
56
  readonly status: number
@@ -39,10 +66,33 @@ export interface StoredResponse {
39
66
  * not yet completed (a concurrent duplicate) → 409 + Retry-After.
40
67
  */
41
68
  export type IdempotencyBeginResult =
42
- | { readonly state: "new" }
69
+ | { readonly state: "new"; readonly reservation: string }
43
70
  | { readonly state: "replay"; readonly response: StoredResponse }
44
71
  | { readonly state: "mismatch" }
45
72
  | { readonly state: "in-flight" }
73
+ | { readonly state: "capacity" }
74
+
75
+ /** Namespaces isolate the same client key across tenants/subjects without putting identity in a header. */
76
+ export interface IdempotencyEntryKey {
77
+ readonly namespace: string
78
+ readonly key: string
79
+ }
80
+
81
+ export interface IdempotencyBeginInput extends IdempotencyEntryKey {
82
+ readonly fingerprint: string
83
+ readonly ttlMs: number
84
+ }
85
+
86
+ export interface IdempotencyCompletionInput extends IdempotencyEntryKey {
87
+ /** Opaque ownership token returned by `begin(state:"new")`. */
88
+ readonly reservation: string
89
+ readonly response: StoredResponse
90
+ }
91
+
92
+ export interface IdempotencyAbandonInput extends IdempotencyEntryKey {
93
+ /** Opaque ownership token returned by `begin(state:"new")`. */
94
+ readonly reservation: string
95
+ }
46
96
 
47
97
  /**
48
98
  * Storage seam for idempotent responses. `begin` MUST be atomic: for one key, exactly one concurrent
@@ -50,16 +100,21 @@ export type IdempotencyBeginResult =
50
100
  * this free from the single-threaded event loop; a durable store uses an atomic insert.
51
101
  */
52
102
  export interface IdempotencyStore {
53
- /** Reserve `key` for `fingerprint`, retaining any stored response for `ttlMs`. */
54
- begin(
55
- key: string,
56
- fingerprint: string,
57
- ttlMs: number,
58
- ): IdempotencyBeginResult | Promise<IdempotencyBeginResult>
59
- /** Persist the final response for a reserved key so later retries replay it. */
60
- complete(key: string, response: StoredResponse): void | Promise<void>
61
- /** Release a reservation that produced no cacheable response, so the client may retry. */
62
- abandon(key: string): void | Promise<void>
103
+ /**
104
+ * An honest durability marker. Omit/`memory` for process-local stores; a route declaring
105
+ * `scope: "durable"` rejects anything other than `durable` at registration.
106
+ */
107
+ readonly durability?: "memory" | "durable"
108
+ /** Reserve one namespaced key. Exactly one concurrent caller may receive `new`. */
109
+ begin(input: IdempotencyBeginInput): IdempotencyBeginResult | Promise<IdempotencyBeginResult>
110
+ /**
111
+ * Persist the final response only when `reservation` still owns the key. Returns false for a stale
112
+ * completion (for example after expiry + re-reservation), which prevents an older request from
113
+ * overwriting a newer result.
114
+ */
115
+ complete(input: IdempotencyCompletionInput): boolean | Promise<boolean>
116
+ /** Release only the pending reservation owned by `reservation`. */
117
+ abandon(input: IdempotencyAbandonInput): boolean | Promise<boolean>
63
118
  }
64
119
 
65
120
  /** A key must be a non-empty, bounded, control-char-free token. Fail closed on anything else. */
@@ -67,11 +122,16 @@ export function validIdempotencyKey(key: string): boolean {
67
122
  if (typeof key !== "string" || key.length === 0 || key.length > KEY_MAX_LENGTH) return false
68
123
  for (let i = 0; i < key.length; i++) {
69
124
  const code = key.charCodeAt(i)
70
- if (code <= 31 || code === 127) return false
125
+ if (code < 33 || code > 126) return false
71
126
  }
72
127
  return true
73
128
  }
74
129
 
130
+ /** Namespace values are server-resolved, bounded opaque tokens (normally a tenant/subject hash). */
131
+ export function validIdempotencyNamespace(namespace: string): boolean {
132
+ return validIdempotencyKey(namespace)
133
+ }
134
+
75
135
  const encoder = new TextEncoder()
76
136
 
77
137
  /**
@@ -82,8 +142,10 @@ export async function computeIdempotencyFingerprint(
82
142
  method: string,
83
143
  path: string,
84
144
  body: Uint8Array,
145
+ contentType = "",
85
146
  ): Promise<string> {
86
- const head = encoder.encode(`${method.toUpperCase()}\n${path}\n`)
147
+ const mediaType = contentType.split(";", 1)[0]?.trim().toLowerCase() ?? ""
148
+ const head = encoder.encode(`${method.toUpperCase()}\n${path}\n${mediaType}\n`)
87
149
  const material = new Uint8Array(head.length + body.length)
88
150
  material.set(head, 0)
89
151
  material.set(body, head.length)
@@ -94,6 +156,41 @@ export async function computeIdempotencyFingerprint(
94
156
  return hex
95
157
  }
96
158
 
159
+ function canonicalJson(value: unknown): string {
160
+ if (value === null || typeof value === "boolean" || typeof value === "string") {
161
+ return JSON.stringify(value)
162
+ }
163
+ if (typeof value === "number") {
164
+ if (!Number.isFinite(value)) throw new TypeError("non-finite JSON number")
165
+ return JSON.stringify(value)
166
+ }
167
+ if (Array.isArray(value)) return `[${value.map((item) => canonicalJson(item)).join(",")}]`
168
+ if (typeof value === "object") {
169
+ const record = value as Record<string, unknown>
170
+ const entries = Object.keys(record)
171
+ .sort()
172
+ .map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`)
173
+ return `{${entries.join(",")}}`
174
+ }
175
+ throw new TypeError("unsupported JSON value")
176
+ }
177
+
178
+ /** Canonicalize JSON bodies so whitespace/property-order retries bind to the same semantic request. */
179
+ export function canonicalizeIdempotencyBody(
180
+ body: Uint8Array,
181
+ contentType: string | null,
182
+ ): Uint8Array {
183
+ const mediaType = contentType?.split(";", 1)[0]?.trim().toLowerCase() ?? ""
184
+ if (mediaType !== "application/json" && !mediaType.endsWith("+json")) return body
185
+ try {
186
+ return encoder.encode(canonicalJson(JSON.parse(new TextDecoder().decode(body))))
187
+ } catch {
188
+ // Invalid JSON is rejected by the validation/body lane. Preserve its raw bytes for a stable
189
+ // fingerprint until that rejection releases the reservation.
190
+ return body
191
+ }
192
+ }
193
+
97
194
  const BASE64_CHUNK = 0x8000
98
195
 
99
196
  function bytesToBase64(bytes: Uint8Array): string {
@@ -111,19 +208,93 @@ function base64ToBytes(value: string): Uint8Array {
111
208
  return bytes
112
209
  }
113
210
 
211
+ function base64DecodedLength(value: string): number {
212
+ if (value === "") return 0
213
+ if (value.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(value)) {
214
+ throw new TypeError("idempotency: corrupt stored response body")
215
+ }
216
+ const padding = value.endsWith("==") ? 2 : value.endsWith("=") ? 1 : 0
217
+ return (value.length / 4) * 3 - padding
218
+ }
219
+
220
+ export class IdempotencyResponseTooLargeError extends Error {
221
+ constructor(public readonly maxBytes: number) {
222
+ super(`idempotency response exceeds the ${maxBytes}-byte storage bound`)
223
+ this.name = "IdempotencyResponseTooLargeError"
224
+ }
225
+ }
226
+
227
+ async function boundedResponseBytes(response: Response, maxBytes: number): Promise<Uint8Array> {
228
+ const contentLength = response.headers.get("content-length")
229
+ if (contentLength !== null && /^\d+$/.test(contentLength) && Number(contentLength) > maxBytes) {
230
+ throw new IdempotencyResponseTooLargeError(maxBytes)
231
+ }
232
+ const body = response.clone().body
233
+ if (body === null) return new Uint8Array()
234
+ const reader = body.getReader()
235
+ const chunks: Uint8Array[] = []
236
+ let total = 0
237
+ try {
238
+ for (;;) {
239
+ const { done, value } = await reader.read()
240
+ if (done) break
241
+ total += value.byteLength
242
+ if (total > maxBytes) {
243
+ await reader.cancel()
244
+ throw new IdempotencyResponseTooLargeError(maxBytes)
245
+ }
246
+ chunks.push(value)
247
+ }
248
+ } finally {
249
+ reader.releaseLock()
250
+ }
251
+ const bytes = new Uint8Array(total)
252
+ let offset = 0
253
+ for (const chunk of chunks) {
254
+ bytes.set(chunk, offset)
255
+ offset += chunk.byteLength
256
+ }
257
+ return bytes
258
+ }
259
+
114
260
  /** Buffer a response into a storable form. Clones first so the live response body stays intact. */
115
- export async function serializeResponse(response: Response): Promise<StoredResponse> {
116
- const bytes = new Uint8Array(await response.clone().arrayBuffer())
261
+ export async function serializeResponse(
262
+ response: Response,
263
+ options: { readonly maxBytes?: number } = {},
264
+ ): Promise<StoredResponse> {
265
+ const maxBytes = options.maxBytes
266
+ if (maxBytes !== undefined && (!Number.isInteger(maxBytes) || maxBytes < 0)) {
267
+ throw new RangeError("idempotency: max response bytes must be a non-negative integer")
268
+ }
269
+ const bytes =
270
+ maxBytes === undefined
271
+ ? new Uint8Array(await response.clone().arrayBuffer())
272
+ : await boundedResponseBytes(response, maxBytes)
117
273
  return {
118
274
  status: response.status,
119
- headers: [...response.headers] as readonly (readonly [string, string])[],
275
+ headers: replaySafeHeaders(response.headers),
120
276
  body: bytes.length > 0 ? bytesToBase64(bytes) : "",
121
277
  }
122
278
  }
123
279
 
124
280
  /** Rebuild a live response from storage, stamping the replay marker header. */
125
- export function responseFromStored(stored: StoredResponse): Response {
126
- const headers = new Headers(stored.headers as [string, string][])
281
+ export function responseFromStored(
282
+ stored: StoredResponse,
283
+ options: { readonly maxBytes?: number } = {},
284
+ ): Response {
285
+ if (!Number.isInteger(stored.status) || stored.status < 200 || stored.status > 599) {
286
+ throw new TypeError("idempotency: corrupt stored response status")
287
+ }
288
+ const decodedLength = base64DecodedLength(stored.body)
289
+ if (options.maxBytes !== undefined) {
290
+ if (!Number.isSafeInteger(options.maxBytes) || options.maxBytes < 0) {
291
+ throw new RangeError("idempotency: max response bytes must be a non-negative safe integer")
292
+ }
293
+ if (decodedLength > options.maxBytes) {
294
+ throw new IdempotencyResponseTooLargeError(options.maxBytes)
295
+ }
296
+ }
297
+ const headers = new Headers(replaySafeHeaders(stored.headers) as [string, string][])
127
298
  headers.set(IDEMPOTENT_REPLAY_HEADER, "1")
128
299
  // Reference the Response body type structurally: `BodyInit` isn't a named type under every lib config,
129
300
  // but a fresh `Uint8Array` is a valid body — the cast bridges the `ArrayBufferLike` generic variance.
@@ -135,13 +306,17 @@ export function responseFromStored(stored: StoredResponse): Response {
135
306
 
136
307
  interface MemoryEntry {
137
308
  readonly fingerprint: string
138
- readonly expiresAt: number
309
+ readonly reservation: string
310
+ readonly ttlMs: number
311
+ expiresAt: number
139
312
  response: StoredResponse | undefined
140
313
  }
141
314
 
142
315
  export interface MemoryIdempotencyStoreOptions {
143
316
  /** Injectable clock (epoch ms) for deterministic TTL tests. Default `Date.now`. */
144
317
  readonly now?: () => number
318
+ /** Hard memory bound. At capacity new keys fail closed; completed/pending entries are never evicted early. */
319
+ readonly maxEntries?: number
145
320
  }
146
321
 
147
322
  /**
@@ -150,37 +325,73 @@ export interface MemoryIdempotencyStoreOptions {
150
325
  * as absent (lazy eviction on access); a periodic {@link MemoryIdempotencyStore.sweep} bounds memory.
151
326
  */
152
327
  export class MemoryIdempotencyStore implements IdempotencyStore {
328
+ readonly durability = "memory" as const
153
329
  private readonly entries = new Map<string, MemoryEntry>()
154
330
  private readonly now: () => number
331
+ private readonly maxEntries: number
155
332
 
156
333
  constructor(options: MemoryIdempotencyStoreOptions = {}) {
157
334
  this.now = options.now ?? Date.now
335
+ this.maxEntries = options.maxEntries ?? 10_000
336
+ if (!Number.isInteger(this.maxEntries) || this.maxEntries < 1) {
337
+ throw new RangeError("idempotency: maxEntries must be a positive integer")
338
+ }
158
339
  }
159
340
 
160
- begin(key: string, fingerprint: string, ttlMs: number): IdempotencyBeginResult {
161
- const existing = this.entries.get(key)
341
+ begin(input: IdempotencyBeginInput): IdempotencyBeginResult {
342
+ const storageKey = this.storageKey(input)
343
+ const existing = this.entries.get(storageKey)
162
344
  if (existing !== undefined && existing.expiresAt > this.now()) {
163
- if (existing.fingerprint !== fingerprint) return { state: "mismatch" }
345
+ if (existing.fingerprint !== input.fingerprint) return { state: "mismatch" }
164
346
  if (existing.response !== undefined) return { state: "replay", response: existing.response }
165
347
  return { state: "in-flight" }
166
348
  }
167
- this.entries.set(key, {
168
- fingerprint,
169
- expiresAt: this.now() + ttlMs,
349
+ if (existing !== undefined) this.entries.delete(storageKey)
350
+ if (this.entries.size >= this.maxEntries) {
351
+ this.sweep()
352
+ if (this.entries.size >= this.maxEntries) return { state: "capacity" }
353
+ }
354
+ const reservation = crypto.randomUUID()
355
+ this.entries.set(storageKey, {
356
+ fingerprint: input.fingerprint,
357
+ reservation,
358
+ ttlMs: input.ttlMs,
359
+ expiresAt: this.now() + input.ttlMs,
170
360
  response: undefined,
171
361
  })
172
- return { state: "new" }
362
+ return { state: "new", reservation }
173
363
  }
174
364
 
175
- complete(key: string, response: StoredResponse): void {
365
+ complete(input: IdempotencyCompletionInput): boolean {
366
+ const key = this.storageKey(input)
176
367
  const entry = this.entries.get(key)
177
- if (entry !== undefined) entry.response = response
368
+ if (entry === undefined || entry.reservation !== input.reservation) return false
369
+ if (entry.expiresAt <= this.now()) {
370
+ this.entries.delete(key)
371
+ return false
372
+ }
373
+ entry.response = input.response
374
+ entry.expiresAt = this.now() + entry.ttlMs
375
+ return true
178
376
  }
179
377
 
180
- abandon(key: string): void {
378
+ abandon(input: IdempotencyAbandonInput): boolean {
379
+ const key = this.storageKey(input)
181
380
  const entry = this.entries.get(key)
381
+ if (entry !== undefined && entry.expiresAt <= this.now()) {
382
+ this.entries.delete(key)
383
+ return false
384
+ }
182
385
  // Only drop a still-pending reservation; never evict a completed (replayable) response.
183
- if (entry !== undefined && entry.response === undefined) this.entries.delete(key)
386
+ if (
387
+ entry !== undefined &&
388
+ entry.response === undefined &&
389
+ entry.reservation === input.reservation
390
+ ) {
391
+ this.entries.delete(key)
392
+ return true
393
+ }
394
+ return false
184
395
  }
185
396
 
186
397
  /** Evict expired entries. Callers may run this on an interval; access-time eviction covers the rest. */
@@ -195,6 +406,10 @@ export class MemoryIdempotencyStore implements IdempotencyStore {
195
406
  get size(): number {
196
407
  return this.entries.size
197
408
  }
409
+
410
+ private storageKey(input: IdempotencyEntryKey): string {
411
+ return `${input.namespace.length}:${input.namespace}${input.key}`
412
+ }
198
413
  }
199
414
 
200
415
  /** Convenience factory mirroring the other core primitives' `create*` style. */
package/src/index.ts CHANGED
@@ -12,7 +12,7 @@
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.10.0" as const
15
+ export const VERSION = "1.11.0" as const
16
16
 
17
17
  export type Version = typeof VERSION
18
18
 
@@ -23,6 +23,10 @@ export * from "./diff.ts"
23
23
  export { FrameworkError, RouteConfigError, type RouteConfigErrorCode } from "./errors.ts"
24
24
  export * from "./idempotency.ts"
25
25
  export { FRAMEWORK_NAME, type FrameworkName } from "./internal/brand.ts"
26
+ export * from "./invariants.ts"
27
+ export * from "./ledger.ts"
28
+ export * from "./manifest.ts"
29
+ export * from "./mount.ts"
26
30
  export * from "./reflection.ts"
27
31
  export { METHODS, type Method, Router, type RouterMatch } from "./router/router.ts"
28
32
  export type {