@nifrajs/core 1.10.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/assurance.d.ts +13 -0
- package/dist/assurance.d.ts.map +1 -1
- package/dist/assurance.js +20 -0
- package/dist/assurance.js.map +1 -1
- package/dist/capabilities.d.ts +30 -1
- package/dist/capabilities.d.ts.map +1 -1
- package/dist/capabilities.js +32 -6
- package/dist/capabilities.js.map +1 -1
- 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/classification.d.ts +20 -0
- package/dist/classification.d.ts.map +1 -1
- package/dist/classification.js +120 -0
- package/dist/classification.js.map +1 -1
- package/dist/errors.d.ts +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/idempotency.d.ts +59 -12
- package/dist/idempotency.d.ts.map +1 -1
- package/dist/idempotency.js +191 -20
- package/dist/idempotency.js.map +1 -1
- package/dist/index.d.ts +6 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -1
- package/dist/index.js.map +1 -1
- package/dist/invariants.d.ts +70 -0
- package/dist/invariants.d.ts.map +1 -0
- package/dist/invariants.js +487 -0
- package/dist/invariants.js.map +1 -0
- package/dist/ledger.d.ts +154 -0
- package/dist/ledger.d.ts.map +1 -0
- package/dist/ledger.js +271 -0
- package/dist/ledger.js.map +1 -0
- package/dist/manifest.d.ts +81 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/manifest.js +394 -0
- package/dist/manifest.js.map +1 -0
- package/dist/mount.d.ts +18 -0
- package/dist/mount.d.ts.map +1 -0
- package/dist/mount.js +11 -0
- package/dist/mount.js.map +1 -0
- package/dist/reflection.d.ts +3 -3
- package/dist/reflection.d.ts.map +1 -1
- package/dist/reflection.js +5 -5
- package/dist/reflection.js.map +1 -1
- package/dist/server/context.d.ts +17 -1
- package/dist/server/context.d.ts.map +1 -1
- package/dist/server/server.d.ts +29 -2
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +189 -26
- package/dist/server/server.js.map +1 -1
- package/package.json +31 -1
- package/src/assurance.ts +33 -0
- package/src/capabilities.ts +60 -4
- package/src/causality.ts +550 -0
- package/src/classification.ts +145 -0
- package/src/errors.ts +1 -0
- package/src/idempotency.ts +245 -30
- package/src/index.ts +6 -1
- package/src/invariants.ts +604 -0
- package/src/ledger.ts +430 -0
- package/src/manifest.ts +543 -0
- package/src/mount.ts +24 -0
- package/src/reflection.ts +11 -7
- package/src/server/context.ts +20 -1
- package/src/server/server.ts +268 -31
package/src/errors.ts
CHANGED
package/src/idempotency.ts
CHANGED
|
@@ -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
|
-
/**
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
|
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
|
|
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(
|
|
116
|
-
|
|
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:
|
|
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(
|
|
126
|
-
|
|
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
|
|
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(
|
|
161
|
-
const
|
|
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.
|
|
168
|
-
|
|
169
|
-
|
|
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(
|
|
365
|
+
complete(input: IdempotencyCompletionInput): boolean {
|
|
366
|
+
const key = this.storageKey(input)
|
|
176
367
|
const entry = this.entries.get(key)
|
|
177
|
-
if (entry
|
|
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(
|
|
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 (
|
|
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,17 +12,22 @@
|
|
|
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"
|
|
24
25
|
export * from "./idempotency.ts"
|
|
25
26
|
export { FRAMEWORK_NAME, type FrameworkName } from "./internal/brand.ts"
|
|
27
|
+
export * from "./invariants.ts"
|
|
28
|
+
export * from "./ledger.ts"
|
|
29
|
+
export * from "./manifest.ts"
|
|
30
|
+
export * from "./mount.ts"
|
|
26
31
|
export * from "./reflection.ts"
|
|
27
32
|
export { METHODS, type Method, Router, type RouterMatch } from "./router/router.ts"
|
|
28
33
|
export type {
|