@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,371 @@
|
|
|
1
|
+
import { Type, type Static, type TSchema } from '@sinclair/typebox';
|
|
2
|
+
import { checkExact, createExactSchemaValidator } from './schema-validation';
|
|
3
|
+
import { attachAbortSignal, detachAbortSignal, snapshotPlainData } from './plain-data';
|
|
4
|
+
import { decodeBaseRecord, decodeOneResult, decodeManyResult, decodeListResult } from './record-decoder';
|
|
5
|
+
import { HttpError } from './types';
|
|
6
|
+
import { captureResourceSchemas, captureResourceTransport } from './resource-transport-config';
|
|
7
|
+
import type {
|
|
8
|
+
BaseRecord, DataProvider, GetListParams, GetOneParams, GetManyParams,
|
|
9
|
+
CreateParams, CreateManyParams, UpdateParams, UpdateManyParams,
|
|
10
|
+
DeleteParams, DeleteManyParams,
|
|
11
|
+
ResourceInputOperation,
|
|
12
|
+
GetOneResult, GetManyResult, GetListResult,
|
|
13
|
+
} from './types';
|
|
14
|
+
|
|
15
|
+
export interface ResourceSchemas {
|
|
16
|
+
/** Schema for complete records returned by standard CRUD operations. */
|
|
17
|
+
record: TSchema;
|
|
18
|
+
/** Required to enable create/createMany. */
|
|
19
|
+
create?: TSchema;
|
|
20
|
+
/** Required to enable update/updateMany. */
|
|
21
|
+
update?: TSchema;
|
|
22
|
+
/** Without a schema, delete operations only accept undefined variables. */
|
|
23
|
+
delete?: TSchema;
|
|
24
|
+
/** Referenced schemas used by Type.Ref. */
|
|
25
|
+
references?: readonly TSchema[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type ResourceSchemaMap = Readonly<Record<string, ResourceSchemas>>;
|
|
29
|
+
export type InferSchemaResourceMap<TSchemas extends ResourceSchemaMap> = {
|
|
30
|
+
[R in keyof TSchemas]: unknown extends Static<TSchemas[R]['record']>
|
|
31
|
+
? BaseRecord : Static<TSchemas[R]['record']>;
|
|
32
|
+
};
|
|
33
|
+
export type InferSchemaInputMap<TSchemas extends ResourceSchemaMap> = {
|
|
34
|
+
[R in keyof TSchemas]: {
|
|
35
|
+
[O in ResourceInputOperation]: TSchemas[R] extends Record<O, infer Schema extends TSchema>
|
|
36
|
+
? unknown extends Static<Schema> ? unknown : Static<Schema>
|
|
37
|
+
: O extends 'delete' ? undefined : never;
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
type ResourceOperation = Exclude<keyof DataProvider, 'getApiUrl' | 'custom'>;
|
|
41
|
+
const identity = Type.Union([Type.String(), Type.Number()]);
|
|
42
|
+
const common = {
|
|
43
|
+
resource: Type.String({ minLength: 1 }),
|
|
44
|
+
meta: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
|
|
45
|
+
};
|
|
46
|
+
const filter = Type.Recursive(Self => Type.Union([
|
|
47
|
+
Type.Object({
|
|
48
|
+
operator: Type.Union([Type.Literal('or'), Type.Literal('and')]),
|
|
49
|
+
value: Type.Array(Self),
|
|
50
|
+
}, { additionalProperties: false }),
|
|
51
|
+
Type.Object({
|
|
52
|
+
field: Type.String(),
|
|
53
|
+
operator: Type.Union([
|
|
54
|
+
Type.Literal('eq'), Type.Literal('ne'), Type.Literal('lt'), Type.Literal('gt'),
|
|
55
|
+
Type.Literal('lte'), Type.Literal('gte'), Type.Literal('contains'), Type.Literal('ncontains'),
|
|
56
|
+
Type.Literal('startswith'), Type.Literal('endswith'), Type.Literal('in'), Type.Literal('nin'),
|
|
57
|
+
Type.Literal('null'), Type.Literal('nnull'), Type.Literal('between'), Type.Literal('nbetween'),
|
|
58
|
+
]),
|
|
59
|
+
value: Type.Unknown(),
|
|
60
|
+
}, { additionalProperties: false }),
|
|
61
|
+
]));
|
|
62
|
+
const requestSchemas = {
|
|
63
|
+
getList: Type.Object({
|
|
64
|
+
...common,
|
|
65
|
+
filters: Type.Optional(Type.Array(filter)),
|
|
66
|
+
sorters: Type.Optional(Type.Array(Type.Object({
|
|
67
|
+
field: Type.String(), order: Type.Union([Type.Literal('asc'), Type.Literal('desc')]),
|
|
68
|
+
}, { additionalProperties: false }))),
|
|
69
|
+
pagination: Type.Optional(Type.Object({
|
|
70
|
+
current: Type.Optional(Type.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER })),
|
|
71
|
+
pageSize: Type.Optional(Type.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER })),
|
|
72
|
+
mode: Type.Optional(Type.Union([Type.Literal('server'), Type.Literal('client'), Type.Literal('off')])),
|
|
73
|
+
}, { additionalProperties: false })),
|
|
74
|
+
}, { additionalProperties: false }),
|
|
75
|
+
getOne: Type.Object({ ...common, id: identity }, { additionalProperties: false }),
|
|
76
|
+
getMany: Type.Object({ ...common, ids: Type.Array(identity) }, { additionalProperties: false }),
|
|
77
|
+
create: Type.Object({ ...common, variables: Type.Unknown() }, { additionalProperties: false }),
|
|
78
|
+
createMany: Type.Object({ ...common, variables: Type.Array(Type.Unknown()) }, { additionalProperties: false }),
|
|
79
|
+
update: Type.Object({ ...common, id: identity, variables: Type.Unknown() }, { additionalProperties: false }),
|
|
80
|
+
updateMany: Type.Object({ ...common, ids: Type.Array(identity), variables: Type.Unknown() }, { additionalProperties: false }),
|
|
81
|
+
deleteOne: Type.Object({ ...common, id: identity, variables: Type.Optional(Type.Unknown()) }, { additionalProperties: false }),
|
|
82
|
+
deleteMany: Type.Object({ ...common, ids: Type.Array(identity), variables: Type.Optional(Type.Unknown()) }, { additionalProperties: false }),
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
function createSchemaBoundary(schemas: ResourceSchemaMap) {
|
|
86
|
+
const definitions = captureResourceSchemas(schemas);
|
|
87
|
+
const validators = new WeakMap<TSchema[], WeakMap<TSchema, ReturnType<typeof createExactSchemaValidator>>>();
|
|
88
|
+
const registry = new Map([...definitions].map(([resource, definition]) => {
|
|
89
|
+
// DataProvider records must remain objects even when a broad schema is supplied.
|
|
90
|
+
const record = Type.Intersect([Type.Object({}), definition.record]);
|
|
91
|
+
return [resource, {
|
|
92
|
+
...definition,
|
|
93
|
+
references: [...(definition.references ?? [])],
|
|
94
|
+
one: Type.Object({ data: record }),
|
|
95
|
+
many: Type.Object({ data: Type.Array(record) }),
|
|
96
|
+
list: Type.Object({ data: Type.Array(record), total: Type.Integer({ minimum: 0, maximum: Number.MAX_SAFE_INTEGER }) }),
|
|
97
|
+
}];
|
|
98
|
+
}));
|
|
99
|
+
|
|
100
|
+
function required(resource: string, operation: string): never {
|
|
101
|
+
throw new HttpError('A resource schema is required for this operation', 400, undefined, {
|
|
102
|
+
code: 'RESOURCE_SCHEMA_REQUIRED',
|
|
103
|
+
details: { resource, operation, phase: 'input', writeMayHaveSucceeded: false },
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function resolve(resource: string, operation: ResourceOperation) {
|
|
108
|
+
return registry.get(resource) ?? required(resource, operation);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function invalidData(operation: ResourceOperation, phase: 'input' | 'response', resource = '', writeMayHaveSucceeded = false): never {
|
|
112
|
+
throw new HttpError(phase === 'input' ? 'Invalid resource input' : 'Invalid provider response',
|
|
113
|
+
phase === 'input' ? 422 : 502, undefined, {
|
|
114
|
+
code: phase === 'input' ? 'INVALID_RESOURCE_INPUT' : 'INVALID_PROVIDER_RESPONSE',
|
|
115
|
+
details: { resource, operation, phase, writeMayHaveSucceeded },
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function captureRequest<S extends TSchema>(schema: S, value: unknown, operation: ResourceOperation): Static<S> {
|
|
120
|
+
try {
|
|
121
|
+
const detached = detachAbortSignal(value);
|
|
122
|
+
const signal = detached.signal;
|
|
123
|
+
let input = detached.rest;
|
|
124
|
+
if ((operation === 'deleteOne' || operation === 'deleteMany') && typeof value === 'object' && value !== null) {
|
|
125
|
+
// Undefined delete variables represent an absent body, not a JSON value.
|
|
126
|
+
const descriptors = Object.getOwnPropertyDescriptors(value);
|
|
127
|
+
const descriptor = descriptors['variables'];
|
|
128
|
+
const variable: unknown = descriptor && 'value' in descriptor ? descriptor.value : null;
|
|
129
|
+
if (descriptor?.enumerable && variable === undefined) {
|
|
130
|
+
const prototype: unknown = Object.getPrototypeOf(value);
|
|
131
|
+
if (Array.isArray(value) || (prototype !== Object.prototype && prototype !== null)) return invalidData(operation, 'input');
|
|
132
|
+
delete descriptors['variables'];
|
|
133
|
+
input = Object.defineProperties({}, descriptors);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const candidate = snapshotPlainData(input);
|
|
137
|
+
if (checkExact(schema, candidate) && typeof candidate === 'object' && candidate !== null) {
|
|
138
|
+
return attachAbortSignal(candidate, signal);
|
|
139
|
+
}
|
|
140
|
+
} catch {
|
|
141
|
+
// Capture failures are input errors and must not run submitted accessors.
|
|
142
|
+
}
|
|
143
|
+
return invalidData(operation, 'input');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function validate(
|
|
147
|
+
schema: TSchema,
|
|
148
|
+
references: TSchema[],
|
|
149
|
+
value: unknown,
|
|
150
|
+
resource: string,
|
|
151
|
+
operation: ResourceOperation,
|
|
152
|
+
phase: 'input' | 'response',
|
|
153
|
+
writeMayHaveSucceeded = false,
|
|
154
|
+
): void {
|
|
155
|
+
let issues: { path: string; message: string }[];
|
|
156
|
+
try {
|
|
157
|
+
let scopedValidators = validators.get(references);
|
|
158
|
+
if (!scopedValidators) {
|
|
159
|
+
scopedValidators = new WeakMap();
|
|
160
|
+
validators.set(references, scopedValidators);
|
|
161
|
+
}
|
|
162
|
+
let validator = scopedValidators.get(schema);
|
|
163
|
+
if (!validator) {
|
|
164
|
+
validator = createExactSchemaValidator(schema, references);
|
|
165
|
+
scopedValidators.set(schema, validator);
|
|
166
|
+
}
|
|
167
|
+
if (validator.Check(value)) return;
|
|
168
|
+
// Do not include rejected values or raw responses in diagnostic details.
|
|
169
|
+
issues = [...validator.Errors(value)]
|
|
170
|
+
.map(({ path, message }) => ({ path, message }));
|
|
171
|
+
} catch {
|
|
172
|
+
throw new HttpError('Unable to evaluate resource schema', 500, undefined, {
|
|
173
|
+
code: 'RESOURCE_SCHEMA_INVALID',
|
|
174
|
+
details: { resource, operation, phase, writeMayHaveSucceeded },
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
const errors = phase === 'input' ? Object.fromEntries(issues.map(({ path, message }) => [
|
|
178
|
+
path.slice(1).split('/').map(part => part.replace(/~1/g, '/').replace(/~0/g, '~')).join('.') || '_root',
|
|
179
|
+
message,
|
|
180
|
+
])) : undefined;
|
|
181
|
+
throw new HttpError(
|
|
182
|
+
phase === 'input' ? 'Invalid resource input' : 'Invalid provider response',
|
|
183
|
+
phase === 'input' ? 422 : 502,
|
|
184
|
+
errors,
|
|
185
|
+
{
|
|
186
|
+
code: phase === 'input' ? 'INVALID_RESOURCE_INPUT' : 'INVALID_PROVIDER_RESPONSE',
|
|
187
|
+
details: { resource, operation, phase, issues, writeMayHaveSucceeded },
|
|
188
|
+
},
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function input(
|
|
193
|
+
resource: string,
|
|
194
|
+
operation: ResourceOperation,
|
|
195
|
+
kind: 'create' | 'update' | 'delete',
|
|
196
|
+
variables: unknown,
|
|
197
|
+
batch = false,
|
|
198
|
+
) {
|
|
199
|
+
const definition = resolve(resource, operation);
|
|
200
|
+
const schema = definition[kind] ?? (kind === 'delete' ? Type.Undefined() : required(resource, operation));
|
|
201
|
+
validate(batch ? Type.Array(schema) : schema, definition.references, variables, resource, operation, 'input');
|
|
202
|
+
return definition;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function output(definition: ReturnType<typeof resolve>, resource: string, operation: ResourceOperation,
|
|
206
|
+
kind: 'one', result: unknown, writeMayHaveSucceeded?: boolean): GetOneResult;
|
|
207
|
+
function output(definition: ReturnType<typeof resolve>, resource: string, operation: ResourceOperation,
|
|
208
|
+
kind: 'many', result: unknown, writeMayHaveSucceeded?: boolean): GetManyResult;
|
|
209
|
+
function output(definition: ReturnType<typeof resolve>, resource: string, operation: ResourceOperation,
|
|
210
|
+
kind: 'list', result: unknown, writeMayHaveSucceeded?: boolean): GetListResult;
|
|
211
|
+
function output(
|
|
212
|
+
definition: ReturnType<typeof resolve>,
|
|
213
|
+
resource: string,
|
|
214
|
+
operation: ResourceOperation,
|
|
215
|
+
kind: 'one' | 'many' | 'list',
|
|
216
|
+
result: unknown,
|
|
217
|
+
writeMayHaveSucceeded = false,
|
|
218
|
+
): GetOneResult | GetManyResult | GetListResult {
|
|
219
|
+
let captured: unknown;
|
|
220
|
+
try { captured = snapshotPlainData(result); }
|
|
221
|
+
catch { return invalidData(operation, 'response', resource, writeMayHaveSucceeded); }
|
|
222
|
+
validate(definition[kind], definition.references, captured, resource, operation, 'response', writeMayHaveSucceeded);
|
|
223
|
+
switch (kind) {
|
|
224
|
+
case 'one': return decodeOneResult(captured, decodeBaseRecord, writeMayHaveSucceeded);
|
|
225
|
+
case 'many': return decodeManyResult(captured, decodeBaseRecord, writeMayHaveSucceeded);
|
|
226
|
+
case 'list': return decodeListResult(captured, decodeBaseRecord);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function prepare<O extends ResourceOperation>(operation: O, request: unknown) {
|
|
231
|
+
if (typeof operation !== 'string' || !Object.hasOwn(requestSchemas, operation)) {
|
|
232
|
+
throw new HttpError('Invalid resource operation', 422, undefined, {
|
|
233
|
+
code: 'INVALID_RESOURCE_INPUT', details: { phase: 'input', writeMayHaveSucceeded: false },
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
const params = captureRequest(requestSchemas[operation], request, operation);
|
|
237
|
+
const definition = resolve(params.resource, operation);
|
|
238
|
+
const variables = 'variables' in params ? params.variables : undefined;
|
|
239
|
+
switch (operation) {
|
|
240
|
+
case 'create': case 'createMany':
|
|
241
|
+
input(params.resource, operation, 'create', variables, operation === 'createMany');
|
|
242
|
+
break;
|
|
243
|
+
case 'update': case 'updateMany':
|
|
244
|
+
input(params.resource, operation, 'update', variables);
|
|
245
|
+
break;
|
|
246
|
+
case 'deleteOne': case 'deleteMany':
|
|
247
|
+
input(params.resource, operation, 'delete', variables);
|
|
248
|
+
break;
|
|
249
|
+
}
|
|
250
|
+
return { params, definition };
|
|
251
|
+
}
|
|
252
|
+
return { prepare, output, required };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Internal contract composition reuses input validation, never an unchecked transport. */
|
|
256
|
+
export function createResourceRequestValidator(schemas: ResourceSchemaMap) {
|
|
257
|
+
const boundary = createSchemaBoundary(schemas);
|
|
258
|
+
return <O extends ResourceOperation>(operation: O, request: unknown) => boundary.prepare(operation, request).params;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function providerFailure(cause: unknown, resource: string, operation: ResourceOperation): HttpError {
|
|
262
|
+
let status = 502;
|
|
263
|
+
try {
|
|
264
|
+
if (typeof cause === 'object' && cause !== null) {
|
|
265
|
+
const candidate: unknown = Object.getOwnPropertyDescriptor(cause, 'statusCode')?.value;
|
|
266
|
+
if (typeof candidate === 'number' && Number.isInteger(candidate) && candidate >= 400 && candidate <= 599) {
|
|
267
|
+
status = candidate;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
} catch {
|
|
271
|
+
// Reflection failures do not expose provider values or supply authentication status.
|
|
272
|
+
}
|
|
273
|
+
return new HttpError('Resource provider failed', status, undefined, {
|
|
274
|
+
code: 'RESOURCE_PROVIDER_FAILED',
|
|
275
|
+
details: { resource, operation, phase: 'transport', writeMayHaveSucceeded: !operation.startsWith('get') },
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Validates standard CRUD payloads and responses without coercing or stripping data.
|
|
281
|
+
* Unregistered resources, unconfigured writes, and custom endpoints fail closed.
|
|
282
|
+
*/
|
|
283
|
+
export function withResourceSchemas(provider: DataProvider, schemas: ResourceSchemaMap): Readonly<DataProvider> {
|
|
284
|
+
const { prepare, output, required } = createSchemaBoundary(schemas);
|
|
285
|
+
const transport = captureResourceTransport(provider);
|
|
286
|
+
async function dispatch(resource: string, operation: ResourceOperation, invoke: () => unknown): Promise<unknown> {
|
|
287
|
+
try { return await invoke(); }
|
|
288
|
+
catch (cause: unknown) { throw providerFailure(cause, resource, operation); }
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const adapted: DataProvider = {
|
|
292
|
+
getApiUrl: () => {
|
|
293
|
+
try {
|
|
294
|
+
const url = transport.getApiUrl();
|
|
295
|
+
if (typeof url === 'string') return url;
|
|
296
|
+
} catch {
|
|
297
|
+
// Configuration diagnostics must not disclose arbitrary provider return values.
|
|
298
|
+
}
|
|
299
|
+
throw new HttpError('Invalid resource provider URL', 502, undefined, { code: 'INVALID_DATA_PROVIDER' });
|
|
300
|
+
},
|
|
301
|
+
getList: async (request: GetListParams) => {
|
|
302
|
+
const { params, definition } = prepare('getList', request);
|
|
303
|
+
return output(definition, params.resource, 'getList', 'list',
|
|
304
|
+
await dispatch(params.resource, 'getList', () => transport.getList(params)));
|
|
305
|
+
},
|
|
306
|
+
getOne: async (request: GetOneParams) => {
|
|
307
|
+
const { params, definition } = prepare('getOne', request);
|
|
308
|
+
return output(definition, params.resource, 'getOne', 'one',
|
|
309
|
+
await dispatch(params.resource, 'getOne', () => transport.getOne(params)));
|
|
310
|
+
},
|
|
311
|
+
create: async (request: CreateParams<unknown>) => {
|
|
312
|
+
const { params, definition } = prepare('create', request);
|
|
313
|
+
const resource = params.resource;
|
|
314
|
+
return output(definition, resource, 'create', 'one',
|
|
315
|
+
await dispatch(resource, 'create', () => transport.create(params)), true);
|
|
316
|
+
},
|
|
317
|
+
update: async (request: UpdateParams<unknown>) => {
|
|
318
|
+
const { params, definition } = prepare('update', request);
|
|
319
|
+
const resource = params.resource;
|
|
320
|
+
return output(definition, resource, 'update', 'one',
|
|
321
|
+
await dispatch(resource, 'update', () => transport.update(params)), true);
|
|
322
|
+
},
|
|
323
|
+
deleteOne: async (request: DeleteParams<unknown>) => {
|
|
324
|
+
const { params, definition } = prepare('deleteOne', request);
|
|
325
|
+
const resource = params.resource;
|
|
326
|
+
return output(definition, resource, 'deleteOne', 'one',
|
|
327
|
+
await dispatch(resource, 'deleteOne', () => transport.deleteOne(params)), true);
|
|
328
|
+
},
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
const getMany = transport.getMany;
|
|
332
|
+
if (getMany) {
|
|
333
|
+
adapted.getMany = async (request: GetManyParams) => {
|
|
334
|
+
const { params, definition } = prepare('getMany', request);
|
|
335
|
+
return output(definition, params.resource, 'getMany', 'many',
|
|
336
|
+
await dispatch(params.resource, 'getMany', () => getMany(params)));
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
const createMany = transport.createMany;
|
|
340
|
+
if (createMany) {
|
|
341
|
+
adapted.createMany = async (request: CreateManyParams<unknown>) => {
|
|
342
|
+
const { params, definition } = prepare('createMany', request);
|
|
343
|
+
const resource = params.resource;
|
|
344
|
+
return output(definition, resource, 'createMany', 'many',
|
|
345
|
+
await dispatch(resource, 'createMany', () => createMany(params)), true);
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
const updateMany = transport.updateMany;
|
|
349
|
+
if (updateMany) {
|
|
350
|
+
adapted.updateMany = async (request: UpdateManyParams<unknown>) => {
|
|
351
|
+
const { params, definition } = prepare('updateMany', request);
|
|
352
|
+
const resource = params.resource;
|
|
353
|
+
return output(definition, resource, 'updateMany', 'many',
|
|
354
|
+
await dispatch(resource, 'updateMany', () => updateMany(params)), true);
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
const deleteMany = transport.deleteMany;
|
|
358
|
+
if (deleteMany) {
|
|
359
|
+
adapted.deleteMany = async (request: DeleteManyParams<unknown>) => {
|
|
360
|
+
const { params, definition } = prepare('deleteMany', request);
|
|
361
|
+
const resource = params.resource;
|
|
362
|
+
return output(definition, resource, 'deleteMany', 'many',
|
|
363
|
+
await dispatch(resource, 'deleteMany', () => deleteMany(params)), true);
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
if (transport.custom) {
|
|
367
|
+
adapted.custom = async () => required('', 'custom');
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
return Object.freeze(adapted);
|
|
371
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { Hint, Kind, OptionalKind, ReadonlyKind, type TSchema } from '@sinclair/typebox';
|
|
2
|
+
import { TypeGuard } from '@sinclair/typebox/type';
|
|
3
|
+
import { HttpError } from './types';
|
|
4
|
+
import type { DataProvider } from './types';
|
|
5
|
+
import type { ResourceSchemas } from './resource-schemas';
|
|
6
|
+
|
|
7
|
+
function invalidSchema(): never {
|
|
8
|
+
throw new HttpError('Invalid resource schema configuration', 400, undefined, {
|
|
9
|
+
code: 'RESOURCE_SCHEMA_INVALID', details: { phase: 'configuration', writeMayHaveSucceeded: false },
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
function invalidProvider(): never {
|
|
13
|
+
throw new HttpError('Invalid resource provider', 400, undefined, {
|
|
14
|
+
code: 'INVALID_DATA_PROVIDER', details: { phase: 'configuration', writeMayHaveSucceeded: false },
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Configuration can carry TypeBox markers, but no getters, functions or foreign symbols. */
|
|
19
|
+
function copySchemaData(value: unknown, ancestors = new Set<object>(), depth = 0): unknown {
|
|
20
|
+
if (depth > 128) return invalidSchema();
|
|
21
|
+
if (value === null || value === undefined || typeof value === 'string' || typeof value === 'boolean') return value;
|
|
22
|
+
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
|
23
|
+
if (typeof value !== 'object' || value === null || ancestors.has(value)) return invalidSchema();
|
|
24
|
+
const array = Array.isArray(value);
|
|
25
|
+
const prototype: unknown = Object.getPrototypeOf(value);
|
|
26
|
+
if (!array && prototype !== Object.prototype && prototype !== null) return invalidSchema();
|
|
27
|
+
ancestors.add(value);
|
|
28
|
+
try {
|
|
29
|
+
const descriptors = Object.getOwnPropertyDescriptors(value);
|
|
30
|
+
const read = (key: PropertyKey): unknown => {
|
|
31
|
+
const descriptor: PropertyDescriptor | undefined = Reflect.get(descriptors, key);
|
|
32
|
+
if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) return invalidSchema();
|
|
33
|
+
const field: unknown = descriptor.value;
|
|
34
|
+
return copySchemaData(field, ancestors, depth + 1);
|
|
35
|
+
};
|
|
36
|
+
if (array) {
|
|
37
|
+
const length: unknown = descriptors['length']?.value;
|
|
38
|
+
if (typeof length !== 'number' || !Number.isInteger(length) || length < 0 ||
|
|
39
|
+
Reflect.ownKeys(descriptors).length !== length + 1) return invalidSchema();
|
|
40
|
+
return Array.from({ length }, (_, index) => read(String(index)));
|
|
41
|
+
}
|
|
42
|
+
const copy: Record<PropertyKey, unknown> = {};
|
|
43
|
+
for (const key of Reflect.ownKeys(descriptors)) {
|
|
44
|
+
if (typeof key === 'symbol' && key !== Kind && key !== OptionalKind && key !== ReadonlyKind && key !== Hint) {
|
|
45
|
+
return invalidSchema();
|
|
46
|
+
}
|
|
47
|
+
const field = read(key);
|
|
48
|
+
if (typeof key === 'symbol' && typeof field !== 'string') return invalidSchema();
|
|
49
|
+
Object.defineProperty(copy, key, { value: field, enumerable: true, configurable: true, writable: true });
|
|
50
|
+
}
|
|
51
|
+
return copy;
|
|
52
|
+
} finally {
|
|
53
|
+
ancestors.delete(value);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function schema(value: unknown): TSchema {
|
|
58
|
+
const copy = copySchemaData(value);
|
|
59
|
+
if (!TypeGuard.IsSchema(copy)) return invalidSchema();
|
|
60
|
+
return copy;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function fields(value: unknown): Map<string, unknown> {
|
|
64
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return invalidSchema();
|
|
65
|
+
const prototype: unknown = Object.getPrototypeOf(value);
|
|
66
|
+
if ((prototype !== Object.prototype && prototype !== null) || Object.getOwnPropertySymbols(value).length) return invalidSchema();
|
|
67
|
+
const result = new Map<string, unknown>();
|
|
68
|
+
for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(value))) {
|
|
69
|
+
if (!descriptor.enumerable || !('value' in descriptor)) return invalidSchema();
|
|
70
|
+
const field: unknown = descriptor.value;
|
|
71
|
+
result.set(key, field);
|
|
72
|
+
}
|
|
73
|
+
return result;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Copy each schema before lazy validators or transport callbacks can observe source mutations. */
|
|
77
|
+
export function captureResourceSchemas(value: unknown): Map<string, ResourceSchemas> {
|
|
78
|
+
try {
|
|
79
|
+
const captured = new Map<string, ResourceSchemas>();
|
|
80
|
+
for (const [resource, definition] of fields(value)) {
|
|
81
|
+
if (!resource.trim()) return invalidSchema();
|
|
82
|
+
const input = fields(definition);
|
|
83
|
+
if ([...input.keys()].some(key => !['record', 'create', 'update', 'delete', 'references'].includes(key))) return invalidSchema();
|
|
84
|
+
const record = schema(input.get('record'));
|
|
85
|
+
const references: TSchema[] = [];
|
|
86
|
+
if (input.has('references')) {
|
|
87
|
+
const copied = copySchemaData(input.get('references'));
|
|
88
|
+
if (!Array.isArray(copied)) return invalidSchema();
|
|
89
|
+
const candidates: unknown[] = copied;
|
|
90
|
+
for (const candidate of candidates) {
|
|
91
|
+
if (!TypeGuard.IsSchema(candidate)) return invalidSchema();
|
|
92
|
+
references.push(candidate);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
captured.set(resource, {
|
|
96
|
+
record, references,
|
|
97
|
+
...(input.has('create') ? { create: schema(input.get('create')) } : {}),
|
|
98
|
+
...(input.has('update') ? { update: schema(input.get('update')) } : {}),
|
|
99
|
+
...(input.has('delete') ? { delete: schema(input.get('delete')) } : {}),
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
return captured;
|
|
103
|
+
} catch {
|
|
104
|
+
return invalidSchema();
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Class prototype methods are supported without evaluating property accessors. */
|
|
109
|
+
function member(provider: object, name: string): { value: unknown } | undefined {
|
|
110
|
+
let target: object | null = provider;
|
|
111
|
+
const seen = new Set<object>();
|
|
112
|
+
for (let depth = 0; target !== null && target !== Object.prototype && depth < 32; depth++) {
|
|
113
|
+
if (seen.has(target)) return invalidProvider();
|
|
114
|
+
seen.add(target);
|
|
115
|
+
const descriptor = Object.getOwnPropertyDescriptor(target, name);
|
|
116
|
+
if (descriptor) {
|
|
117
|
+
if (!('value' in descriptor)) return invalidProvider();
|
|
118
|
+
const value: unknown = descriptor.value;
|
|
119
|
+
return { value };
|
|
120
|
+
}
|
|
121
|
+
const prototype: unknown = Object.getPrototypeOf(target);
|
|
122
|
+
if (prototype !== null && typeof prototype !== 'object') return invalidProvider();
|
|
123
|
+
target = prototype;
|
|
124
|
+
}
|
|
125
|
+
if (target !== null && target !== Object.prototype) return invalidProvider();
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
type Invocation = (...args: unknown[]) => unknown;
|
|
130
|
+
export function captureResourceTransport(provider: unknown) {
|
|
131
|
+
try {
|
|
132
|
+
if (typeof provider !== 'object' || provider === null || Array.isArray(provider)) return invalidProvider();
|
|
133
|
+
const owner = provider;
|
|
134
|
+
function capture(name: keyof DataProvider, required: true): Invocation;
|
|
135
|
+
function capture(name: keyof DataProvider, required: false): Invocation | undefined;
|
|
136
|
+
function capture(name: keyof DataProvider, required: boolean): Invocation | undefined {
|
|
137
|
+
const found = member(owner, name);
|
|
138
|
+
if (!required && found === undefined) return undefined;
|
|
139
|
+
const captured = found?.value;
|
|
140
|
+
if (typeof captured !== 'function') return invalidProvider();
|
|
141
|
+
return (...args: unknown[]): unknown => {
|
|
142
|
+
return Reflect.apply(captured, owner, args);
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
return Object.freeze({
|
|
146
|
+
getApiUrl: capture('getApiUrl', true), getList: capture('getList', true),
|
|
147
|
+
getOne: capture('getOne', true), create: capture('create', true),
|
|
148
|
+
update: capture('update', true), deleteOne: capture('deleteOne', true),
|
|
149
|
+
getMany: capture('getMany', false), createMany: capture('createMany', false),
|
|
150
|
+
updateMany: capture('updateMany', false), deleteMany: capture('deleteMany', false),
|
|
151
|
+
custom: capture('custom', false),
|
|
152
|
+
});
|
|
153
|
+
} catch {
|
|
154
|
+
return invalidProvider();
|
|
155
|
+
}
|
|
156
|
+
}
|
package/src/route-parsing.ts
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
export type ParsedResourceAction =
|
|
2
|
-
action: 'list' | 'create'
|
|
3
|
-
id
|
|
4
|
-
};
|
|
1
|
+
export type ParsedResourceAction =
|
|
2
|
+
| { action: 'list' | 'create' }
|
|
3
|
+
| { action: 'edit' | 'show' | 'clone'; id: string };
|
|
5
4
|
|
|
6
5
|
export function parseResourceActionSegments(restSegments: string[]): ParsedResourceAction {
|
|
7
|
-
|
|
8
|
-
if (
|
|
9
|
-
if (
|
|
10
|
-
if (
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
return { action: '
|
|
6
|
+
const [first, second] = restSegments;
|
|
7
|
+
if (first === undefined) return { action: 'list' };
|
|
8
|
+
if (first === 'create') return { action: 'create' };
|
|
9
|
+
if ((first === 'edit' || first === 'show' || first === 'clone') && second) {
|
|
10
|
+
return { action: first, id: second };
|
|
11
|
+
}
|
|
12
|
+
if (second === 'edit') return { action: 'edit', id: first };
|
|
13
|
+
return { action: 'show', id: first };
|
|
14
14
|
}
|