@znight/core 0.1.2 → 0.1.4
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 +29 -9
- package/dist/index.d.mts +111 -2
- package/dist/index.mjs +102 -5
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/container.ts +87 -0
- package/src/errors.ts +36 -1
- package/src/hooks.ts +59 -0
- package/src/index.ts +6 -1
- package/src/scope.ts +21 -2
package/package.json
CHANGED
package/src/container.ts
CHANGED
|
@@ -93,6 +93,30 @@ export interface ManagedInstance {
|
|
|
93
93
|
/** Seed values of a request scope: token-keyed pairs (aliases and known classes are accepted). */
|
|
94
94
|
export type ScopeValues = Iterable<readonly [ProviderKey, unknown]>
|
|
95
95
|
|
|
96
|
+
/**
|
|
97
|
+
* A request scope opened with {@link Container.openScope}: its owner runs code in it with
|
|
98
|
+
* {@link OpenedScope.run} and ends it with {@link OpenedScope.close}. The callback-free form of
|
|
99
|
+
* {@link Container.runInScope}, for hot paths that stay synchronous as long as their work is (the
|
|
100
|
+
* HTTP pipeline): no promise is created unless something is asynchronous.
|
|
101
|
+
*/
|
|
102
|
+
export interface OpenedScope {
|
|
103
|
+
/** The values of the scope (what `currentScope()` returns in {@link run}). */
|
|
104
|
+
readonly context: ScopeContext
|
|
105
|
+
/**
|
|
106
|
+
* Calls `fn(...args)` in the scope and returns its result. The code it runs and the asynchronous
|
|
107
|
+
* continuations it creates (awaits, promise callbacks, timers) see the scope, like the callback
|
|
108
|
+
* of `runInScope`. May be called several times until the scope is closed.
|
|
109
|
+
*/
|
|
110
|
+
run<A extends readonly unknown[], R>(fn: (...args: A) => R, ...args: A): R
|
|
111
|
+
/**
|
|
112
|
+
* Closes the scope (a later resolution of a request-scoped provider in it throws ZNIGHT013) and
|
|
113
|
+
* disposes the instances created in it, in reverse creation order. Returns `undefined` when no
|
|
114
|
+
* instance was created (nothing to wait for), otherwise a promise rejecting with ZNIGHT021 when a
|
|
115
|
+
* disposal fails. Idempotent: later calls return the first result.
|
|
116
|
+
*/
|
|
117
|
+
close(): Promise<void> | undefined
|
|
118
|
+
}
|
|
119
|
+
|
|
96
120
|
type Status = 'open' | 'initializing' | 'ready' | 'failed' | 'disposed'
|
|
97
121
|
|
|
98
122
|
interface InitRun {
|
|
@@ -706,11 +730,27 @@ export class Container {
|
|
|
706
730
|
return result
|
|
707
731
|
}
|
|
708
732
|
|
|
733
|
+
/**
|
|
734
|
+
* Opens a request scope seeded with `values`, like {@link runInScope}, but without running
|
|
735
|
+
* anything: the caller runs code in it (`run`) and closes it (`close`) when the work it started
|
|
736
|
+
* is over. Request-scoped providers resolved in it are disposed by `close`, which must be called.
|
|
737
|
+
*/
|
|
738
|
+
openScope(values?: ScopeValues): OpenedScope {
|
|
739
|
+
let seeds: unknown[] | undefined
|
|
740
|
+
if (values !== undefined) {
|
|
741
|
+
seeds = []
|
|
742
|
+
for (const [key, value] of values) seeds.push(this.#canonical(key), value)
|
|
743
|
+
}
|
|
744
|
+
return new ScopeHandle(this.#storage, new RequestScope(this.#canonical, seeds), this.#closeScopeFn)
|
|
745
|
+
}
|
|
746
|
+
|
|
709
747
|
/** The active request scope, if any. */
|
|
710
748
|
currentScope(): ScopeContext | undefined {
|
|
711
749
|
return this.#storage.getStore()
|
|
712
750
|
}
|
|
713
751
|
|
|
752
|
+
readonly #closeScopeFn = (scope: RequestScope): Promise<unknown[]> => this.#closeScope(scope)
|
|
753
|
+
|
|
714
754
|
async #closeScope(scope: RequestScope): Promise<unknown[]> {
|
|
715
755
|
scope.closed = true
|
|
716
756
|
const errors: unknown[] = []
|
|
@@ -1011,3 +1051,50 @@ function disposeQuietly(instance: unknown, method: string | undefined): void {
|
|
|
1011
1051
|
// best effort
|
|
1012
1052
|
}
|
|
1013
1053
|
}
|
|
1054
|
+
|
|
1055
|
+
/** Implementation of {@link OpenedScope}. */
|
|
1056
|
+
class ScopeHandle implements OpenedScope {
|
|
1057
|
+
readonly #storage: AsyncLocalStorage<RequestScope>
|
|
1058
|
+
readonly #scope: RequestScope
|
|
1059
|
+
readonly #dispose: (scope: RequestScope) => Promise<unknown[]>
|
|
1060
|
+
#closed = false
|
|
1061
|
+
#closing: Promise<void> | undefined
|
|
1062
|
+
|
|
1063
|
+
constructor(
|
|
1064
|
+
storage: AsyncLocalStorage<RequestScope>,
|
|
1065
|
+
scope: RequestScope,
|
|
1066
|
+
dispose: (scope: RequestScope) => Promise<unknown[]>,
|
|
1067
|
+
) {
|
|
1068
|
+
this.#storage = storage
|
|
1069
|
+
this.#scope = scope
|
|
1070
|
+
this.#dispose = dispose
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
get context(): ScopeContext {
|
|
1074
|
+
return this.#scope
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
run<A extends readonly unknown[], R>(fn: (...args: A) => R, ...args: A): R {
|
|
1078
|
+
return this.#storage.run(this.#scope, fn, ...args)
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
close(): Promise<void> | undefined {
|
|
1082
|
+
if (this.#closed) return this.#closing
|
|
1083
|
+
this.#closed = true
|
|
1084
|
+
const scope = this.#scope
|
|
1085
|
+
if (scope.created.length === 0) {
|
|
1086
|
+
scope.closed = true
|
|
1087
|
+
return undefined
|
|
1088
|
+
}
|
|
1089
|
+
this.#closing = this.#dispose(scope).then(errors => {
|
|
1090
|
+
if (errors.length > 0) {
|
|
1091
|
+
throw new ZnightAggregateError(
|
|
1092
|
+
ErrorCode.LifecycleFailed,
|
|
1093
|
+
`Failed to dispose ${errors.length} request-scoped instance(s)`,
|
|
1094
|
+
errors,
|
|
1095
|
+
)
|
|
1096
|
+
}
|
|
1097
|
+
})
|
|
1098
|
+
return this.#closing
|
|
1099
|
+
}
|
|
1100
|
+
}
|
package/src/errors.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* messaging, config, observability).
|
|
8
8
|
* - From `ZNIGHT030`, each first-party package reserves a block of ten: `ZNIGHT030`–`039`
|
|
9
9
|
* `@znight/openapi`, `ZNIGHT040`–`049` `@znight/schedule`, `ZNIGHT050`–`059` `@znight/websocket`
|
|
10
|
-
* (reserved), `ZNIGHT060`–`069` `@znight/graphql
|
|
10
|
+
* (reserved), `ZNIGHT060`–`069` `@znight/graphql`.
|
|
11
11
|
*
|
|
12
12
|
* Within a range, codes are allocated in order without gaps. A package uses its block for its own
|
|
13
13
|
* failure modes, with one catch-all code for invalid usage and options, and reuses a lower code
|
|
@@ -100,6 +100,34 @@ export const ErrorCode = {
|
|
|
100
100
|
MissingLockStore: 'ZNIGHT043',
|
|
101
101
|
/** A scheduled run was aborted (`ctx.signal.reason`, `JobAbortedError`); never thrown by the framework. */
|
|
102
102
|
JobAborted: 'ZNIGHT044',
|
|
103
|
+
|
|
104
|
+
// ZNIGHT060–069: @znight/graphql (RFC 0005)
|
|
105
|
+
/** A TypeScript type cannot be mapped to a GraphQL type, or a resolver lacks the annotation it needs. */
|
|
106
|
+
InvalidGraphQLType: 'ZNIGHT060',
|
|
107
|
+
/** Two declarations or root fields share a GraphQL name, or a name is invalid or reserved. */
|
|
108
|
+
GraphQLNameConflict: 'ZNIGHT061',
|
|
109
|
+
/**
|
|
110
|
+
* Invalid resolver structure: `@ResolveField` without `@Parent()`, a subscription not returning an
|
|
111
|
+
* async iterable, no `@Query`, inherited handlers, resolvers in a compiled library.
|
|
112
|
+
*/
|
|
113
|
+
InvalidResolver: 'ZNIGHT062',
|
|
114
|
+
/**
|
|
115
|
+
* Invalid input position: a type used as input and output, a getter, a union, a cycle of non-null
|
|
116
|
+
* input fields, a default value not matching its type.
|
|
117
|
+
*/
|
|
118
|
+
InvalidGraphQLInput: 'ZNIGHT063',
|
|
119
|
+
/** The members of a GraphQL union cannot be told apart at runtime. */
|
|
120
|
+
UnresolvableAbstractType: 'ZNIGHT064',
|
|
121
|
+
/**
|
|
122
|
+
* Invalid loader: `@Loader()` on a type that is not `Loader<L>`, `L` not a `BatchLoad`; at runtime,
|
|
123
|
+
* a batch function returning another number of values than keys.
|
|
124
|
+
*/
|
|
125
|
+
InvalidLoader: 'ZNIGHT065',
|
|
126
|
+
/**
|
|
127
|
+
* Invalid GraphQL setup at boot: malformed or version-skewed metadata, invalid schema, invalid
|
|
128
|
+
* `GraphQLPlugin` options, missing `HttpPlugin`, scalar name mismatch.
|
|
129
|
+
*/
|
|
130
|
+
InvalidGraphQLSetup: 'ZNIGHT066',
|
|
103
131
|
} as const
|
|
104
132
|
|
|
105
133
|
export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode]
|
|
@@ -142,6 +170,13 @@ export const ERROR_DESCRIPTIONS: Readonly<Record<ErrorCode, string>> = {
|
|
|
142
170
|
ZNIGHT042: 'Unknown job: no job has this name',
|
|
143
171
|
ZNIGHT043: 'Missing lock store: a job requires a lock but SchedulePlugin has no store',
|
|
144
172
|
ZNIGHT044: 'Job aborted: a scheduled run was aborted (timeout, lost lock, removal or shutdown)',
|
|
173
|
+
ZNIGHT060: 'Invalid GraphQL type: a TypeScript type cannot be mapped to a GraphQL type',
|
|
174
|
+
ZNIGHT061: 'GraphQL name conflict: two types or root fields share a name, or a name is invalid or reserved',
|
|
175
|
+
ZNIGHT062: 'Invalid resolver: the structure of a GraphQL resolver is invalid',
|
|
176
|
+
ZNIGHT063: 'Invalid GraphQL input: a type cannot be used in an input position',
|
|
177
|
+
ZNIGHT064: 'Unresolvable abstract type: the members of a GraphQL union cannot be told apart at runtime',
|
|
178
|
+
ZNIGHT065: 'Invalid loader: not a Loader of a BatchLoad provider, or a batch returning the wrong number of values',
|
|
179
|
+
ZNIGHT066: 'Invalid GraphQL setup: metadata, schema, GraphQLPlugin options or HttpPlugin missing',
|
|
145
180
|
}
|
|
146
181
|
|
|
147
182
|
export interface ZnightErrorOptions extends ErrorOptions {
|
package/src/hooks.ts
CHANGED
|
@@ -27,6 +27,12 @@ export interface ZnightHooks {
|
|
|
27
27
|
* drop, a wrapper that threw first): see `ScheduledRunEvent.outcome`.
|
|
28
28
|
*/
|
|
29
29
|
'schedule:run': ScheduledRunEvent
|
|
30
|
+
/**
|
|
31
|
+
* One GraphQL operation handled by `@znight/graphql` (RFC 0005): a query or a mutation, or a whole
|
|
32
|
+
* subscription from its start to its end. Wrapped with `hooks.run` when wrappers exist, then
|
|
33
|
+
* emitted when listeners exist.
|
|
34
|
+
*/
|
|
35
|
+
'graphql:operation': GraphQLOperationEvent
|
|
30
36
|
}
|
|
31
37
|
|
|
32
38
|
/**
|
|
@@ -162,6 +168,59 @@ export interface ScheduledRunEvent {
|
|
|
162
168
|
aborted?: ScheduledRunAbortReason
|
|
163
169
|
}
|
|
164
170
|
|
|
171
|
+
/** Type of a GraphQL operation (`GraphQLOperationEvent.operationType`). */
|
|
172
|
+
export type GraphQLOperationType = 'query' | 'mutation' | 'subscription'
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* How a GraphQL operation reached the server (`GraphQLOperationEvent.transport`): GraphQL over HTTP,
|
|
176
|
+
* GraphQL over Server-Sent Events, or a direct call (`execute()` / `subscribe()` of the plugin).
|
|
177
|
+
*/
|
|
178
|
+
export type GraphQLOperationTransport = 'http' | 'sse' | 'direct'
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Outcome of a GraphQL operation (`GraphQLOperationEvent.outcome`):
|
|
182
|
+
*
|
|
183
|
+
* - `'success'`: executed, no error;
|
|
184
|
+
* - `'partial'`: executed, with data and field errors;
|
|
185
|
+
* - `'error'`: executed, without data (a non-null root field failed, or the execution threw);
|
|
186
|
+
* - `'rejected'`: never executed (parse, validation, variable coercion or limit errors).
|
|
187
|
+
*/
|
|
188
|
+
export type GraphQLOperationOutcome = 'success' | 'partial' | 'error' | 'rejected'
|
|
189
|
+
|
|
190
|
+
/** One error of a GraphQL response, as sent to the client (masked when it is unexpected). */
|
|
191
|
+
export interface GraphQLOperationError {
|
|
192
|
+
readonly message: string
|
|
193
|
+
/** `extensions.code` of the error (`FORBIDDEN`, `GRAPHQL_PARSE_FAILED`...). */
|
|
194
|
+
readonly code: string | undefined
|
|
195
|
+
/** Response path of the field that failed; absent for request errors. */
|
|
196
|
+
readonly path?: readonly (string | number)[]
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Instrumentation event of the `graphql:operation` hook, produced by `@znight/graphql` (RFC 0005)
|
|
201
|
+
* once per operation. For a subscription, it covers its whole life: wrappers see it start, and
|
|
202
|
+
* `outcome` is set when it ends. Mutable fields are filled in by the producer before `hooks.run`
|
|
203
|
+
* resolves; listeners see the final values.
|
|
204
|
+
*/
|
|
205
|
+
export interface GraphQLOperationEvent {
|
|
206
|
+
/** `undefined` when the request was rejected before an operation was selected (parse error...). */
|
|
207
|
+
readonly operationType: GraphQLOperationType | undefined
|
|
208
|
+
/** `undefined` for an anonymous operation, or when none was selected. */
|
|
209
|
+
readonly operationName: string | undefined
|
|
210
|
+
/** SHA-256 (hex) of the query text, cached with the parsed document; `undefined` without a query. */
|
|
211
|
+
readonly documentId: string | undefined
|
|
212
|
+
readonly transport: GraphQLOperationTransport
|
|
213
|
+
readonly requestId: string
|
|
214
|
+
/** `GraphQLContext` from `@znight/graphql`. */
|
|
215
|
+
readonly context: unknown
|
|
216
|
+
/** `performance.now()` when the operation started. */
|
|
217
|
+
readonly startTime: number
|
|
218
|
+
/** Outcome, set before `hooks.run` resolves. */
|
|
219
|
+
outcome?: GraphQLOperationOutcome
|
|
220
|
+
/** Errors of the response (field and request errors), set before `hooks.run` resolves. */
|
|
221
|
+
errors?: readonly GraphQLOperationError[]
|
|
222
|
+
}
|
|
223
|
+
|
|
165
224
|
/** Name of a declared hook. */
|
|
166
225
|
export type HookName = keyof ZnightHooks
|
|
167
226
|
|
package/src/index.ts
CHANGED
|
@@ -17,7 +17,7 @@ export {
|
|
|
17
17
|
type ProviderValues,
|
|
18
18
|
type ShutdownOptions,
|
|
19
19
|
} from './application.ts'
|
|
20
|
-
export { Container, type InitOptions, type ManagedInstance, type ScopeValues } from './container.ts'
|
|
20
|
+
export { Container, type InitOptions, type ManagedInstance, type OpenedScope, type ScopeValues } from './container.ts'
|
|
21
21
|
export {
|
|
22
22
|
type ClassMarker,
|
|
23
23
|
Factory,
|
|
@@ -38,6 +38,11 @@ export {
|
|
|
38
38
|
export { isProviderDefinition } from './definition.ts'
|
|
39
39
|
export * from './errors.ts'
|
|
40
40
|
export {
|
|
41
|
+
type GraphQLOperationError,
|
|
42
|
+
type GraphQLOperationEvent,
|
|
43
|
+
type GraphQLOperationOutcome,
|
|
44
|
+
type GraphQLOperationTransport,
|
|
45
|
+
type GraphQLOperationType,
|
|
41
46
|
type HookEvent,
|
|
42
47
|
type HookListener,
|
|
43
48
|
type HookName,
|
package/src/scope.ts
CHANGED
|
@@ -26,13 +26,32 @@ export interface ScopedInstance {
|
|
|
26
26
|
|
|
27
27
|
/** Implementation of {@link ScopeContext} used by the container (internal). */
|
|
28
28
|
export class RequestScope implements ScopeContext {
|
|
29
|
-
readonly values: Map<Token, unknown> = new Map()
|
|
30
29
|
readonly created: ScopedInstance[] = []
|
|
31
30
|
closed = false
|
|
32
31
|
readonly #canonical: (key: ProviderKey) => Token
|
|
32
|
+
#values: Map<Token, unknown> | undefined
|
|
33
|
+
/** Seed values not yet in {@link values}: canonical tokens and values, alternately. */
|
|
34
|
+
#seeds: readonly unknown[] | undefined
|
|
33
35
|
|
|
34
|
-
|
|
36
|
+
/** `seeds`: canonical tokens and their values, alternately (copied into {@link values} on first use). */
|
|
37
|
+
constructor(canonical: (key: ProviderKey) => Token, seeds?: readonly unknown[]) {
|
|
35
38
|
this.#canonical = canonical
|
|
39
|
+
this.#seeds = seeds
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The values of the scope by canonical token: the seeds, then what was set or created in it. */
|
|
43
|
+
get values(): Map<Token, unknown> {
|
|
44
|
+
let values = this.#values
|
|
45
|
+
if (values === undefined) {
|
|
46
|
+
values = new Map()
|
|
47
|
+
const seeds = this.#seeds
|
|
48
|
+
if (seeds !== undefined) {
|
|
49
|
+
for (let index = 0; index < seeds.length; index += 2) values.set(seeds[index] as Token, seeds[index + 1])
|
|
50
|
+
this.#seeds = undefined
|
|
51
|
+
}
|
|
52
|
+
this.#values = values
|
|
53
|
+
}
|
|
54
|
+
return values
|
|
36
55
|
}
|
|
37
56
|
|
|
38
57
|
get<T = unknown>(key: ProviderKey<T>): T | undefined {
|