@nifrajs/core 2.9.0 → 2.10.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nifrajs/core",
3
- "version": "2.9.0",
3
+ "version": "2.10.0",
4
4
  "description": "Bun-native, contract-first HTTP framework - the router, server, and route descriptor.",
5
5
  "keywords": [
6
6
  "bun",
@@ -167,6 +167,21 @@
167
167
  "types": "./dist/classification.d.ts",
168
168
  "default": "./dist/classification.js"
169
169
  },
170
+ "./channel": {
171
+ "bun": "./src/channel.ts",
172
+ "types": "./dist/channel.d.ts",
173
+ "default": "./dist/channel.js"
174
+ },
175
+ "./data": {
176
+ "bun": "./src/data.ts",
177
+ "types": "./dist/data.d.ts",
178
+ "default": "./dist/data.js"
179
+ },
180
+ "./range": {
181
+ "bun": "./src/range.ts",
182
+ "types": "./dist/range.d.ts",
183
+ "default": "./dist/range.js"
184
+ },
170
185
  "./contract": {
171
186
  "bun": "./src/server/contract.ts",
172
187
  "types": "./dist/server/contract.d.ts",
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.9.0" as const
13
+ export const VERSION = "2.10.0" as const
14
14
 
15
15
  export type Version = typeof VERSION
16
16