@svadmin/core 0.49.1 → 0.52.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 +32 -0
- package/package.json +21 -4
- package/src/access-control-contract.ts +250 -0
- package/src/adapters/casbin.ts +20 -14
- package/src/adapters/casl.ts +22 -19
- package/src/admin-tool.ts +7 -7
- package/src/audit-contract.ts +105 -0
- package/src/audit.ts +88 -128
- package/src/auth-hooks.svelte.ts +593 -261
- package/src/auth-mutation-contract.ts +143 -0
- package/src/auth-query-contract.ts +97 -0
- package/src/captured-mutation.svelte.ts +45 -0
- package/src/chatProvider.svelte.ts +1 -1
- package/src/command-contract.ts +195 -0
- package/src/context.svelte.ts +217 -205
- package/src/contract-context.svelte.ts +37 -0
- package/src/contract-form.svelte.ts +566 -0
- package/src/create-contract.ts +56 -0
- package/src/create-hooks.svelte.ts +347 -0
- package/src/create-many-contract.ts +90 -0
- package/src/create-many-hooks.svelte.ts +396 -0
- package/src/data-transfer.svelte.ts +1 -215
- package/src/defined-options.ts +50 -0
- package/src/delete-contract.ts +50 -0
- package/src/delete-hooks.svelte.ts +403 -0
- package/src/delete-many-contract.ts +60 -0
- package/src/delete-many-hooks.svelte.ts +402 -0
- package/src/enterprise.ts +4 -4
- package/src/export-format.ts +16 -14
- package/src/export-hooks.svelte.ts +191 -0
- package/src/feature-gate-contract.ts +53 -0
- package/src/hook-utils.svelte.ts +53 -105
- package/src/hooks.svelte.ts +19 -774
- package/src/http.ts +88 -87
- package/src/i18n.svelte.ts +56 -2
- package/src/import-contract.ts +106 -0
- package/src/import-hooks.svelte.ts +310 -0
- package/src/index.ts +77 -28
- package/src/inferencer-graphql.ts +7 -6
- package/src/infinite-query.svelte.ts +228 -0
- package/src/invalidation-contract.ts +26 -0
- package/src/live-sse.ts +91 -127
- package/src/live-subscription.svelte.ts +130 -0
- package/src/live-transport.ts +127 -0
- package/src/live-websocket.ts +85 -118
- package/src/live.svelte.ts +66 -68
- package/src/notification.svelte.ts +17 -15
- package/src/permission-catalog.ts +121 -0
- package/src/permission-hints-contract.ts +48 -0
- package/src/permissions.svelte.ts +112 -59
- package/src/plain-data.ts +61 -0
- package/src/provider-bundle.ts +85 -79
- package/src/provider-response.ts +35 -0
- package/src/query-hooks.svelte.ts +241 -286
- package/src/query-invalidation.ts +172 -0
- package/src/query-keys.ts +221 -110
- package/src/query-session.svelte.ts +216 -0
- package/src/query-snapshot.ts +144 -0
- package/src/reactive-projection.ts +53 -0
- package/src/record-decoder.ts +63 -0
- package/src/resource-binding.svelte.ts +19 -0
- package/src/resource-contract.ts +595 -0
- package/src/resource-mutation-types.ts +30 -0
- package/src/resource-schemas.ts +371 -0
- package/src/resource-transport-config.ts +156 -0
- package/src/route-parsing.ts +11 -11
- package/src/router-provider.ts +64 -59
- package/src/router.ts +8 -3
- package/src/routing-hooks.svelte.ts +90 -62
- package/src/schema-validation.ts +92 -0
- package/src/schema.ts +13 -0
- package/src/select-options.ts +95 -0
- package/src/select-query.svelte.ts +191 -0
- package/src/session-query.svelte.ts +88 -0
- package/src/strict-command-hooks.svelte.ts +333 -0
- package/src/strict-hooks.svelte.ts +198 -0
- package/src/table-hooks.svelte.ts +180 -174
- package/src/table-state.svelte.ts +117 -117
- package/src/task-contract.ts +87 -0
- package/src/task-hooks.svelte.ts +193 -149
- package/src/task-provider.ts +115 -0
- package/src/task-subscription.ts +132 -0
- package/src/theme.svelte.ts +155 -154
- package/src/toast.svelte.ts +38 -37
- package/src/types.ts +55 -103
- package/src/unsafe.ts +7 -0
- package/src/update-contract.ts +65 -0
- package/src/update-hooks.svelte.ts +347 -0
- package/src/update-many-contract.ts +79 -0
- package/src/update-many-hooks.svelte.ts +373 -0
- package/src/url-sync.ts +11 -10
- package/src/useCan.ts +96 -31
- package/src/useParsed.svelte.ts +18 -14
- package/src/useStepsForm.svelte.ts +123 -63
- package/src/utility-hooks.svelte.ts +101 -158
- package/src/form-hooks.svelte.ts +0 -889
- package/src/mutation-hooks.svelte.ts +0 -561
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { Type, type Static } from '@sinclair/typebox';
|
|
2
|
+
import type { AuthProvider } from './types';
|
|
3
|
+
import { authErrorSchema, authRedirectSchema, decodeIdentity } from './auth-query-contract';
|
|
4
|
+
import { snapshotPlainData } from './plain-data';
|
|
5
|
+
import { checkExact } from './schema-validation';
|
|
6
|
+
|
|
7
|
+
const actionResultSchema = Type.Object({
|
|
8
|
+
success: Type.Boolean(),
|
|
9
|
+
redirectTo: Type.Optional(authRedirectSchema),
|
|
10
|
+
error: Type.Optional(authErrorSchema),
|
|
11
|
+
}, { additionalProperties: false });
|
|
12
|
+
const parametersSchema = Type.Record(Type.String(), Type.Unknown());
|
|
13
|
+
const profileSchema = Type.Object({
|
|
14
|
+
name: Type.Optional(Type.String()),
|
|
15
|
+
avatar: Type.Optional(Type.String()),
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
export type AuthActionResult = Static<typeof actionResultSchema>;
|
|
19
|
+
export type AuthMutationMethod =
|
|
20
|
+
| 'login' | 'logout' | 'register' | 'forgotPassword'
|
|
21
|
+
| 'updatePassword' | 'updateIdentity' | 'updateProfile';
|
|
22
|
+
export type AuthMutationArgs<M extends AuthMutationMethod> = Parameters<NonNullable<AuthProvider[M]>>;
|
|
23
|
+
|
|
24
|
+
export class AuthMutationError extends Error {
|
|
25
|
+
constructor(readonly code:
|
|
26
|
+
| 'INVALID_AUTH_INPUT' | 'AUTH_METHOD_UNAVAILABLE' | 'AUTH_REQUEST_FAILED'
|
|
27
|
+
| 'INVALID_AUTH_RESULT' | 'AUTH_REJECTED' | 'AUTH_RESULT_SUPERSEDED',
|
|
28
|
+
) {
|
|
29
|
+
super({
|
|
30
|
+
INVALID_AUTH_INPUT: 'Invalid authentication input.',
|
|
31
|
+
AUTH_METHOD_UNAVAILABLE: 'Authentication method is unavailable.',
|
|
32
|
+
AUTH_REQUEST_FAILED: 'Authentication request failed; its outcome could not be confirmed.',
|
|
33
|
+
INVALID_AUTH_RESULT: 'Invalid authentication result; its outcome could not be confirmed.',
|
|
34
|
+
AUTH_REJECTED: 'Authentication request was rejected.',
|
|
35
|
+
AUTH_RESULT_SUPERSEDED: 'Authentication result belongs to an inactive request.',
|
|
36
|
+
}[code]);
|
|
37
|
+
this.name = 'AuthMutationError';
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function authMutationFailure(code: AuthMutationError['code']): AuthActionResult {
|
|
42
|
+
const error = new AuthMutationError(code);
|
|
43
|
+
return Object.freeze({ success: false, error: Object.freeze({ name: error.code, message: error.message }) });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function decodeAuthAction(value: unknown): AuthActionResult {
|
|
47
|
+
try {
|
|
48
|
+
const candidate = snapshotPlainData(value);
|
|
49
|
+
if (checkExact(actionResultSchema, candidate) && !(candidate.success && candidate.error !== undefined)) {
|
|
50
|
+
return candidate.success ? Object.freeze(candidate) : authMutationFailure('AUTH_REJECTED');
|
|
51
|
+
}
|
|
52
|
+
} catch {
|
|
53
|
+
// Never expose provider payloads, exception text, or reflection failures.
|
|
54
|
+
}
|
|
55
|
+
throw new AuthMutationError('INVALID_AUTH_RESULT');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function decodeAuthParameters(value: unknown): Record<string, unknown> {
|
|
59
|
+
try {
|
|
60
|
+
const candidate = snapshotPlainData(value);
|
|
61
|
+
if (checkExact(parametersSchema, candidate)) return candidate;
|
|
62
|
+
} catch {
|
|
63
|
+
// Business-specific fields are validated by the selected provider.
|
|
64
|
+
}
|
|
65
|
+
throw new AuthMutationError('INVALID_AUTH_INPUT');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function decodeProfile(value: unknown): Parameters<NonNullable<AuthProvider['updateProfile']>>[0] {
|
|
69
|
+
try {
|
|
70
|
+
if (typeof value !== 'object' || value === null) throw new Error();
|
|
71
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, 'avatar');
|
|
72
|
+
const avatar: unknown = descriptor && 'value' in descriptor ? descriptor.value : undefined;
|
|
73
|
+
if (typeof File !== 'undefined' && avatar instanceof File) {
|
|
74
|
+
// File is immutable upload data. Validate every other field without reading accessors.
|
|
75
|
+
const descriptors = Object.getOwnPropertyDescriptors(value);
|
|
76
|
+
delete descriptors['avatar'];
|
|
77
|
+
if (Object.getOwnPropertySymbols(value).length > 0 ||
|
|
78
|
+
(Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) ||
|
|
79
|
+
descriptor?.enumerable !== true) throw new Error();
|
|
80
|
+
const rest: unknown = Object.defineProperties({}, descriptors);
|
|
81
|
+
const candidate = decodeAuthParameters(rest);
|
|
82
|
+
if (checkExact(profileSchema, candidate)) return { ...candidate, avatar };
|
|
83
|
+
} else {
|
|
84
|
+
const candidate = decodeAuthParameters(value);
|
|
85
|
+
if (checkExact(profileSchema, candidate)) return candidate;
|
|
86
|
+
}
|
|
87
|
+
} catch {
|
|
88
|
+
// Invalid metadata must not turn into a partial profile update.
|
|
89
|
+
}
|
|
90
|
+
throw new AuthMutationError('INVALID_AUTH_INPUT');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function captureMethod<P>(
|
|
94
|
+
provider: AuthProvider,
|
|
95
|
+
select: () => ((params: P) => Promise<AuthActionResult>) | undefined,
|
|
96
|
+
): (params: P) => Promise<unknown> {
|
|
97
|
+
try {
|
|
98
|
+
const method = select();
|
|
99
|
+
if (typeof method === 'function') return params => method.call(provider, params);
|
|
100
|
+
} catch {
|
|
101
|
+
// A throwing method accessor is unavailable, not an input or server error.
|
|
102
|
+
}
|
|
103
|
+
throw new AuthMutationError('AUTH_METHOD_UNAVAILABLE');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Validate and snapshot before dispatch, retaining the exact method and receiver. */
|
|
107
|
+
export function prepareAuthMutation(provider: AuthProvider, method: AuthMutationMethod, value: unknown): () => Promise<unknown> {
|
|
108
|
+
switch (method) {
|
|
109
|
+
case 'logout': {
|
|
110
|
+
const invoke = captureMethod(provider, () => provider.logout);
|
|
111
|
+
const input = value === undefined ? undefined : decodeAuthParameters(value);
|
|
112
|
+
return () => invoke(input);
|
|
113
|
+
}
|
|
114
|
+
case 'updateProfile': {
|
|
115
|
+
const invoke = captureMethod(provider, () => provider.updateProfile);
|
|
116
|
+
const input = decodeProfile(value);
|
|
117
|
+
return () => invoke(input);
|
|
118
|
+
}
|
|
119
|
+
case 'updateIdentity': {
|
|
120
|
+
const invoke = captureMethod(provider, () => provider.updateIdentity);
|
|
121
|
+
try {
|
|
122
|
+
const input = decodeIdentity(value);
|
|
123
|
+
if (input !== null) return () => invoke(input);
|
|
124
|
+
} catch {
|
|
125
|
+
// Query decoder errors are normalized to this input boundary.
|
|
126
|
+
}
|
|
127
|
+
throw new AuthMutationError('INVALID_AUTH_INPUT');
|
|
128
|
+
}
|
|
129
|
+
case 'login':
|
|
130
|
+
case 'register':
|
|
131
|
+
case 'forgotPassword':
|
|
132
|
+
case 'updatePassword': {
|
|
133
|
+
const invoke = captureMethod(provider, () => provider[method]);
|
|
134
|
+
const input = decodeAuthParameters(value);
|
|
135
|
+
return () => invoke(input);
|
|
136
|
+
}
|
|
137
|
+
default: {
|
|
138
|
+
const invalid: never = method;
|
|
139
|
+
void invalid;
|
|
140
|
+
throw new AuthMutationError('AUTH_METHOD_UNAVAILABLE');
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { Type, type Static, type TSchema } from '@sinclair/typebox';
|
|
2
|
+
import { snapshotPlainData } from './plain-data';
|
|
3
|
+
import { checkExact } from './schema-validation';
|
|
4
|
+
|
|
5
|
+
export const authErrorSchema = Type.Object({
|
|
6
|
+
message: Type.String(),
|
|
7
|
+
name: Type.Optional(Type.String()),
|
|
8
|
+
}, { additionalProperties: false });
|
|
9
|
+
|
|
10
|
+
// Auth hooks navigate within this application; external redirects belong to the provider.
|
|
11
|
+
export const authRedirectSchema = Type.String({ pattern: '^/(?![/\\\\])[^\\\\\\x00-\\x20\\x7f]*$' });
|
|
12
|
+
const identitySchema = Type.Intersect([
|
|
13
|
+
Type.Object({
|
|
14
|
+
id: Type.Optional(Type.String()),
|
|
15
|
+
name: Type.Optional(Type.String()),
|
|
16
|
+
email: Type.Optional(Type.String()),
|
|
17
|
+
avatar: Type.Optional(Type.String()),
|
|
18
|
+
}),
|
|
19
|
+
Type.Record(Type.String(), Type.Unknown()),
|
|
20
|
+
]);
|
|
21
|
+
const checkResultSchema = Type.Object({
|
|
22
|
+
authenticated: Type.Boolean(),
|
|
23
|
+
redirectTo: Type.Optional(authRedirectSchema),
|
|
24
|
+
error: Type.Optional(authErrorSchema),
|
|
25
|
+
logout: Type.Optional(Type.Boolean()),
|
|
26
|
+
}, { additionalProperties: false });
|
|
27
|
+
const errorResultSchema = Type.Object({
|
|
28
|
+
redirectTo: Type.Optional(authRedirectSchema),
|
|
29
|
+
logout: Type.Optional(Type.Boolean()),
|
|
30
|
+
}, { additionalProperties: false });
|
|
31
|
+
|
|
32
|
+
export type Identity = Static<typeof identitySchema>;
|
|
33
|
+
export type CheckResult = Static<typeof checkResultSchema>;
|
|
34
|
+
export type AuthErrorResult = Static<typeof errorResultSchema>;
|
|
35
|
+
|
|
36
|
+
export class AuthErrorHandlingError extends Error {
|
|
37
|
+
constructor(readonly code: 'AUTH_ERROR_HANDLER_FAILED' | 'INVALID_AUTH_ERROR_RESULT' | 'AUTH_LOGOUT_FAILED') {
|
|
38
|
+
super({
|
|
39
|
+
AUTH_ERROR_HANDLER_FAILED: 'Authentication error handling failed.',
|
|
40
|
+
INVALID_AUTH_ERROR_RESULT: 'Invalid authentication error handling result.',
|
|
41
|
+
AUTH_LOGOUT_FAILED: 'Authentication logout could not be confirmed.',
|
|
42
|
+
}[code]);
|
|
43
|
+
this.name = 'AuthErrorHandlingError';
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type AuthErrorHandlingResult =
|
|
48
|
+
| { status: 'ignored' | 'handled' | 'superseded' }
|
|
49
|
+
| { status: 'failed'; error: AuthErrorHandlingError };
|
|
50
|
+
|
|
51
|
+
export function decodeAuthErrorResult(value: unknown): AuthErrorResult {
|
|
52
|
+
try {
|
|
53
|
+
const candidate = snapshotPlainData(value);
|
|
54
|
+
if (checkExact(errorResultSchema, candidate)) return Object.freeze(candidate);
|
|
55
|
+
} catch {
|
|
56
|
+
// Invalid provider instructions never become navigation or logout requests.
|
|
57
|
+
}
|
|
58
|
+
throw new AuthErrorHandlingError('INVALID_AUTH_ERROR_RESULT');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export class AuthQueryError extends Error {
|
|
62
|
+
constructor(readonly code: 'AUTH_QUERY_FAILED' | 'INVALID_AUTH_IDENTITY' | 'INVALID_AUTH_CHECK') {
|
|
63
|
+
super({
|
|
64
|
+
AUTH_QUERY_FAILED: 'Authentication query failed.',
|
|
65
|
+
INVALID_AUTH_IDENTITY: 'Invalid authentication identity.',
|
|
66
|
+
INVALID_AUTH_CHECK: 'Invalid authentication check result.',
|
|
67
|
+
}[code]);
|
|
68
|
+
this.name = 'AuthQueryError';
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function decode<S extends TSchema>(schema: S, value: unknown, code: AuthQueryError['code']): Static<S> {
|
|
73
|
+
try {
|
|
74
|
+
const candidate = snapshotPlainData(value);
|
|
75
|
+
if (checkExact(schema, candidate)) return candidate;
|
|
76
|
+
} catch {
|
|
77
|
+
// Provider values and reflection errors must not escape the boundary.
|
|
78
|
+
}
|
|
79
|
+
throw new AuthQueryError(code);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function decodeIdentity(value: unknown): Identity | null {
|
|
83
|
+
if (value === null) return null;
|
|
84
|
+
return Object.freeze(decode(identitySchema, value, 'INVALID_AUTH_IDENTITY'));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function decodeAuthCheck(value: unknown): CheckResult {
|
|
88
|
+
const result = decode(checkResultSchema, value, 'INVALID_AUTH_CHECK');
|
|
89
|
+
if (result.authenticated && (result.logout === true || result.error !== undefined)) {
|
|
90
|
+
throw new AuthQueryError('INVALID_AUTH_CHECK');
|
|
91
|
+
}
|
|
92
|
+
// A validated string can still contain upstream credentials or private diagnostics.
|
|
93
|
+
if (result.error !== undefined) {
|
|
94
|
+
result.error = Object.freeze({ message: 'Authentication check failed.', name: 'AUTH_CHECK_REJECTED' });
|
|
95
|
+
}
|
|
96
|
+
return Object.freeze(result);
|
|
97
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { createMutation, type CreateMutationOptions, type MutateOptions } from '@tanstack/svelte-query';
|
|
2
|
+
import { replaceReactiveUnionMembers } from './reactive-projection';
|
|
3
|
+
|
|
4
|
+
interface Invocation<TParams, TScope> {
|
|
5
|
+
readonly params: TParams;
|
|
6
|
+
readonly scope: TScope | undefined;
|
|
7
|
+
readScope(): TScope;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Capture before the query runtime awaits callbacks, without keying state by user params. */
|
|
11
|
+
export function createCapturedMutation<TData, TParams extends object, TScope, TContext = unknown>(
|
|
12
|
+
capture: (params: TParams) => TScope,
|
|
13
|
+
options: () => CreateMutationOptions<TData, unknown, Invocation<TParams, TScope>, TContext>,
|
|
14
|
+
projectContext: (value: TContext | undefined) => unknown,
|
|
15
|
+
) {
|
|
16
|
+
function prepare(params: TParams): Invocation<TParams, TScope> {
|
|
17
|
+
try {
|
|
18
|
+
const scope = capture(params);
|
|
19
|
+
return Object.freeze({ params, scope, readScope: () => scope });
|
|
20
|
+
} catch (error) {
|
|
21
|
+
return Object.freeze({ params, scope: undefined, readScope: (): never => { throw error; } });
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
const mutation = createMutation<TData, unknown, Invocation<TParams, TScope>, TContext>(options);
|
|
25
|
+
function callbacks(value: MutateOptions<TData, unknown, TParams, unknown> = {}): MutateOptions<TData, unknown, Invocation<TParams, TScope>, TContext> {
|
|
26
|
+
return {
|
|
27
|
+
onSuccess: (data, invocation, context, execution) =>
|
|
28
|
+
value.onSuccess?.(data, invocation.params, projectContext(context), execution),
|
|
29
|
+
onError: (error, invocation, context, execution) =>
|
|
30
|
+
value.onError?.(error, invocation.params, projectContext(context), execution),
|
|
31
|
+
onSettled: (data, error, invocation, context, execution) =>
|
|
32
|
+
value.onSettled?.(data, error, invocation.params, projectContext(context), execution),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
return replaceReactiveUnionMembers(mutation, {
|
|
36
|
+
get variables() { return mutation.variables?.params; },
|
|
37
|
+
get context() { return projectContext(mutation.context); },
|
|
38
|
+
mutate(params: TParams, value?: MutateOptions<TData, unknown, TParams, unknown>): void {
|
|
39
|
+
mutation.mutate(prepare(params), callbacks(value));
|
|
40
|
+
},
|
|
41
|
+
mutateAsync(params: TParams, value?: MutateOptions<TData, unknown, TParams, unknown>): Promise<TData> {
|
|
42
|
+
return mutation.mutateAsync(prepare(params), callbacks(value));
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { Type, type TObject, type TSchema } from '@sinclair/typebox';
|
|
2
|
+
import { TypeGuard } from '@sinclair/typebox/type';
|
|
3
|
+
import { checkExact } from './schema-validation';
|
|
4
|
+
import { closeContractSchema, type SafeSchema, type SchemaValue } from './resource-contract';
|
|
5
|
+
import { snapshotPlainData } from './plain-data';
|
|
6
|
+
import { HttpError, type DataProvider } from './types';
|
|
7
|
+
|
|
8
|
+
const brand: unique symbol = Symbol('CommandContract');
|
|
9
|
+
export interface CommandContract<I extends TObject = TObject, O extends TSchema = TSchema> {
|
|
10
|
+
readonly name: string;
|
|
11
|
+
readonly [brand]: { input: I; output: O };
|
|
12
|
+
}
|
|
13
|
+
export type CommandInput<I extends TObject> = SchemaValue<I> extends infer Input extends Record<string, unknown> ? Input : Record<string, unknown>;
|
|
14
|
+
export type CommandOutput<O extends TSchema> = SchemaValue<O>;
|
|
15
|
+
interface Definition {
|
|
16
|
+
key: string;
|
|
17
|
+
url: string;
|
|
18
|
+
method: 'get' | 'post' | 'put' | 'patch' | 'delete';
|
|
19
|
+
input: TSchema;
|
|
20
|
+
response: TSchema;
|
|
21
|
+
}
|
|
22
|
+
const definitions = new WeakMap<CommandContract, Definition>();
|
|
23
|
+
let nextKey = 0;
|
|
24
|
+
|
|
25
|
+
function invalidContract(): never {
|
|
26
|
+
throw new HttpError('Invalid command contract', 400, undefined, { code: 'INVALID_COMMAND_CONTRACT' });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function matches(schema: TSchema, value: unknown): boolean {
|
|
30
|
+
return checkExact(schema, value);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function inputSnapshot(definition: Definition, input: unknown): Record<string, unknown> {
|
|
34
|
+
try {
|
|
35
|
+
const candidate = snapshotPlainData(input);
|
|
36
|
+
if (typeof candidate === 'object' && candidate !== null && !Array.isArray(candidate) &&
|
|
37
|
+
matches(definition.input, candidate)) return candidate;
|
|
38
|
+
} catch {
|
|
39
|
+
// Reflection and validation errors must not expose submitted values.
|
|
40
|
+
}
|
|
41
|
+
throw new HttpError('Invalid command input', 422, undefined, { code: 'INVALID_COMMAND_INPUT' });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function responseSnapshot(definition: Definition, response: unknown): { data: unknown } {
|
|
45
|
+
try {
|
|
46
|
+
const candidate = snapshotPlainData(response);
|
|
47
|
+
if (typeof candidate === 'object' && candidate !== null && !Array.isArray(candidate) &&
|
|
48
|
+
Object.hasOwn(candidate, 'data') && matches(definition.response, candidate)) {
|
|
49
|
+
return { data: candidate['data'] };
|
|
50
|
+
}
|
|
51
|
+
} catch {
|
|
52
|
+
// Never retain a rejected response or execute its getters/serialization hooks.
|
|
53
|
+
}
|
|
54
|
+
throw new HttpError('Invalid command response', 502, undefined, {
|
|
55
|
+
code: 'INVALID_COMMAND_RESPONSE',
|
|
56
|
+
details: { writeMayHaveSucceeded: definition.method !== 'get' },
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function executionError(error: unknown, writeMayHaveSucceeded: boolean): HttpError {
|
|
61
|
+
let status = 502;
|
|
62
|
+
try {
|
|
63
|
+
if (typeof error === 'object' && error !== null) {
|
|
64
|
+
const candidate: unknown = Object.getOwnPropertyDescriptor(error, 'statusCode')?.value;
|
|
65
|
+
if (typeof candidate === 'number' && Number.isInteger(candidate) && candidate >= 400 && candidate <= 599) {
|
|
66
|
+
status = candidate;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
} catch {
|
|
70
|
+
// Error reflection failures are not authentication instructions.
|
|
71
|
+
}
|
|
72
|
+
return new HttpError('Command execution failed', status, undefined, {
|
|
73
|
+
code: 'COMMAND_FAILED', details: { writeMayHaveSucceeded },
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function defineCommand<I extends TObject, O extends TSchema>(
|
|
78
|
+
name: string,
|
|
79
|
+
definition: {
|
|
80
|
+
url: string;
|
|
81
|
+
method: Definition['method'];
|
|
82
|
+
input: I & SafeSchema<I>;
|
|
83
|
+
output: O & SafeSchema<O>;
|
|
84
|
+
},
|
|
85
|
+
): CommandContract<I, O>;
|
|
86
|
+
export function defineCommand(name: string, definition: {
|
|
87
|
+
url: string; method: Definition['method']; input: TObject; output: TSchema;
|
|
88
|
+
}): CommandContract {
|
|
89
|
+
try {
|
|
90
|
+
if (typeof name !== 'string' || !name.trim() || typeof definition !== 'object' || definition === null) {
|
|
91
|
+
return invalidContract();
|
|
92
|
+
}
|
|
93
|
+
const fields = Object.getOwnPropertyDescriptors(definition);
|
|
94
|
+
for (const key of ['url', 'method', 'input', 'output']) {
|
|
95
|
+
const descriptor = fields[key];
|
|
96
|
+
if (!descriptor || !('value' in descriptor)) return invalidContract();
|
|
97
|
+
}
|
|
98
|
+
const url: unknown = fields['url']?.value;
|
|
99
|
+
const method: unknown = fields['method']?.value;
|
|
100
|
+
const inputSchema: unknown = fields['input']?.value;
|
|
101
|
+
const outputSchema: unknown = fields['output']?.value;
|
|
102
|
+
if (typeof url !== 'string' || !url.trim() ||
|
|
103
|
+
(method !== 'get' && method !== 'post' && method !== 'put' && method !== 'patch' && method !== 'delete') ||
|
|
104
|
+
!TypeGuard.IsObject(inputSchema) || !TypeGuard.IsSchema(outputSchema)) {
|
|
105
|
+
return invalidContract();
|
|
106
|
+
}
|
|
107
|
+
const input = closeContractSchema(inputSchema);
|
|
108
|
+
const response = Type.Object({ data: closeContractSchema(outputSchema) }, { additionalProperties: false });
|
|
109
|
+
const contract: CommandContract = Object.freeze({
|
|
110
|
+
name, [brand]: Object.freeze({ input: inputSchema, output: outputSchema }),
|
|
111
|
+
});
|
|
112
|
+
definitions.set(contract, {
|
|
113
|
+
key: JSON.stringify([++nextKey, name, url, method, input, response]),
|
|
114
|
+
url, method, input, response,
|
|
115
|
+
});
|
|
116
|
+
return contract;
|
|
117
|
+
} catch {
|
|
118
|
+
return invalidContract();
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function definitionOf(command: CommandContract): Definition {
|
|
123
|
+
const definition = definitions.get(command);
|
|
124
|
+
if (!definition) throw new HttpError('Use defineCommand to create a command contract', 400, undefined, { code: 'INVALID_COMMAND_CONTRACT' });
|
|
125
|
+
return definition;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function commandDefinition(command: CommandContract): Readonly<Pick<Definition, 'key' | 'url' | 'method'>> {
|
|
129
|
+
const { key, url, method } = definitionOf(command);
|
|
130
|
+
return Object.freeze({ key, url, method });
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function parseCommandInput<I extends TObject, O extends TSchema>(
|
|
134
|
+
command: CommandContract<I, O>, input: unknown,
|
|
135
|
+
): CommandInput<I>;
|
|
136
|
+
export function parseCommandInput(command: CommandContract, input: unknown): Record<string, unknown> {
|
|
137
|
+
return inputSnapshot(definitionOf(command), input);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function parseCommandResponse<I extends TObject, O extends TSchema>(
|
|
141
|
+
command: CommandContract<I, O>, response: unknown,
|
|
142
|
+
): { data: CommandOutput<O> };
|
|
143
|
+
export function parseCommandResponse(command: CommandContract, response: unknown): { data: unknown } {
|
|
144
|
+
return responseSnapshot(definitionOf(command), response);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Capture transport and checked input before a scheduler can yield to another scope. */
|
|
148
|
+
export function prepareCommand<I extends TObject, O extends TSchema>(
|
|
149
|
+
provider: DataProvider,
|
|
150
|
+
command: CommandContract<I, O>,
|
|
151
|
+
input: NoInfer<CommandInput<I>>,
|
|
152
|
+
): { readonly input: CommandInput<I>; execute(): Promise<{ data: CommandOutput<O> }> };
|
|
153
|
+
export function prepareCommand(
|
|
154
|
+
provider: DataProvider,
|
|
155
|
+
command: CommandContract,
|
|
156
|
+
input: Record<string, unknown>,
|
|
157
|
+
): { readonly input: Record<string, unknown>; execute(): Promise<{ data: unknown }> } {
|
|
158
|
+
const definition = definitionOf(command);
|
|
159
|
+
const payload = inputSnapshot(definition, input);
|
|
160
|
+
let custom: DataProvider['custom'];
|
|
161
|
+
try {
|
|
162
|
+
custom = provider.custom;
|
|
163
|
+
} catch (error) {
|
|
164
|
+
throw executionError(error, false);
|
|
165
|
+
}
|
|
166
|
+
if (typeof custom !== 'function') {
|
|
167
|
+
throw new HttpError('Provider does not support commands', 400, undefined, { code: 'COMMAND_NOT_SUPPORTED' });
|
|
168
|
+
}
|
|
169
|
+
const dispatch = custom;
|
|
170
|
+
return Object.freeze({
|
|
171
|
+
get input() { return inputSnapshot(definition, payload); },
|
|
172
|
+
async execute() {
|
|
173
|
+
let response: unknown;
|
|
174
|
+
try {
|
|
175
|
+
const input = inputSnapshot(definition, payload);
|
|
176
|
+
const pending: unknown = Reflect.apply(dispatch, provider, [{
|
|
177
|
+
url: definition.url, method: definition.method,
|
|
178
|
+
...(definition.method === 'get' ? { query: input } : { payload: input }),
|
|
179
|
+
}]);
|
|
180
|
+
response = await pending;
|
|
181
|
+
} catch (error) {
|
|
182
|
+
throw executionError(error, definition.method !== 'get');
|
|
183
|
+
}
|
|
184
|
+
return responseSnapshot(definition, response);
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export async function executeCommand<I extends TObject, O extends TSchema>(
|
|
190
|
+
provider: DataProvider,
|
|
191
|
+
command: CommandContract<I, O>,
|
|
192
|
+
input: NoInfer<CommandInput<I>>,
|
|
193
|
+
): Promise<{ data: CommandOutput<O> }> {
|
|
194
|
+
return prepareCommand(provider, command, input).execute();
|
|
195
|
+
}
|