@nifrajs/core 2.9.1 → 2.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.
- package/README.md +17 -0
- package/dist/channel.d.ts +52 -0
- package/dist/channel.d.ts.map +1 -0
- package/dist/channel.js +231 -0
- package/dist/channel.js.map +1 -0
- package/dist/data.d.ts +117 -0
- package/dist/data.d.ts.map +1 -0
- package/dist/data.js +163 -0
- package/dist/data.js.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/range.d.ts +34 -0
- package/dist/range.d.ts.map +1 -0
- package/dist/range.js +91 -0
- package/dist/range.js.map +1 -0
- package/dist/server/platform-adapters.d.ts +80 -0
- package/dist/server/platform-adapters.d.ts.map +1 -0
- package/dist/server/platform-adapters.js +172 -0
- package/dist/server/platform-adapters.js.map +1 -0
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +5 -1
- package/dist/server/server.js.map +1 -1
- package/dist/server.d.ts +1 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +1 -0
- package/dist/server.js.map +1 -1
- package/package.json +16 -1
- package/src/channel.ts +299 -0
- package/src/data.ts +292 -0
- package/src/index.ts +1 -1
- package/src/range.ts +94 -0
- package/src/server/platform-adapters.ts +255 -0
- package/src/server/server.ts +4 -1
- package/src/server.ts +15 -0
package/src/channel.ts
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/** Typed, runtime-neutral channel contracts. Durable replay, presence, fan-out, and tenant-aware
|
|
2
|
+
* routing belong in an adapter; the bundled hub provides only bounded process-local replay for tests
|
|
3
|
+
* and local development. */
|
|
4
|
+
|
|
5
|
+
export interface ChannelContract<Name extends string, Message> {
|
|
6
|
+
readonly name: Name
|
|
7
|
+
/** Type-only message witness; adapters never materialize it. */
|
|
8
|
+
readonly __message?: (value: Message) => Message
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ChannelEvent<Message> {
|
|
12
|
+
/** Monotonic within one channel; adapters may use a different opaque cursor representation. */
|
|
13
|
+
readonly sequence: number
|
|
14
|
+
readonly resumeToken: string
|
|
15
|
+
readonly data: Message
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type ChannelCloseReason = "closed" | "backpressure" | "aborted"
|
|
19
|
+
|
|
20
|
+
export interface ChannelSubscription<Message> extends AsyncIterableIterator<ChannelEvent<Message>> {
|
|
21
|
+
readonly channel: string
|
|
22
|
+
readonly closed: boolean
|
|
23
|
+
readonly closeReason: ChannelCloseReason | undefined
|
|
24
|
+
readonly resumeToken: string | undefined
|
|
25
|
+
close(): void
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ChannelSubscribeOptions {
|
|
29
|
+
/** Maximum undelivered events retained for this subscriber. Default 64. */
|
|
30
|
+
readonly maxQueue?: number
|
|
31
|
+
/** Resume after this opaque cursor. The adapter must fail closed when the cursor is unavailable. */
|
|
32
|
+
readonly resumeFrom?: string
|
|
33
|
+
readonly signal?: AbortSignal
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ChannelHub {
|
|
37
|
+
subscribe<Name extends string, Message>(
|
|
38
|
+
channel: ChannelContract<Name, Message>,
|
|
39
|
+
options?: ChannelSubscribeOptions,
|
|
40
|
+
): ChannelSubscription<Message>
|
|
41
|
+
publish<Name extends string, Message>(
|
|
42
|
+
channel: ChannelContract<Name, Message>,
|
|
43
|
+
data: Message,
|
|
44
|
+
): ChannelEvent<Message>
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface MemoryChannelHubOptions {
|
|
48
|
+
/** Default queue bound per subscriber. Default 64. */
|
|
49
|
+
readonly maxQueue?: number
|
|
50
|
+
/** Maximum events retained per channel for local cursor replay. Default: the queue bound. */
|
|
51
|
+
readonly historySize?: number
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A requested cursor is malformed, belongs to another channel, or fell outside retained history. */
|
|
55
|
+
export class ChannelResumeUnavailableError extends Error {
|
|
56
|
+
readonly channel: string
|
|
57
|
+
readonly resumeToken: string
|
|
58
|
+
|
|
59
|
+
constructor(channel: string, resumeToken: string) {
|
|
60
|
+
super("channel: resume cursor is unavailable")
|
|
61
|
+
this.name = "ChannelResumeUnavailableError"
|
|
62
|
+
this.channel = channel
|
|
63
|
+
this.resumeToken = resumeToken
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function validName(name: string): boolean {
|
|
68
|
+
return /^[A-Za-z][A-Za-z0-9._:/-]{0,127}$/.test(name)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Define a typed channel name. Message types are erased; adapters own validation at their boundary. */
|
|
72
|
+
export function defineChannel<Name extends string, Message>(
|
|
73
|
+
name: Name,
|
|
74
|
+
): ChannelContract<Name, Message> {
|
|
75
|
+
if (!validName(name)) throw new TypeError("channel: name must be a bounded token")
|
|
76
|
+
return Object.freeze({ name })
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
type Waiter<Message> = (result: IteratorResult<ChannelEvent<Message>>) => void
|
|
80
|
+
|
|
81
|
+
class MemorySubscription<Message> implements ChannelSubscription<Message> {
|
|
82
|
+
readonly channel: string
|
|
83
|
+
private readonly maxQueue: number
|
|
84
|
+
private readonly remove: () => void
|
|
85
|
+
private readonly queue: ChannelEvent<Message>[] = []
|
|
86
|
+
private readonly waiters: Waiter<Message>[] = []
|
|
87
|
+
private isClosed = false
|
|
88
|
+
private reason: ChannelCloseReason | undefined
|
|
89
|
+
private lastToken: string | undefined
|
|
90
|
+
private abortSignal: AbortSignal | undefined
|
|
91
|
+
private abortHandler: (() => void) | undefined
|
|
92
|
+
|
|
93
|
+
constructor(channel: string, maxQueue: number, remove: () => void) {
|
|
94
|
+
this.channel = channel
|
|
95
|
+
this.maxQueue = maxQueue
|
|
96
|
+
this.remove = remove
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
seed(events: readonly ChannelEvent<Message>[]): void {
|
|
100
|
+
if (events.length > this.maxQueue) {
|
|
101
|
+
throw new RangeError("channel: resume history exceeds subscriber queue bound")
|
|
102
|
+
}
|
|
103
|
+
this.queue.push(...events)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
attachSignal(signal: AbortSignal | undefined): void {
|
|
107
|
+
if (signal === undefined || this.isClosed) return
|
|
108
|
+
const handler = (): void => this.close("aborted")
|
|
109
|
+
this.abortSignal = signal
|
|
110
|
+
this.abortHandler = handler
|
|
111
|
+
signal.addEventListener("abort", handler, { once: true })
|
|
112
|
+
// Check after registration as well: an abort can happen between the initial caller check and
|
|
113
|
+
// listener registration in host implementations. `close()` removes the listener again.
|
|
114
|
+
if (signal.aborted) this.close("aborted")
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
get closed(): boolean {
|
|
118
|
+
return this.isClosed
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
get closeReason(): ChannelCloseReason | undefined {
|
|
122
|
+
return this.reason
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
get resumeToken(): string | undefined {
|
|
126
|
+
return this.lastToken
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
push(event: ChannelEvent<Message>): void {
|
|
130
|
+
if (this.isClosed) return
|
|
131
|
+
const waiter = this.waiters.shift()
|
|
132
|
+
if (waiter !== undefined) {
|
|
133
|
+
this.lastToken = event.resumeToken
|
|
134
|
+
waiter({ done: false, value: event })
|
|
135
|
+
return
|
|
136
|
+
}
|
|
137
|
+
if (this.queue.length >= this.maxQueue) {
|
|
138
|
+
this.close("backpressure")
|
|
139
|
+
return
|
|
140
|
+
}
|
|
141
|
+
this.queue.push(event)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
next(): Promise<IteratorResult<ChannelEvent<Message>>> {
|
|
145
|
+
const event = this.queue.shift()
|
|
146
|
+
if (event !== undefined) {
|
|
147
|
+
this.lastToken = event.resumeToken
|
|
148
|
+
return Promise.resolve({ done: false, value: event })
|
|
149
|
+
}
|
|
150
|
+
if (this.isClosed) return Promise.resolve({ done: true, value: undefined })
|
|
151
|
+
return new Promise((resolve) => this.waiters.push(resolve))
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return(): Promise<IteratorResult<ChannelEvent<Message>>> {
|
|
155
|
+
this.close("closed")
|
|
156
|
+
return Promise.resolve({ done: true, value: undefined })
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
close(reason: ChannelCloseReason = "closed"): void {
|
|
160
|
+
if (this.isClosed) return
|
|
161
|
+
this.isClosed = true
|
|
162
|
+
this.reason = reason
|
|
163
|
+
this.queue.length = 0
|
|
164
|
+
if (this.abortSignal !== undefined && this.abortHandler !== undefined) {
|
|
165
|
+
this.abortSignal.removeEventListener("abort", this.abortHandler)
|
|
166
|
+
this.abortSignal = undefined
|
|
167
|
+
this.abortHandler = undefined
|
|
168
|
+
}
|
|
169
|
+
this.remove()
|
|
170
|
+
for (const waiter of this.waiters.splice(0)) waiter({ done: true, value: undefined })
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
[Symbol.asyncIterator](): AsyncIterableIterator<ChannelEvent<Message>> {
|
|
174
|
+
return this
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** A bounded in-memory hub suitable for tests and local development, never for durable fan-out. */
|
|
179
|
+
export function memoryChannelHub(options: MemoryChannelHubOptions = {}): ChannelHub {
|
|
180
|
+
const maxQueue = options.maxQueue ?? 64
|
|
181
|
+
if (!Number.isSafeInteger(maxQueue) || maxQueue < 1) {
|
|
182
|
+
throw new RangeError("channel: maxQueue must be a positive safe integer")
|
|
183
|
+
}
|
|
184
|
+
const historySize = options.historySize ?? maxQueue
|
|
185
|
+
if (!Number.isSafeInteger(historySize) || historySize < 0) {
|
|
186
|
+
throw new RangeError("channel: historySize must be a non-negative safe integer")
|
|
187
|
+
}
|
|
188
|
+
const subscribers = new Map<string, Set<MemorySubscription<unknown>>>()
|
|
189
|
+
const sequences = new Map<string, number>()
|
|
190
|
+
const history = new Map<string, ChannelEvent<unknown>[]>()
|
|
191
|
+
|
|
192
|
+
const tokenFor = (channel: string, sequence: number): string =>
|
|
193
|
+
`${channel}:${sequence.toString(36)}`
|
|
194
|
+
|
|
195
|
+
const sequenceOf = (channel: string, token: string): number | undefined => {
|
|
196
|
+
const prefix = `${channel}:`
|
|
197
|
+
if (!token.startsWith(prefix)) return undefined
|
|
198
|
+
const digits = token.slice(prefix.length)
|
|
199
|
+
if (!/^[0-9a-z]+$/.test(digits)) return undefined
|
|
200
|
+
const sequence = Number.parseInt(digits, 36)
|
|
201
|
+
return Number.isSafeInteger(sequence) && sequence > 0 ? sequence : undefined
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
subscribe<Name extends string, Message>(
|
|
206
|
+
channel: ChannelContract<Name, Message>,
|
|
207
|
+
subscriptionOptions: ChannelSubscribeOptions = {},
|
|
208
|
+
): ChannelSubscription<Message> {
|
|
209
|
+
const queue = subscriptionOptions.maxQueue ?? maxQueue
|
|
210
|
+
if (!Number.isSafeInteger(queue) || queue < 1) {
|
|
211
|
+
throw new RangeError("channel: maxQueue must be a positive safe integer")
|
|
212
|
+
}
|
|
213
|
+
const currentSequence = sequences.get(channel.name) ?? 0
|
|
214
|
+
const resumeFrom = subscriptionOptions.resumeFrom
|
|
215
|
+
let replay: readonly ChannelEvent<unknown>[] = []
|
|
216
|
+
if (resumeFrom !== undefined) {
|
|
217
|
+
const requestedSequence = sequenceOf(channel.name, resumeFrom)
|
|
218
|
+
const channelHistory = history.get(channel.name) ?? []
|
|
219
|
+
const oldest = channelHistory[0]?.sequence
|
|
220
|
+
const known =
|
|
221
|
+
requestedSequence !== undefined &&
|
|
222
|
+
requestedSequence <= currentSequence &&
|
|
223
|
+
(requestedSequence === currentSequence ||
|
|
224
|
+
channelHistory.some((event) => event.sequence === requestedSequence))
|
|
225
|
+
if (!known) throw new ChannelResumeUnavailableError(channel.name, resumeFrom)
|
|
226
|
+
replay = channelHistory.filter((event) => event.sequence > requestedSequence)
|
|
227
|
+
// Keep this explicit so a future history representation cannot silently truncate a resume.
|
|
228
|
+
if (oldest !== undefined && requestedSequence < oldest && replay.length === 0) {
|
|
229
|
+
throw new ChannelResumeUnavailableError(channel.name, resumeFrom)
|
|
230
|
+
}
|
|
231
|
+
if (replay.length > queue) {
|
|
232
|
+
throw new ChannelResumeUnavailableError(channel.name, resumeFrom)
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
let members = subscribers.get(channel.name)
|
|
236
|
+
if (members === undefined) {
|
|
237
|
+
members = new Set()
|
|
238
|
+
subscribers.set(channel.name, members)
|
|
239
|
+
}
|
|
240
|
+
let subscription: MemorySubscription<unknown>
|
|
241
|
+
const remove = (): void => {
|
|
242
|
+
members?.delete(subscription)
|
|
243
|
+
if (members?.size === 0) subscribers.delete(channel.name)
|
|
244
|
+
}
|
|
245
|
+
subscription = new MemorySubscription(channel.name, queue, remove)
|
|
246
|
+
subscription.seed(replay)
|
|
247
|
+
members.add(subscription)
|
|
248
|
+
subscription.attachSignal(subscriptionOptions.signal)
|
|
249
|
+
return subscription as unknown as ChannelSubscription<Message>
|
|
250
|
+
},
|
|
251
|
+
publish<Name extends string, Message>(
|
|
252
|
+
channel: ChannelContract<Name, Message>,
|
|
253
|
+
data: Message,
|
|
254
|
+
): ChannelEvent<Message> {
|
|
255
|
+
const previous = sequences.get(channel.name) ?? 0
|
|
256
|
+
if (previous === Number.MAX_SAFE_INTEGER) {
|
|
257
|
+
throw new RangeError("channel: sequence space exhausted")
|
|
258
|
+
}
|
|
259
|
+
const sequence = previous + 1
|
|
260
|
+
sequences.set(channel.name, sequence)
|
|
261
|
+
const event: ChannelEvent<Message> = {
|
|
262
|
+
sequence,
|
|
263
|
+
resumeToken: tokenFor(channel.name, sequence),
|
|
264
|
+
data,
|
|
265
|
+
}
|
|
266
|
+
if (historySize > 0) {
|
|
267
|
+
const channelHistory = history.get(channel.name) ?? []
|
|
268
|
+
channelHistory.push(event as unknown as ChannelEvent<unknown>)
|
|
269
|
+
if (channelHistory.length > historySize) channelHistory.shift()
|
|
270
|
+
history.set(channel.name, channelHistory)
|
|
271
|
+
}
|
|
272
|
+
for (const subscriber of subscribers.get(channel.name) ?? []) {
|
|
273
|
+
subscriber.push(event as unknown as ChannelEvent<unknown>)
|
|
274
|
+
}
|
|
275
|
+
return event
|
|
276
|
+
},
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Adapt a channel subscription to a cancellable Web stream for SSE/HTTP adapters. */
|
|
281
|
+
export function channelReadableStream<Message>(
|
|
282
|
+
subscription: ChannelSubscription<Message>,
|
|
283
|
+
): ReadableStream<ChannelEvent<Message>> {
|
|
284
|
+
const iterator = subscription[Symbol.asyncIterator]()
|
|
285
|
+
return new ReadableStream<ChannelEvent<Message>>({
|
|
286
|
+
async pull(controller) {
|
|
287
|
+
try {
|
|
288
|
+
const result = await iterator.next()
|
|
289
|
+
if (result.done) controller.close()
|
|
290
|
+
else controller.enqueue(result.value)
|
|
291
|
+
} catch (error) {
|
|
292
|
+
controller.error(error)
|
|
293
|
+
}
|
|
294
|
+
},
|
|
295
|
+
cancel() {
|
|
296
|
+
subscription.close()
|
|
297
|
+
},
|
|
298
|
+
})
|
|
299
|
+
}
|
package/src/data.ts
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/** Public, token-only data access contracts. Durable adapters and RLS policy enforcement stay outside
|
|
2
|
+
* this package; this subpath defines the typed seam they implement. */
|
|
3
|
+
|
|
4
|
+
export const DATA_CAPABILITIES = Object.freeze({
|
|
5
|
+
read: "db.read",
|
|
6
|
+
write: "db.write",
|
|
7
|
+
} as const)
|
|
8
|
+
|
|
9
|
+
export type DataAccess = keyof typeof DATA_CAPABILITIES
|
|
10
|
+
export type DataCapability = (typeof DATA_CAPABILITIES)[DataAccess]
|
|
11
|
+
|
|
12
|
+
export interface DataOperationSpec {
|
|
13
|
+
readonly access: DataAccess
|
|
14
|
+
readonly input?: unknown
|
|
15
|
+
readonly output?: unknown
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type DataOperationMap = Readonly<Record<string, DataOperationSpec>>
|
|
19
|
+
|
|
20
|
+
export interface DataContract<Operations extends DataOperationMap = DataOperationMap> {
|
|
21
|
+
readonly version: string
|
|
22
|
+
readonly operations: Operations
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Opaque request-local RLS (or equivalent data-policy) scope passed to an adapter. */
|
|
26
|
+
export interface RlsScope {
|
|
27
|
+
/** Caller-owned scope token. The public seam never interprets, logs, or derives identity from it. */
|
|
28
|
+
readonly token: string
|
|
29
|
+
/** Optional opaque digest for correlating a scope without exposing tenant or subject data. */
|
|
30
|
+
readonly digest?: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Backward-compatible name for the request scope carried by every data operation. */
|
|
34
|
+
export type DataScope = RlsScope
|
|
35
|
+
|
|
36
|
+
export interface DataRequest<Operation extends string = string, Input = unknown> {
|
|
37
|
+
readonly operation: Operation
|
|
38
|
+
readonly scope: DataScope
|
|
39
|
+
readonly capability: DataCapability
|
|
40
|
+
readonly input: Input
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface DataDrift {
|
|
44
|
+
readonly kind: "version" | "added" | "removed" | "access-changed"
|
|
45
|
+
readonly operation?: string
|
|
46
|
+
readonly declaredAccess?: DataAccess
|
|
47
|
+
readonly observedAccess?: DataAccess
|
|
48
|
+
readonly declaredVersion?: string
|
|
49
|
+
readonly observedVersion?: string
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
type InputOf<Operation> = Operation extends { readonly input: infer Input } ? Input : never
|
|
53
|
+
type OutputOf<Operation> = Operation extends { readonly output: infer Output } ? Output : unknown
|
|
54
|
+
type CapabilityOf<Operation> = Operation extends { readonly access: "read" }
|
|
55
|
+
? typeof DATA_CAPABILITIES.read
|
|
56
|
+
: Operation extends { readonly access: "write" }
|
|
57
|
+
? typeof DATA_CAPABILITIES.write
|
|
58
|
+
: DataCapability
|
|
59
|
+
type InputField<Input> = [Input] extends [never]
|
|
60
|
+
? { readonly input?: never }
|
|
61
|
+
: { readonly input: Input }
|
|
62
|
+
|
|
63
|
+
export type DataInput<
|
|
64
|
+
Contract extends DataContract,
|
|
65
|
+
Operation extends keyof Contract["operations"] & string,
|
|
66
|
+
> = Contract["operations"][Operation] extends infer Definition ? InputOf<Definition> : never
|
|
67
|
+
|
|
68
|
+
export type DataOutput<
|
|
69
|
+
Contract extends DataContract,
|
|
70
|
+
Operation extends keyof Contract["operations"] & string,
|
|
71
|
+
> = Contract["operations"][Operation] extends infer Definition ? OutputOf<Definition> : unknown
|
|
72
|
+
|
|
73
|
+
export type DataRequestFor<
|
|
74
|
+
Contract extends DataContract,
|
|
75
|
+
Operation extends keyof Contract["operations"] & string,
|
|
76
|
+
> = {
|
|
77
|
+
readonly operation: Operation
|
|
78
|
+
readonly scope: DataScope
|
|
79
|
+
readonly capability: CapabilityOf<Contract["operations"][Operation]>
|
|
80
|
+
} & InputField<DataInput<Contract, Operation>>
|
|
81
|
+
|
|
82
|
+
export interface TypedDataPort<Contract extends DataContract> {
|
|
83
|
+
execute<Operation extends keyof Contract["operations"] & string>(
|
|
84
|
+
request: DataRequestFor<Contract, Operation>,
|
|
85
|
+
): Promise<DataOutput<Contract, Operation>>
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Runtime capability beacon used by a request-bound data port. */
|
|
89
|
+
export type DataCapabilityBeacon = (context: object, capability: DataCapability) => void
|
|
90
|
+
|
|
91
|
+
/** Options for the public request-bound data port wrapper. */
|
|
92
|
+
export interface DataPortOptions {
|
|
93
|
+
/** Usually `useCapability` from `@nifrajs/core/capabilities`. */
|
|
94
|
+
readonly beacon?: DataCapabilityBeacon
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** A data port bound to one request context; every operation emits capability evidence first. */
|
|
98
|
+
export interface BoundDataPort<Contract extends DataContract> extends TypedDataPort<Contract> {}
|
|
99
|
+
|
|
100
|
+
/** Public factory for wiring a private data adapter into Nifra's capability evidence chain. */
|
|
101
|
+
export interface DataPort<Contract extends DataContract> {
|
|
102
|
+
for(context: object): BoundDataPort<Contract>
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Bind a private data adapter to request-local capability evidence.
|
|
107
|
+
*
|
|
108
|
+
* The adapter remains responsible for RLS enforcement and durable storage. This wrapper owns the
|
|
109
|
+
* framework-side proof boundary: it emits exactly the operation's typed `db.read`/`db.write` token
|
|
110
|
+
* before invoking the adapter, and refuses to run when no beacon is configured or the beacon denies
|
|
111
|
+
* the operation. The context is never copied into the request or retained by the port.
|
|
112
|
+
*
|
|
113
|
+
* The emitted token is derived from the **contract**, never from `request.capability`. The typed
|
|
114
|
+
* request narrows that field, but types are erased before this line runs, and a request assembled
|
|
115
|
+
* from decoded input would otherwise let a write operation announce itself as `db.read` - evidence
|
|
116
|
+
* that names the wrong capability is worse than no evidence, because the ledger looks satisfied. An
|
|
117
|
+
* unknown operation, or a declared capability that disagrees with the contract, fails closed.
|
|
118
|
+
*/
|
|
119
|
+
export function createDataPort<Contract extends DataContract>(
|
|
120
|
+
contract: Contract,
|
|
121
|
+
adapter: TypedDataPort<Contract>,
|
|
122
|
+
options: DataPortOptions = {},
|
|
123
|
+
): DataPort<Contract> {
|
|
124
|
+
if (contract === null || typeof contract !== "object" || contract.operations === undefined) {
|
|
125
|
+
throw new TypeError("data: contract must be a defineDataContract result")
|
|
126
|
+
}
|
|
127
|
+
if (adapter === null || typeof adapter !== "object" || typeof adapter.execute !== "function") {
|
|
128
|
+
throw new TypeError("data: adapter must implement execute(request)")
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function bind(context: object): BoundDataPort<Contract> {
|
|
132
|
+
if (context === null || typeof context !== "object") {
|
|
133
|
+
throw new TypeError("data: context must be an object")
|
|
134
|
+
}
|
|
135
|
+
const beacon = options.beacon
|
|
136
|
+
if (beacon === undefined) {
|
|
137
|
+
throw new Error(
|
|
138
|
+
"@nifrajs/core/data: for(context) needs a beacon - pass `beacon: useCapability` to createDataPort",
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
return Object.freeze({
|
|
142
|
+
execute<Operation extends keyof Contract["operations"] & string>(
|
|
143
|
+
request: DataRequestFor<Contract, Operation>,
|
|
144
|
+
): Promise<DataOutput<Contract, Operation>> {
|
|
145
|
+
const declared = contract.operations[request.operation]
|
|
146
|
+
if (declared === undefined || !isDataAccess(declared.access)) {
|
|
147
|
+
return Promise.reject(
|
|
148
|
+
new TypeError(`data: ${String(request.operation)} is not declared by this contract`),
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
const capability = DATA_CAPABILITIES[declared.access]
|
|
152
|
+
if (request.capability !== capability) {
|
|
153
|
+
return Promise.reject(
|
|
154
|
+
new TypeError(`data: ${String(request.operation)} requires ${capability}`),
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
try {
|
|
158
|
+
beacon(context, capability)
|
|
159
|
+
} catch (error) {
|
|
160
|
+
return Promise.reject(error)
|
|
161
|
+
}
|
|
162
|
+
return adapter.execute(request)
|
|
163
|
+
},
|
|
164
|
+
})
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return Object.freeze({ for: bind })
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function assertToken(value: string, label: string): string {
|
|
171
|
+
if (
|
|
172
|
+
typeof value !== "string" ||
|
|
173
|
+
value.length === 0 ||
|
|
174
|
+
value.length > 256 ||
|
|
175
|
+
hasControlCharacter(value)
|
|
176
|
+
) {
|
|
177
|
+
throw new TypeError(`data: ${label} must be a bounded opaque token`)
|
|
178
|
+
}
|
|
179
|
+
return value
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function hasControlCharacter(value: string): boolean {
|
|
183
|
+
for (let index = 0; index < value.length; index++) {
|
|
184
|
+
const code = value.charCodeAt(index)
|
|
185
|
+
if (code <= 31 || code === 127) return true
|
|
186
|
+
}
|
|
187
|
+
return false
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Create a bounded, opaque RLS/equivalent policy scope for a data adapter. */
|
|
191
|
+
export function rlsScope(token: string, digest?: string): RlsScope {
|
|
192
|
+
const scope = {
|
|
193
|
+
token: assertToken(token, "scope token"),
|
|
194
|
+
...(digest === undefined ? {} : { digest: assertToken(digest, "scope digest") }),
|
|
195
|
+
}
|
|
196
|
+
return Object.freeze(scope)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Backward-compatible constructor for {@link RlsScope}. */
|
|
200
|
+
export function dataScope(token: string, digest?: string): DataScope {
|
|
201
|
+
return rlsScope(token, digest)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function isDataAccess(value: unknown): value is DataAccess {
|
|
205
|
+
return value === "read" || value === "write"
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function validVersion(value: unknown): value is string {
|
|
209
|
+
return (
|
|
210
|
+
typeof value === "string" &&
|
|
211
|
+
value.length > 0 &&
|
|
212
|
+
value.length <= 128 &&
|
|
213
|
+
!hasControlCharacter(value)
|
|
214
|
+
)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function validOperationName(value: string): boolean {
|
|
218
|
+
return /^[A-Za-z][A-Za-z0-9._-]{0,127}$/.test(value)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Define and freeze a token-only data contract. Input/output types are erased at runtime. */
|
|
222
|
+
export function defineDataContract<const Operations extends DataOperationMap>(input: {
|
|
223
|
+
readonly version: string
|
|
224
|
+
readonly operations: Operations
|
|
225
|
+
}): DataContract<Operations> {
|
|
226
|
+
if (!validVersion(input.version)) throw new TypeError("data: version must be a bounded token")
|
|
227
|
+
if (input.operations === null || typeof input.operations !== "object") {
|
|
228
|
+
throw new TypeError("data: operations must be an object")
|
|
229
|
+
}
|
|
230
|
+
const operations: Record<string, DataOperationSpec> = {}
|
|
231
|
+
for (const [name, operation] of Object.entries(input.operations)) {
|
|
232
|
+
if (!validOperationName(name)) throw new TypeError(`data: invalid operation name ${name}`)
|
|
233
|
+
if (operation === null || typeof operation !== "object" || !isDataAccess(operation.access)) {
|
|
234
|
+
throw new TypeError(`data: ${name} must declare read or write access`)
|
|
235
|
+
}
|
|
236
|
+
// `input` and `output` are type witnesses only. Never retain runtime schemas, defaults, or
|
|
237
|
+
// caller-owned values in the public contract object; adapters receive only token metadata.
|
|
238
|
+
operations[name] = Object.freeze({ access: operation.access })
|
|
239
|
+
}
|
|
240
|
+
return Object.freeze({
|
|
241
|
+
version: input.version,
|
|
242
|
+
operations: Object.freeze(operations) as Operations,
|
|
243
|
+
})
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export interface DataContractSnapshot {
|
|
247
|
+
readonly version: string
|
|
248
|
+
readonly operations: Readonly<Record<string, { readonly access: DataAccess }>>
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Strip a contract to its token-only version/access snapshot for CI or adapter drift checks. */
|
|
252
|
+
export function snapshotDataContract(contract: DataContract): DataContractSnapshot {
|
|
253
|
+
const operations: Record<string, { readonly access: DataAccess }> = {}
|
|
254
|
+
for (const [name, operation] of Object.entries(contract.operations)) {
|
|
255
|
+
if (isDataAccess(operation.access)) operations[name] = { access: operation.access }
|
|
256
|
+
}
|
|
257
|
+
return Object.freeze({ version: contract.version, operations: Object.freeze(operations) })
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Compare a declared contract with an adapter-observed token-only snapshot. */
|
|
261
|
+
export function diffDataContract(
|
|
262
|
+
declared: DataContract,
|
|
263
|
+
observed: DataContractSnapshot | DataContract,
|
|
264
|
+
): readonly DataDrift[] {
|
|
265
|
+
const left = snapshotDataContract(declared)
|
|
266
|
+
const right = "operations" in observed ? snapshotDataContract(observed) : observed
|
|
267
|
+
const drift: DataDrift[] = []
|
|
268
|
+
if (left.version !== right.version) {
|
|
269
|
+
drift.push({
|
|
270
|
+
kind: "version",
|
|
271
|
+
declaredVersion: left.version,
|
|
272
|
+
observedVersion: right.version,
|
|
273
|
+
})
|
|
274
|
+
}
|
|
275
|
+
for (const [name, operation] of Object.entries(left.operations)) {
|
|
276
|
+
const candidate = right.operations[name]
|
|
277
|
+
if (candidate === undefined) {
|
|
278
|
+
drift.push({ kind: "removed", operation: name })
|
|
279
|
+
} else if (candidate.access !== operation.access) {
|
|
280
|
+
drift.push({
|
|
281
|
+
kind: "access-changed",
|
|
282
|
+
operation: name,
|
|
283
|
+
declaredAccess: operation.access,
|
|
284
|
+
observedAccess: candidate.access,
|
|
285
|
+
})
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
for (const [name] of Object.entries(right.operations)) {
|
|
289
|
+
if (left.operations[name] === undefined) drift.push({ kind: "added", operation: name })
|
|
290
|
+
}
|
|
291
|
+
return Object.freeze(drift)
|
|
292
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* read its own package.json at runtime. `scripts/version.ts` rewrites it on every release bump and
|
|
11
11
|
* `check:publish` asserts it equals `@nifrajs/core`'s package version.
|
|
12
12
|
*/
|
|
13
|
-
export const VERSION = "2.
|
|
13
|
+
export const VERSION = "2.11.0" as const
|
|
14
14
|
|
|
15
15
|
export type Version = typeof VERSION
|
|
16
16
|
|
package/src/range.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Range: bytes=...` parsing - the shared owner for every byte-range surface in the framework.
|
|
3
|
+
*
|
|
4
|
+
* This lives in core rather than in the middleware package because two unrelated layers need the
|
|
5
|
+
* exact same answer: `@nifrajs/middleware`'s `rangeResponse` for caller-owned buffers, and
|
|
6
|
+
* `@nifrajs/web`'s `public/` static handler for files on disk. A second implementation that happens
|
|
7
|
+
* to agree today is how a media server ends up serving byte 0 of a seek request in one code path and
|
|
8
|
+
* byte N in the other.
|
|
9
|
+
*
|
|
10
|
+
* The input is request-controlled on every call, so the parser is bounded before it is clever: a
|
|
11
|
+
* capped header length and a capped specifier count keep a malformed range-set from turning a sort
|
|
12
|
+
* and merge into an allocation amplifier.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export interface ByteRange {
|
|
16
|
+
readonly start: number
|
|
17
|
+
readonly end: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type ByteRangeResult =
|
|
21
|
+
| { readonly kind: "none" }
|
|
22
|
+
| { readonly kind: "unsatisfiable" }
|
|
23
|
+
| { readonly kind: "satisfiable"; readonly ranges: readonly ByteRange[] }
|
|
24
|
+
|
|
25
|
+
const INTEGER = /^(?:0|[1-9]\d*)$/
|
|
26
|
+
const MAX_RANGE_HEADER_LENGTH = 16 * 1024
|
|
27
|
+
const MAX_RANGE_SPECIFIERS = 16
|
|
28
|
+
|
|
29
|
+
function validSize(size: number): void {
|
|
30
|
+
if (!Number.isSafeInteger(size) || size < 0) {
|
|
31
|
+
throw new RangeError("parseByteRange: size must be a non-negative safe integer")
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Parse an HTTP `Range: bytes=...` header against a known representation size.
|
|
37
|
+
*
|
|
38
|
+
* Three outcomes, and the distinction matters to the caller's status code: `none` means the header
|
|
39
|
+
* was absent or syntactically invalid and must be ignored (200), `unsatisfiable` means a well-formed
|
|
40
|
+
* range-set selected nothing (416), `satisfiable` carries the coalesced ranges (206).
|
|
41
|
+
*/
|
|
42
|
+
export function parseByteRange(value: string | null, size: number): ByteRangeResult {
|
|
43
|
+
validSize(size)
|
|
44
|
+
if (value === null) return { kind: "none" }
|
|
45
|
+
const match = /^bytes\s*=\s*(.+)$/i.exec(value)
|
|
46
|
+
if (match === null) return { kind: "none" }
|
|
47
|
+
const source = match[1]!
|
|
48
|
+
if (source.length > MAX_RANGE_HEADER_LENGTH) return { kind: "none" }
|
|
49
|
+
let separators = 0
|
|
50
|
+
for (const character of source) {
|
|
51
|
+
if (character === "," && ++separators >= MAX_RANGE_SPECIFIERS) return { kind: "none" }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const ranges: ByteRange[] = []
|
|
55
|
+
let validRangeSpec = false
|
|
56
|
+
for (const raw of source.split(",")) {
|
|
57
|
+
const item = raw.trim()
|
|
58
|
+
const dash = item.indexOf("-")
|
|
59
|
+
if (dash < 0) continue
|
|
60
|
+
const left = item.slice(0, dash).trim()
|
|
61
|
+
const right = item.slice(dash + 1).trim()
|
|
62
|
+
if (left === "" && (!INTEGER.test(right) || right === "0")) continue
|
|
63
|
+
if (left !== "" && !INTEGER.test(left)) continue
|
|
64
|
+
if (right !== "" && !INTEGER.test(right)) continue
|
|
65
|
+
validRangeSpec = true
|
|
66
|
+
|
|
67
|
+
let start: number
|
|
68
|
+
let end: number
|
|
69
|
+
if (left === "") {
|
|
70
|
+
const suffix = Number(right)
|
|
71
|
+
start = suffix >= size ? 0 : size - suffix
|
|
72
|
+
end = size - 1
|
|
73
|
+
} else {
|
|
74
|
+
start = Number(left)
|
|
75
|
+
end = right === "" ? size - 1 : Math.min(Number(right), size - 1)
|
|
76
|
+
}
|
|
77
|
+
if (start > end || start >= size || size === 0) continue
|
|
78
|
+
ranges.push({ start, end })
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// A syntactically invalid Range header is ignored; a valid range-set with no overlap is 416.
|
|
82
|
+
if (ranges.length === 0) return validRangeSpec ? { kind: "unsatisfiable" } : { kind: "none" }
|
|
83
|
+
ranges.sort((a, b) => a.start - b.start || a.end - b.end)
|
|
84
|
+
const merged: ByteRange[] = []
|
|
85
|
+
for (const range of ranges) {
|
|
86
|
+
const previous = merged[merged.length - 1]
|
|
87
|
+
if (previous !== undefined && range.start <= previous.end + 1) {
|
|
88
|
+
merged[merged.length - 1] = { start: previous.start, end: Math.max(previous.end, range.end) }
|
|
89
|
+
} else {
|
|
90
|
+
merged.push(range)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return { kind: "satisfiable", ranges: merged }
|
|
94
|
+
}
|