@svadmin/core 0.50.0 → 0.53.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 +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 -776
- package/src/http.ts +88 -87
- package/src/i18n.svelte.ts +64 -2
- package/src/import-contract.ts +106 -0
- package/src/import-hooks.svelte.ts +310 -0
- package/src/index.ts +77 -30
- package/src/inferencer-graphql.ts +7 -6
- package/src/inferencer.ts +9 -0
- 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 -288
- package/src/query-invalidation.ts +172 -0
- package/src/query-keys.ts +221 -110
- package/src/query-session.svelte.ts +220 -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 +103 -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 +190 -180
- 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 +68 -110
- 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
- package/src/schema-form.ts +0 -31
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
import { CloneType, Kind, TransformKind, Type, type Static, type TObject, type TSchema } from '@sinclair/typebox';
|
|
2
|
+
import { checkExact, createExactSchemaValidator } from './schema-validation';
|
|
3
|
+
import { TypeGuard } from '@sinclair/typebox/type';
|
|
4
|
+
import { DeleteManyPartialError, HttpError, type DataProvider, type KnownResources, type BaseRecord,
|
|
5
|
+
type GetManyParams, type CreateManyParams, type UpdateManyParams, type DeleteManyParams,
|
|
6
|
+
type GetOneResult, type GetManyResult } from './types';
|
|
7
|
+
import { withResourceSchemas, createResourceRequestValidator, type ResourceSchemas } from './resource-schemas';
|
|
8
|
+
import { decodeBaseRecord } from './record-decoder';
|
|
9
|
+
import { snapshotPlainData } from './plain-data';
|
|
10
|
+
|
|
11
|
+
export interface ContractSchemas extends ResourceSchemas {
|
|
12
|
+
record: TObject;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const brand: unique symbol = Symbol('ResourceContract');
|
|
16
|
+
export interface ResourceContract<S extends ContractSchemas = ContractSchemas> {
|
|
17
|
+
readonly name: KnownResources;
|
|
18
|
+
readonly [brand]: S;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Class instances preserve identity when a contract is stored in Svelte state.
|
|
22
|
+
class DefinedResource implements ResourceContract {
|
|
23
|
+
readonly name: KnownResources;
|
|
24
|
+
readonly [brand]: ContractSchemas;
|
|
25
|
+
|
|
26
|
+
constructor(name: KnownResources, schemas: ContractSchemas) {
|
|
27
|
+
this.name = name;
|
|
28
|
+
this[brand] = schemas;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type ErasedContractRecord = BaseRecord & { id: string | number };
|
|
33
|
+
export type ContractRecord<S extends ContractSchemas> = string extends keyof Static<S['record']> ? ErasedContractRecord
|
|
34
|
+
: SchemaValue<S['record']> extends infer Row extends BaseRecord ? Row : BaseRecord;
|
|
35
|
+
export type ContractId<S extends ContractSchemas> = ContractRecord<S> extends { id: infer Id }
|
|
36
|
+
? Extract<Id, string | number> : never;
|
|
37
|
+
export type ContractInput<S extends ContractSchemas, O extends 'create' | 'update' | 'delete'> =
|
|
38
|
+
S extends Record<O, infer Schema extends TSchema> ? SchemaValue<Schema>
|
|
39
|
+
: O extends 'delete' ? undefined : never;
|
|
40
|
+
export type ContractFormInput<S extends ContractSchemas, A extends 'create' | 'edit'> =
|
|
41
|
+
Extract<ContractInput<S, A extends 'edit' ? 'update' : 'create'>, Record<string, unknown>>;
|
|
42
|
+
export type ContractFormAction = 'create' | 'edit' | 'clone' | 'show';
|
|
43
|
+
export type ContractFormValues<S extends ContractSchemas, A extends ContractFormAction> =
|
|
44
|
+
ContractSchemas extends S ? Record<string, unknown> : A extends 'show' ? ContractRecord<S>
|
|
45
|
+
: ContractFormInput<S, A extends 'edit' ? 'edit' : 'create'>;
|
|
46
|
+
export type ContractFormDraft<S extends ContractSchemas, A extends ContractFormAction> =
|
|
47
|
+
Partial<Record<Extract<keyof ContractFormValues<S, A>, string>, unknown>>;
|
|
48
|
+
|
|
49
|
+
type IsAny<T> = 0 extends (1 & T) ? true : false;
|
|
50
|
+
type Unsafe<T> = IsAny<T> extends true ? true
|
|
51
|
+
: unknown extends T ? true
|
|
52
|
+
: T extends readonly (infer Item)[] ? Unsafe<Item>
|
|
53
|
+
: T extends object ? string extends keyof T ? true : true extends {
|
|
54
|
+
[K in keyof T]-?: Unsafe<T[K]>
|
|
55
|
+
}[keyof T] ? true : false
|
|
56
|
+
: false;
|
|
57
|
+
export type SafeSchema<S extends TSchema> = true extends Unsafe<Static<S>> ? never : S;
|
|
58
|
+
type SafeValue<T> = IsAny<T> extends true ? unknown
|
|
59
|
+
: T extends object ? { [K in keyof T]: SafeValue<T[K]> } : T;
|
|
60
|
+
export type SchemaValue<S extends TSchema> = SafeValue<Static<S>>;
|
|
61
|
+
type CheckedSchemas<S extends ContractSchemas> = {
|
|
62
|
+
[K in keyof S]: S[K] extends TSchema ? SafeSchema<S[K]> : S[K];
|
|
63
|
+
} & (ContractRecord<S> extends { id: string | number } ? unknown : never);
|
|
64
|
+
|
|
65
|
+
interface Definition {
|
|
66
|
+
schemas: ContractSchemas;
|
|
67
|
+
key: string;
|
|
68
|
+
providers: WeakMap<DataProvider, Readonly<DataProvider>>;
|
|
69
|
+
}
|
|
70
|
+
const definitions = new WeakMap<ResourceContract, Definition>();
|
|
71
|
+
let nextKey = 0;
|
|
72
|
+
|
|
73
|
+
// A deliberately closed JSON subset keeps runtime validation aligned with Static.
|
|
74
|
+
export function closeContractSchema<S extends TSchema>(schema: S): S {
|
|
75
|
+
const copy = CloneType(schema);
|
|
76
|
+
const visit = (node: TSchema): void => {
|
|
77
|
+
if (TransformKind in node) {
|
|
78
|
+
throw new HttpError('Contract transforms require a separate decoding boundary', 400, undefined, { code: 'INVALID_RESOURCE_CONTRACT' });
|
|
79
|
+
}
|
|
80
|
+
if (!TypeGuard.IsSchema(node) || !['Object', 'Array', 'Tuple', 'Union', 'String', 'Number', 'Integer', 'Boolean', 'Null', 'Literal', 'Never'].includes(node[Kind])) {
|
|
81
|
+
throw new HttpError('Unsupported or unconstrained contract schema', 400, undefined, { code: 'INVALID_RESOURCE_CONTRACT' });
|
|
82
|
+
}
|
|
83
|
+
if (TypeGuard.IsObject(node)) {
|
|
84
|
+
if (node.additionalProperties !== undefined && node.additionalProperties !== false) {
|
|
85
|
+
throw new HttpError('Contract objects must be closed', 400, undefined, { code: 'INVALID_RESOURCE_CONTRACT' });
|
|
86
|
+
}
|
|
87
|
+
node.additionalProperties = false;
|
|
88
|
+
for (const property of Object.values(node.properties)) visit(property);
|
|
89
|
+
}
|
|
90
|
+
if (TypeGuard.IsArray(node)) visit(node.items);
|
|
91
|
+
if (TypeGuard.IsTuple(node)) for (const item of node.items ?? []) visit(item);
|
|
92
|
+
if (TypeGuard.IsUnion(node)) for (const item of node.anyOf) visit(item);
|
|
93
|
+
};
|
|
94
|
+
visit(copy);
|
|
95
|
+
return copy;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function defineResource<const S extends ContractSchemas>(
|
|
99
|
+
name: KnownResources,
|
|
100
|
+
schemas: S & CheckedSchemas<S>,
|
|
101
|
+
): ResourceContract<S>;
|
|
102
|
+
export function defineResource(name: KnownResources, schemas: ContractSchemas): ResourceContract {
|
|
103
|
+
const isId = (schema: TSchema): boolean => (
|
|
104
|
+
['String', 'Number', 'Integer'].includes(schema[Kind])
|
|
105
|
+
|| (TypeGuard.IsLiteral(schema) && ['string', 'number'].includes(typeof schema.const))
|
|
106
|
+
|| (TypeGuard.IsUnion(schema) && schema.anyOf.every(isId))
|
|
107
|
+
);
|
|
108
|
+
if (!name.trim() || schemas.record[Kind] !== 'Object'
|
|
109
|
+
|| !schemas.record.required?.includes('id')
|
|
110
|
+
|| !schemas.record.properties['id']
|
|
111
|
+
|| !isId(schemas.record.properties['id'])) {
|
|
112
|
+
throw new HttpError('A resource contract requires a name and a required string/number id', 400, undefined, { code: 'INVALID_RESOURCE_CONTRACT' });
|
|
113
|
+
}
|
|
114
|
+
const record = closeContractSchema(schemas.record);
|
|
115
|
+
const definition: ContractSchemas = {
|
|
116
|
+
record,
|
|
117
|
+
...(schemas.create ? { create: closeContractSchema(schemas.create) } : {}),
|
|
118
|
+
...(schemas.update ? { update: closeContractSchema(schemas.update) } : {}),
|
|
119
|
+
...(schemas.delete ? { delete: closeContractSchema(schemas.delete) } : {}),
|
|
120
|
+
};
|
|
121
|
+
const contract: ResourceContract = Object.freeze(new DefinedResource(name, schemas));
|
|
122
|
+
// Include the schema so persisted caches cannot reuse an ordinal after a contract revision.
|
|
123
|
+
definitions.set(contract, { schemas: definition, key: JSON.stringify([++nextKey, name, definition]), providers: new WeakMap() });
|
|
124
|
+
return contract;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function definitionOf(contract: ResourceContract): Definition {
|
|
128
|
+
const definition = definitions.get(contract);
|
|
129
|
+
if (!definition) throw new HttpError('Use defineResource to create a resource contract', 400, undefined, { code: 'INVALID_RESOURCE_CONTRACT' });
|
|
130
|
+
return definition;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function contractKey(contract: ResourceContract): string {
|
|
134
|
+
return definitionOf(contract).key;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Resource metadata must carry a real contract, never a schema inferred from display fields. */
|
|
138
|
+
export function resolveResourceContract(resource: {
|
|
139
|
+
name: string;
|
|
140
|
+
identifier?: string;
|
|
141
|
+
contract?: ResourceContract;
|
|
142
|
+
primaryKey?: string;
|
|
143
|
+
}): ResourceContract {
|
|
144
|
+
const contract = resource.contract;
|
|
145
|
+
if (!contract || contract.name !== (resource.identifier ?? resource.name) || (resource.primaryKey !== undefined && resource.primaryKey !== 'id')) {
|
|
146
|
+
throw new HttpError('Resource metadata requires a matching id-based contract', 400, undefined, { code: 'RESOURCE_CONTRACT_REQUIRED' });
|
|
147
|
+
}
|
|
148
|
+
definitionOf(contract);
|
|
149
|
+
return contract;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function validateContractId(contract: ResourceContract, value: unknown): void {
|
|
153
|
+
check(idSchema(definitionOf(contract).schemas), value, 'id');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The private schema snapshot is the runtime evidence for the branded record type. */
|
|
157
|
+
export function parseContractRecord<S extends ContractSchemas>(contract: ResourceContract<S>, value: unknown): ContractRecord<S>;
|
|
158
|
+
export function parseContractRecord(contract: ResourceContract, value: unknown): BaseRecord {
|
|
159
|
+
const schema = definitionOf(contract).schemas.record;
|
|
160
|
+
try {
|
|
161
|
+
const record = decodeBaseRecord(snapshotPlainData(value));
|
|
162
|
+
if (checkExact(schema, record)) return record;
|
|
163
|
+
} catch {
|
|
164
|
+
// Malformed transport values must not escape as raw reflection errors.
|
|
165
|
+
}
|
|
166
|
+
throw new HttpError('Invalid provider response', 502, undefined, { code: 'INVALID_PROVIDER_RESPONSE' });
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function idSchema(schemas: ContractSchemas): TSchema {
|
|
170
|
+
const schema = schemas.record.properties['id'];
|
|
171
|
+
if (!schema) throw new HttpError('Missing contract ID schema', 400, undefined, { code: 'INVALID_RESOURCE_CONTRACT' });
|
|
172
|
+
return schema;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// The private definition is an immutable snapshot of the schema carried by the contract.
|
|
176
|
+
export function parseContractId<S extends ContractSchemas>(contract: ResourceContract<S>, value: unknown): ContractId<S>;
|
|
177
|
+
export function parseContractId(contract: ResourceContract, value: unknown): string | number {
|
|
178
|
+
validateContractId(contract, value);
|
|
179
|
+
if (typeof value !== 'string' && typeof value !== 'number') {
|
|
180
|
+
throw new HttpError('Invalid contract ID', 422, undefined, { code: 'INVALID_RESOURCE_INPUT' });
|
|
181
|
+
}
|
|
182
|
+
return value;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const routeIdPrefix = '~svadmin-id:';
|
|
186
|
+
const routeIdShape = Type.Union([
|
|
187
|
+
Type.Tuple([Type.Literal('number'), Type.Number()]),
|
|
188
|
+
Type.Tuple([Type.Literal('string'), Type.String()]),
|
|
189
|
+
]);
|
|
190
|
+
|
|
191
|
+
/** Newly generated links preserve the identity type even when both ID kinds are valid. */
|
|
192
|
+
export function formatContractRouteId<S extends ContractSchemas>(contract: ResourceContract<S>, value: ContractId<S>): string {
|
|
193
|
+
const id = parseContractId(contract, value);
|
|
194
|
+
return `${routeIdPrefix}${JSON.stringify([typeof id, id])}`;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Bare numeric-only links require an exact canonical conversion; tagged links preserve both types. */
|
|
198
|
+
export function parseContractRouteId<S extends ContractSchemas>(contract: ResourceContract<S>, value: string): ContractId<S> {
|
|
199
|
+
if (value.startsWith(routeIdPrefix)) {
|
|
200
|
+
try {
|
|
201
|
+
const decoded: unknown = JSON.parse(value.slice(routeIdPrefix.length));
|
|
202
|
+
if (!checkExact(routeIdShape, decoded)) {
|
|
203
|
+
throw new Error('Invalid tagged identity');
|
|
204
|
+
}
|
|
205
|
+
return parseContractId(contract, decoded[1]);
|
|
206
|
+
} catch {
|
|
207
|
+
throw new HttpError('Invalid resource identity', 422, undefined, { code: 'INVALID_RESOURCE_INPUT' });
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
try { return parseContractId(contract, value); }
|
|
211
|
+
catch {
|
|
212
|
+
const numeric = Number(value);
|
|
213
|
+
if (!Number.isFinite(numeric) || String(numeric) !== value) {
|
|
214
|
+
throw new HttpError('Invalid resource identity', 422, undefined, { code: 'INVALID_RESOURCE_INPUT' });
|
|
215
|
+
}
|
|
216
|
+
return parseContractId(contract, numeric);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export type ContractDeleteInput<S extends ContractSchemas> =
|
|
221
|
+
ContractSchemas extends S ? unknown : ContractInput<S, 'delete'>;
|
|
222
|
+
export type ContractUpdateInput<S extends ContractSchemas> =
|
|
223
|
+
ContractSchemas extends S ? unknown : ContractInput<S, 'update'>;
|
|
224
|
+
export type ContractCreateInput<S extends ContractSchemas> =
|
|
225
|
+
ContractSchemas extends S ? unknown : ContractInput<S, 'create'>;
|
|
226
|
+
|
|
227
|
+
export function requireContractCreateSchema(contract: ResourceContract): void {
|
|
228
|
+
if (!definitionOf(contract).schemas.create) {
|
|
229
|
+
throw new HttpError('A create schema is required', 400, undefined, { code: 'INVALID_RESOURCE_CONTRACT' });
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Imported metadata values require the private create schema before dispatch. */
|
|
234
|
+
export function parseContractCreateInput<S extends ContractSchemas>(
|
|
235
|
+
contract: ResourceContract<S>, value: unknown,
|
|
236
|
+
): ContractCreateInput<S>;
|
|
237
|
+
export function parseContractCreateInput(contract: ResourceContract, value: unknown): unknown {
|
|
238
|
+
requireContractCreateSchema(contract);
|
|
239
|
+
const schema = definitionOf(contract).schemas.create ?? Type.Never();
|
|
240
|
+
try {
|
|
241
|
+
const candidate = snapshotPlainData(value);
|
|
242
|
+
check(schema, candidate, 'variables');
|
|
243
|
+
return candidate;
|
|
244
|
+
} catch {
|
|
245
|
+
throw new HttpError('Invalid create input', 422, undefined, {
|
|
246
|
+
code: 'INVALID_RESOURCE_INPUT', details: { phase: 'input', writeMayHaveSucceeded: false },
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Unknown metadata payloads must pass the private update schema before dispatch. */
|
|
252
|
+
export function parseContractUpdateInput<S extends ContractSchemas>(
|
|
253
|
+
contract: ResourceContract<S>, value: unknown,
|
|
254
|
+
): ContractUpdateInput<S>;
|
|
255
|
+
export function parseContractUpdateInput(contract: ResourceContract, value: unknown): unknown {
|
|
256
|
+
const schema = definitionOf(contract).schemas.update ?? Type.Never();
|
|
257
|
+
try {
|
|
258
|
+
const candidate = snapshotPlainData(value);
|
|
259
|
+
check(schema, candidate, 'variables');
|
|
260
|
+
return candidate;
|
|
261
|
+
} catch {
|
|
262
|
+
throw new HttpError('Invalid update input', 422, undefined, {
|
|
263
|
+
code: 'INVALID_RESOURCE_INPUT', details: { phase: 'input', writeMayHaveSucceeded: false },
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Erased metadata contracts keep deletion input unknown until the private schema checks it. */
|
|
269
|
+
export function parseContractDeleteInput<S extends ContractSchemas>(
|
|
270
|
+
contract: ResourceContract<S>, value: unknown,
|
|
271
|
+
): ContractDeleteInput<S>;
|
|
272
|
+
export function parseContractDeleteInput(contract: ResourceContract, value: unknown): unknown {
|
|
273
|
+
const schema = definitionOf(contract).schemas.delete ?? Type.Undefined();
|
|
274
|
+
try {
|
|
275
|
+
const candidate = value === undefined ? undefined : snapshotPlainData(value);
|
|
276
|
+
check(schema, candidate, 'variables');
|
|
277
|
+
return candidate;
|
|
278
|
+
} catch {
|
|
279
|
+
throw new HttpError('Invalid deletion input', 422, undefined, {
|
|
280
|
+
code: 'INVALID_RESOURCE_INPUT', details: { phase: 'input', writeMayHaveSucceeded: false },
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export function contractFormValues(contract: ResourceContract, action: string, value: Record<string, unknown>): Record<string, unknown> {
|
|
286
|
+
const schemas = definitionOf(contract).schemas;
|
|
287
|
+
const schema = action === 'edit' ? schemas.update : schemas.create;
|
|
288
|
+
if (!schema || !TypeGuard.IsObject(schema)) {
|
|
289
|
+
throw new HttpError('Forms require an object input schema for their action', 400, undefined, { code: 'INVALID_RESOURCE_CONTRACT' });
|
|
290
|
+
}
|
|
291
|
+
const projected = Object.fromEntries(Object.keys(schema.properties)
|
|
292
|
+
.filter(key => Object.hasOwn(value, key)).map(key => [key, value[key]]));
|
|
293
|
+
check(schema, projected, 'values');
|
|
294
|
+
return projected;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function formSchema(contract: ResourceContract, action: ContractFormAction): TObject {
|
|
298
|
+
const schemas = definitionOf(contract).schemas;
|
|
299
|
+
const schema = action === 'show' ? schemas.record : action === 'edit' ? schemas.update : schemas.create;
|
|
300
|
+
if (!schema || !TypeGuard.IsObject(schema)) {
|
|
301
|
+
throw new HttpError('Forms require an object schema for their action', 400, undefined, { code: 'INVALID_RESOURCE_CONTRACT' });
|
|
302
|
+
}
|
|
303
|
+
return schema;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export function getContractFormFields<S extends ContractSchemas, A extends ContractFormAction>(
|
|
307
|
+
contract: ResourceContract<S>, action: A,
|
|
308
|
+
): Extract<keyof ContractFormValues<S, A>, string>[];
|
|
309
|
+
export function getContractFormFields(contract: ResourceContract, action: ContractFormAction): string[] {
|
|
310
|
+
return Object.keys(formSchema(contract, action).properties);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Drafts prove only plain-data shape and field ownership, never a complete business payload. */
|
|
314
|
+
export function snapshotContractFormDraft<S extends ContractSchemas, A extends ContractFormAction>(
|
|
315
|
+
contract: ResourceContract<S>, action: A, value: unknown,
|
|
316
|
+
): ContractFormDraft<S, A>;
|
|
317
|
+
export function snapshotContractFormDraft(contract: ResourceContract, action: ContractFormAction, value: unknown): Record<string, unknown> {
|
|
318
|
+
const fields = getContractFormFields(contract, action);
|
|
319
|
+
try {
|
|
320
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value) ||
|
|
321
|
+
Object.getOwnPropertySymbols(value).length ||
|
|
322
|
+
![Object.prototype, null].includes(Object.getPrototypeOf(value))) throw new TypeError('Invalid draft');
|
|
323
|
+
const entries: [string, unknown][] = [];
|
|
324
|
+
for (const [key, property] of Object.entries(Object.getOwnPropertyDescriptors(value))) {
|
|
325
|
+
if (!fields.includes(key) || !property.enumerable || !('value' in property)) throw new TypeError('Invalid field');
|
|
326
|
+
const field: unknown = property.value;
|
|
327
|
+
if (field !== undefined) entries.push([key, field]);
|
|
328
|
+
}
|
|
329
|
+
return decodeBaseRecord(snapshotPlainData(Object.fromEntries(entries)));
|
|
330
|
+
} catch {
|
|
331
|
+
throw new HttpError('Invalid form draft', 422, undefined, { code: 'INVALID_RESOURCE_INPUT' });
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Return field names rather than transport values or schema-generated error text. */
|
|
336
|
+
export function contractFormInvalidFields(contract: ResourceContract, action: ContractFormAction, value: unknown): string[] {
|
|
337
|
+
const schema = formSchema(contract, action);
|
|
338
|
+
const validator = createExactSchemaValidator(schema);
|
|
339
|
+
const paths = Array.from(validator.Errors(value), error => error.path);
|
|
340
|
+
const fields = Object.keys(schema.properties).filter(field => {
|
|
341
|
+
const path = `/${field.replaceAll('~', '~0').replaceAll('/', '~1')}`;
|
|
342
|
+
return paths.some(candidate => candidate === path || candidate.startsWith(`${path}/`));
|
|
343
|
+
});
|
|
344
|
+
return fields.length ? fields : validator.Check(value) ? [] : ['_root'];
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** The private input schema is the evidence for the operation-specific form type. */
|
|
348
|
+
export function parseContractFormInput<S extends ContractSchemas, A extends 'create' | 'edit'>(
|
|
349
|
+
contract: ResourceContract<S>, action: A, value: unknown,
|
|
350
|
+
): ContractFormInput<S, A>;
|
|
351
|
+
export function parseContractFormInput(contract: ResourceContract, action: 'create' | 'edit', value: unknown): BaseRecord {
|
|
352
|
+
const schemas = definitionOf(contract).schemas;
|
|
353
|
+
const schema = action === 'edit' ? schemas.update : schemas.create;
|
|
354
|
+
if (!schema || !TypeGuard.IsObject(schema)) {
|
|
355
|
+
throw new HttpError('Forms require an object input schema for their action', 400, undefined, { code: 'INVALID_RESOURCE_CONTRACT' });
|
|
356
|
+
}
|
|
357
|
+
check(schema, value, 'values');
|
|
358
|
+
return decodeBaseRecord(value);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function check(schema: TSchema, value: unknown, path: string): void {
|
|
362
|
+
if (!checkExact(schema, value)) {
|
|
363
|
+
throw new HttpError('Invalid resource request', 422, { [path]: 'Value does not match the resource contract' }, {
|
|
364
|
+
code: 'INVALID_RESOURCE_INPUT', details: { phase: 'input', path, writeMayHaveSucceeded: false },
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const mutationEnvelope = Type.Object({
|
|
370
|
+
variables: Type.Optional(Type.Unknown()),
|
|
371
|
+
meta: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
|
|
372
|
+
dataProviderName: Type.Optional(Type.String()),
|
|
373
|
+
}, { additionalProperties: false });
|
|
374
|
+
const batchMutationEnvelope = Type.Object({
|
|
375
|
+
...mutationEnvelope.properties,
|
|
376
|
+
ids: Type.Array(Type.Union([Type.String(), Type.Number()])),
|
|
377
|
+
}, { additionalProperties: false });
|
|
378
|
+
|
|
379
|
+
/** Contract-bound writes cannot override the resource, ID, or lifecycle policy. */
|
|
380
|
+
export function validateContractMutationParams(contract: ResourceContract | undefined, params: unknown, batch = false): void {
|
|
381
|
+
if (!contract) return;
|
|
382
|
+
definitionOf(contract);
|
|
383
|
+
check(batch ? batchMutationEnvelope : mutationEnvelope, params, 'mutation');
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const filterGroup = Type.Object({
|
|
387
|
+
operator: Type.Union([Type.Literal('and'), Type.Literal('or')]),
|
|
388
|
+
value: Type.Array(Type.Unknown()),
|
|
389
|
+
}, { additionalProperties: false });
|
|
390
|
+
const fieldFilter = Type.Object({
|
|
391
|
+
field: Type.String(), operator: Type.String(), value: Type.Unknown(),
|
|
392
|
+
}, { additionalProperties: false });
|
|
393
|
+
|
|
394
|
+
function checkFilters(filters: unknown, record: TObject, depth = 0): void {
|
|
395
|
+
if (depth > 32 || !checkExact(Type.Array(Type.Unknown()), filters)) {
|
|
396
|
+
check(Type.Never(), filters, 'filters');
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
for (const filter of filters) {
|
|
400
|
+
if (checkExact(filterGroup, filter)) {
|
|
401
|
+
checkFilters(filter.value, record, depth + 1);
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
if (!checkExact(fieldFilter, filter)) {
|
|
405
|
+
check(Type.Never(), filter, 'filters');
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
if (!Object.hasOwn(record.properties, filter.field)) {
|
|
409
|
+
check(Type.Never(), filter, 'filters.field');
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
const field = record.properties[filter.field];
|
|
413
|
+
if (!field) {
|
|
414
|
+
check(Type.Never(), filter, 'filters.field');
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
const scalar = Type.Exclude(field, Type.Null());
|
|
418
|
+
switch (filter.operator) {
|
|
419
|
+
case 'eq': case 'ne': check(field, filter.value, filter.field); break;
|
|
420
|
+
case 'in': case 'nin': check(Type.Array(field), filter.value, filter.field); break;
|
|
421
|
+
case 'null': case 'nnull': check(Type.Null(), filter.value, filter.field); break;
|
|
422
|
+
case 'contains': case 'ncontains': case 'startswith': case 'endswith':
|
|
423
|
+
check(Type.Intersect([scalar, Type.String()]), filter.value, filter.field); break;
|
|
424
|
+
case 'lt': case 'lte': case 'gt': case 'gte':
|
|
425
|
+
check(Type.Intersect([scalar, Type.Union([Type.String(), Type.Number()])]), filter.value, filter.field); break;
|
|
426
|
+
case 'between': case 'nbetween':
|
|
427
|
+
check(Type.Tuple([
|
|
428
|
+
Type.Intersect([scalar, Type.Union([Type.String(), Type.Number()])]),
|
|
429
|
+
Type.Intersect([scalar, Type.Union([Type.String(), Type.Number()])]),
|
|
430
|
+
]), filter.value, filter.field); break;
|
|
431
|
+
default: check(Type.Never(), filter, 'filters.operator');
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** Internal transport boundary; public hooks never accept a raw provider as proof of a contract. */
|
|
437
|
+
export function contractProvider(
|
|
438
|
+
provider: DataProvider,
|
|
439
|
+
contract: ResourceContract,
|
|
440
|
+
options: { cache?: boolean } = {},
|
|
441
|
+
): Readonly<DataProvider> {
|
|
442
|
+
const definition = definitionOf(contract);
|
|
443
|
+
const cached = options.cache === false ? undefined : definition.providers.get(provider);
|
|
444
|
+
if (cached) return cached;
|
|
445
|
+
const { schemas } = definition;
|
|
446
|
+
const registry = { [contract.name]: schemas };
|
|
447
|
+
const checked = withResourceSchemas(provider, registry);
|
|
448
|
+
const prepare = createResourceRequestValidator(registry);
|
|
449
|
+
const id = idSchema(schemas);
|
|
450
|
+
const distinctIds = Type.Array(id, { uniqueItems: true });
|
|
451
|
+
type Operation = Exclude<keyof DataProvider, 'getApiUrl' | 'custom'>;
|
|
452
|
+
function invalidReceipt(operation: Operation): never {
|
|
453
|
+
throw new HttpError('Invalid provider response', 502, undefined, {
|
|
454
|
+
code: 'INVALID_PROVIDER_RESPONSE',
|
|
455
|
+
details: { resource: contract.name, operation, phase: 'response',
|
|
456
|
+
writeMayHaveSucceeded: !['getList', 'getOne', 'getMany'].includes(operation) },
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
function suppliedId(variables: unknown, expected?: string | number): string | number | undefined {
|
|
460
|
+
const value: unknown = typeof variables === 'object' && variables !== null
|
|
461
|
+
? Object.getOwnPropertyDescriptor(variables, 'id')?.value : undefined;
|
|
462
|
+
if (value === undefined) return undefined;
|
|
463
|
+
const supplied = parseContractId(contract, value);
|
|
464
|
+
if (expected !== undefined && supplied !== expected) check(Type.Never(), value, 'variables.id');
|
|
465
|
+
return supplied;
|
|
466
|
+
}
|
|
467
|
+
function oneReceipt(result: GetOneResult, operation: Operation, expected: string | number | undefined) {
|
|
468
|
+
if (expected !== undefined && result.data['id'] !== expected) return invalidReceipt(operation);
|
|
469
|
+
return result;
|
|
470
|
+
}
|
|
471
|
+
function distinctRecords(records: readonly BaseRecord[], operation: Operation): void {
|
|
472
|
+
if (new Set(records.map(record => record['id'])).size !== records.length) invalidReceipt(operation);
|
|
473
|
+
}
|
|
474
|
+
// Records are already detached and schema-checked by the captured inner provider.
|
|
475
|
+
function manyReceipt(result: GetManyResult, operation: Operation,
|
|
476
|
+
expected: readonly (string | number | undefined)[], match: 'identity-set' | 'create-order') {
|
|
477
|
+
if (result.data.length !== expected.length) return invalidReceipt(operation);
|
|
478
|
+
distinctRecords(result.data, operation);
|
|
479
|
+
const expectedIds = new Set<unknown>(expected);
|
|
480
|
+
for (const [index, record] of result.data.entries()) {
|
|
481
|
+
if (match === 'identity-set' ? !expectedIds.has(record['id'])
|
|
482
|
+
: expected[index] !== undefined && record['id'] !== expected[index]) return invalidReceipt(operation);
|
|
483
|
+
}
|
|
484
|
+
return result;
|
|
485
|
+
}
|
|
486
|
+
function preflight(params: { resource: string; filters?: unknown; sorters?: unknown; pagination?: unknown }) {
|
|
487
|
+
if (params.resource !== contract.name) check(Type.Never(), params.resource, 'resource');
|
|
488
|
+
if (params.filters !== undefined) checkFilters(params.filters, schemas.record);
|
|
489
|
+
if (params.sorters !== undefined) {
|
|
490
|
+
check(Type.Array(Type.Object({
|
|
491
|
+
field: Type.Union(Object.keys(schemas.record.properties).map(key => Type.Literal(key))),
|
|
492
|
+
order: Type.Union([Type.Literal('asc'), Type.Literal('desc')]),
|
|
493
|
+
}, { additionalProperties: false })), params.sorters, 'sorters');
|
|
494
|
+
}
|
|
495
|
+
if (params.pagination !== undefined) check(Type.Object({
|
|
496
|
+
current: Type.Optional(Type.Integer({ minimum: 1 })),
|
|
497
|
+
pageSize: Type.Optional(Type.Integer({ minimum: 1 })),
|
|
498
|
+
mode: Type.Optional(Type.Union(['server', 'client', 'off'].map(mode => Type.Literal(mode)))),
|
|
499
|
+
}, { additionalProperties: false }), params.pagination, 'pagination');
|
|
500
|
+
}
|
|
501
|
+
async function collectRecords(pending: Promise<{ data: BaseRecord }>[]) {
|
|
502
|
+
const results = await Promise.allSettled(pending);
|
|
503
|
+
return { data: results.map(result => {
|
|
504
|
+
if (result.status === 'fulfilled') return result.value.data;
|
|
505
|
+
const cause: unknown = result.reason;
|
|
506
|
+
throw cause;
|
|
507
|
+
}) };
|
|
508
|
+
}
|
|
509
|
+
// Validate the complete request locally. Only the inner provider invokes raw
|
|
510
|
+
// methods and normalizes their failures; local validation keeps its diagnostics.
|
|
511
|
+
const transport: DataProvider = {
|
|
512
|
+
...(checked.custom ? { custom: checked.custom } : {}),
|
|
513
|
+
getApiUrl: checked.getApiUrl,
|
|
514
|
+
getList: async request => {
|
|
515
|
+
const params = prepare('getList', request);
|
|
516
|
+
preflight(params);
|
|
517
|
+
const result = await checked.getList(params);
|
|
518
|
+
distinctRecords(result.data, 'getList');
|
|
519
|
+
return result;
|
|
520
|
+
},
|
|
521
|
+
getOne: async request => {
|
|
522
|
+
const params = prepare('getOne', request);
|
|
523
|
+
preflight(params); check(id, params.id, 'id');
|
|
524
|
+
return oneReceipt(await checked.getOne(params), 'getOne', params.id);
|
|
525
|
+
},
|
|
526
|
+
create: async request => {
|
|
527
|
+
const params = prepare('create', request);
|
|
528
|
+
preflight(params);
|
|
529
|
+
const expected = suppliedId(params.variables);
|
|
530
|
+
return oneReceipt(await checked.create(params), 'create', expected);
|
|
531
|
+
},
|
|
532
|
+
update: async request => {
|
|
533
|
+
const params = prepare('update', request);
|
|
534
|
+
preflight(params); check(id, params.id, 'id'); suppliedId(params.variables, params.id);
|
|
535
|
+
return oneReceipt(await checked.update(params), 'update', params.id);
|
|
536
|
+
},
|
|
537
|
+
deleteOne: async request => {
|
|
538
|
+
const params = prepare('deleteOne', request);
|
|
539
|
+
preflight(params); check(id, params.id, 'id');
|
|
540
|
+
return oneReceipt(await checked.deleteOne(params), 'deleteOne', params.id);
|
|
541
|
+
},
|
|
542
|
+
getMany: async (request: GetManyParams) => {
|
|
543
|
+
const params = prepare('getMany', request);
|
|
544
|
+
preflight(params); check(distinctIds, params.ids, 'ids');
|
|
545
|
+
if (checked.getMany) return manyReceipt(await checked.getMany(params), 'getMany', params.ids, 'identity-set');
|
|
546
|
+
const { ids, ...single } = params;
|
|
547
|
+
return collectRecords(ids.map(async id => oneReceipt(await checked.getOne({ ...single, id }), 'getMany', id)));
|
|
548
|
+
},
|
|
549
|
+
createMany: async (request: CreateManyParams<unknown>) => {
|
|
550
|
+
const params = prepare('createMany', request);
|
|
551
|
+
preflight(params);
|
|
552
|
+
const expected = params.variables.map(variables => suppliedId(variables));
|
|
553
|
+
const supplied = expected.filter(value => value !== undefined);
|
|
554
|
+
check(distinctIds, supplied, 'variables.id');
|
|
555
|
+
const result = checked.createMany ? await checked.createMany(params)
|
|
556
|
+
: await collectRecords(params.variables.map(async (variables, index) =>
|
|
557
|
+
oneReceipt(await checked.create({ ...params, variables }), 'createMany', expected[index])));
|
|
558
|
+
return manyReceipt(result, 'createMany', expected, 'create-order');
|
|
559
|
+
},
|
|
560
|
+
updateMany: async (request: UpdateManyParams<unknown>) => {
|
|
561
|
+
const params = prepare('updateMany', request);
|
|
562
|
+
preflight(params); check(distinctIds, params.ids, 'ids');
|
|
563
|
+
const supplied = suppliedId(params.variables);
|
|
564
|
+
if (supplied !== undefined && params.ids.some(id => id !== supplied)) check(Type.Never(), supplied, 'variables.id');
|
|
565
|
+
if (checked.updateMany) return manyReceipt(await checked.updateMany(params), 'updateMany', params.ids, 'identity-set');
|
|
566
|
+
const { ids, ...single } = params;
|
|
567
|
+
return collectRecords(ids.map(async id => oneReceipt(await checked.update({ ...single, id }), 'updateMany', id)));
|
|
568
|
+
},
|
|
569
|
+
deleteMany: async (request: DeleteManyParams<unknown>) => {
|
|
570
|
+
const params = prepare('deleteMany', request);
|
|
571
|
+
preflight(params); check(distinctIds, params.ids, 'ids');
|
|
572
|
+
if (checked.deleteMany) return manyReceipt(await checked.deleteMany(params), 'deleteMany', params.ids, 'identity-set');
|
|
573
|
+
const { ids, ...single } = params;
|
|
574
|
+
const results = await Promise.all(ids.map(async item => {
|
|
575
|
+
try {
|
|
576
|
+
const result = oneReceipt(await checked.deleteOne({ ...single, id: item }), 'deleteMany', item);
|
|
577
|
+
return { id: item, ok: true, data: result.data } as const;
|
|
578
|
+
}
|
|
579
|
+
catch (cause: unknown) { return { id: item, ok: false, cause } as const; }
|
|
580
|
+
}));
|
|
581
|
+
const succeeded: (string | number)[] = [];
|
|
582
|
+
const failed: (string | number)[] = [];
|
|
583
|
+
const causes: unknown[] = [];
|
|
584
|
+
const data = results.flatMap(result => {
|
|
585
|
+
if (result.ok) { succeeded.push(result.id); return [result.data]; }
|
|
586
|
+
failed.push(result.id); causes.push(result.cause); return [];
|
|
587
|
+
});
|
|
588
|
+
if (failed.length) throw new DeleteManyPartialError(succeeded, failed, causes);
|
|
589
|
+
return { data };
|
|
590
|
+
},
|
|
591
|
+
};
|
|
592
|
+
const adapted = Object.freeze(transport);
|
|
593
|
+
if (options.cache !== false) definition.providers.set(provider, adapted);
|
|
594
|
+
return adapted;
|
|
595
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { CreateMutationResult } from '@tanstack/svelte-query';
|
|
2
|
+
import type { OvertimeResult } from './hook-utils.svelte';
|
|
3
|
+
import type { InferResourceInput, ResourceInputOperation } from './types';
|
|
4
|
+
|
|
5
|
+
// An omitted target must accept the payload for every possible bound resource.
|
|
6
|
+
type CommonInput<R extends string, O extends ResourceInputOperation> =
|
|
7
|
+
(R extends string ? (input: InferResourceInput<R, O>) => void : never) extends
|
|
8
|
+
(input: infer Input) => void ? Input : never;
|
|
9
|
+
|
|
10
|
+
type Variables<Input, O extends ResourceInputOperation, Batch extends boolean> =
|
|
11
|
+
[Input] extends [never] ? { variables: never }
|
|
12
|
+
: Batch extends true ? { variables: Input[] }
|
|
13
|
+
: O extends 'delete'
|
|
14
|
+
? undefined extends Input ? { variables?: Input } : { variables: Input }
|
|
15
|
+
: { variables: Input };
|
|
16
|
+
|
|
17
|
+
export type ResourceMutationParams<
|
|
18
|
+
R extends string,
|
|
19
|
+
O extends ResourceInputOperation,
|
|
20
|
+
Params,
|
|
21
|
+
Batch extends boolean = false,
|
|
22
|
+
> = Omit<Params, 'resource' | 'variables'> & (
|
|
23
|
+
| ({ resource?: never } & Variables<CommonInput<R, O>, O, Batch>)
|
|
24
|
+
| { [Target in R]: { resource: Target } & Variables<InferResourceInput<Target, O>, O, Batch> }[R]
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
export type ResourceMutationResult<Data, Error, Params, Context = unknown> = {
|
|
28
|
+
mutation: CreateMutationResult<Data, Error, Params, Context>;
|
|
29
|
+
readonly overtime: OvertimeResult;
|
|
30
|
+
};
|