@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,347 @@
|
|
|
1
|
+
import { createMutation, useQueryClient } from '@tanstack/svelte-query';
|
|
2
|
+
import { captureAdminContext } from './context.svelte';
|
|
3
|
+
import { captureQueryProvider } from './query-snapshot';
|
|
4
|
+
import { snapshotCreateParams, parseCreateParams, decodeCreateReceipt, createInputId,
|
|
5
|
+
type ContractCreateParams } from './create-contract';
|
|
6
|
+
import { contractKey, parseContractRecord, parseContractId,
|
|
7
|
+
type ContractSchemas, type ContractRecord, type ResourceContract } from './resource-contract';
|
|
8
|
+
import { snapshotPlainData } from './plain-data';
|
|
9
|
+
import { decodeBaseRecord } from './record-decoder';
|
|
10
|
+
import { definedOptions } from './defined-options';
|
|
11
|
+
import { replaceReactiveUnionMembers } from './reactive-projection';
|
|
12
|
+
import { invalidateOwnedQueries, type RefreshScope } from './query-invalidation';
|
|
13
|
+
import { createQueryResultView } from './query-session.svelte';
|
|
14
|
+
import { createOvertimeTracker, type OvertimeOptions } from './hook-utils.svelte';
|
|
15
|
+
import { getAdminOptions } from './options.svelte';
|
|
16
|
+
import { captureAuthLiveScope, clearAuthQueries, handleAuthError } from './auth-hooks.svelte';
|
|
17
|
+
import { auditWithProvider } from './audit';
|
|
18
|
+
import { HttpError } from './types';
|
|
19
|
+
import { useTranslation } from './i18n.svelte';
|
|
20
|
+
import { toast } from './toast.svelte';
|
|
21
|
+
|
|
22
|
+
export interface ContractCreateOptions<S extends ContractSchemas> {
|
|
23
|
+
resource: ResourceContract<S>;
|
|
24
|
+
enabled?: boolean;
|
|
25
|
+
overtimeOptions?: OvertimeOptions;
|
|
26
|
+
}
|
|
27
|
+
type Result<S extends ContractSchemas> = { data: ContractRecord<S> };
|
|
28
|
+
export interface Callbacks<S extends ContractSchemas> {
|
|
29
|
+
onSuccess?: (data: Result<S>, params: ContractCreateParams<S>) => void | Promise<void>;
|
|
30
|
+
onError?: (error: HttpError, params: ContractCreateParams<S> | undefined) => void | Promise<void>;
|
|
31
|
+
onSettled?: (data: Result<S> | undefined, error: HttpError | null, params: ContractCreateParams<S> | undefined) => void | Promise<void>;
|
|
32
|
+
}
|
|
33
|
+
function observe(callback: () => void | Promise<void>): void {
|
|
34
|
+
try { void Promise.resolve(callback()).catch(() => {}); }
|
|
35
|
+
catch { /* Observers cannot change a completed write's outcome. */ }
|
|
36
|
+
}
|
|
37
|
+
function failure(cause: unknown, dispatched = false): HttpError {
|
|
38
|
+
let code: unknown;
|
|
39
|
+
let status: unknown;
|
|
40
|
+
try {
|
|
41
|
+
if (typeof cause === 'object' && cause !== null) {
|
|
42
|
+
code = Object.getOwnPropertyDescriptor(cause, 'code')?.value;
|
|
43
|
+
status = Object.getOwnPropertyDescriptor(cause, 'statusCode')?.value;
|
|
44
|
+
}
|
|
45
|
+
} catch { /* Reflection errors remain private. */ }
|
|
46
|
+
const input = code === 'INVALID_RESOURCE_INPUT' && !dispatched;
|
|
47
|
+
const contract = code === 'INVALID_RESOURCE_CONTRACT' && !dispatched;
|
|
48
|
+
const response = code === 'INVALID_PROVIDER_RESPONSE';
|
|
49
|
+
return new HttpError(input ? 'Invalid create input' : contract ? 'A create schema is required'
|
|
50
|
+
: response ? 'Invalid provider response' : 'Create failed',
|
|
51
|
+
input ? 422 : contract ? 400 : response ? 502 : status === 401 || status === 403 ? status : 500, undefined, {
|
|
52
|
+
code: input ? 'INVALID_RESOURCE_INPUT' : contract ? 'INVALID_RESOURCE_CONTRACT'
|
|
53
|
+
: response ? 'INVALID_PROVIDER_RESPONSE' : 'CREATE_FAILED',
|
|
54
|
+
details: { phase: dispatched ? 'response' : 'input', writeMayHaveSucceeded: dispatched },
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
function copyFailure(error: HttpError): HttpError {
|
|
58
|
+
return new HttpError(error.message, error.statusCode, undefined, {
|
|
59
|
+
...definedOptions({ code: error.code }), details: snapshotPlainData(error.details ?? {}),
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A create and its completion retain the captured resource and authentication revision. */
|
|
64
|
+
export function useContractCreate<S extends ContractSchemas>(options: ContractCreateOptions<S>) {
|
|
65
|
+
contractKey(options.resource);
|
|
66
|
+
const context = captureAdminContext();
|
|
67
|
+
const client = useQueryClient();
|
|
68
|
+
const adminOptions = getAdminOptions();
|
|
69
|
+
const i18n = useTranslation();
|
|
70
|
+
let mounted = true;
|
|
71
|
+
function captureOrigin() {
|
|
72
|
+
const auth = context.authProvider;
|
|
73
|
+
return {
|
|
74
|
+
auth, authScope: captureAuthLiveScope(auth), router: context.routerProvider,
|
|
75
|
+
tenant: context.tenantCacheKey?.__svadminTenant, contract: options.resource,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
function originCurrent(origin: ReturnType<typeof captureOrigin>): boolean {
|
|
79
|
+
try {
|
|
80
|
+
return mounted && options.enabled !== false && origin.auth === context.authProvider &&
|
|
81
|
+
origin.authScope.isCurrent() && origin.router === context.routerProvider &&
|
|
82
|
+
origin.tenant === context.tenantCacheKey?.__svadminTenant && origin.contract === options.resource;
|
|
83
|
+
} catch { return false; }
|
|
84
|
+
}
|
|
85
|
+
function captureScope(input: ReturnType<typeof snapshotCreateParams>) {
|
|
86
|
+
const origin = captureOrigin();
|
|
87
|
+
const contract = origin.contract;
|
|
88
|
+
const captured = captureQueryProvider(context, {
|
|
89
|
+
resource: contract.name, ...definedOptions({ meta: input.meta, dataProviderName: input.dataProviderName }),
|
|
90
|
+
});
|
|
91
|
+
const name = context.resolveDataProviderName(contract.name, input.dataProviderName);
|
|
92
|
+
const raw = context.providers?.[name];
|
|
93
|
+
if (!raw) throw new HttpError('Data provider unavailable', 400);
|
|
94
|
+
return {
|
|
95
|
+
...origin, ...captured, create: raw.create.bind(raw),
|
|
96
|
+
matcher: { ...context.queryKeyMatcher(contract.name, input.dataProviderName), contract: contractKey(contract) },
|
|
97
|
+
notification: context.notificationProvider, audit: context.auditLogProvider, live: context.liveProvider,
|
|
98
|
+
key: JSON.stringify([contractKey(contract), name, captured.source, context.tenantCacheKey?.__svadminTenant, captured.meta]),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
function capture(params: ContractCreateParams<S>) {
|
|
102
|
+
const input = snapshotCreateParams(params);
|
|
103
|
+
const scope = captureScope(input);
|
|
104
|
+
const { variables } = parseCreateParams(scope.contract, input);
|
|
105
|
+
if (options.enabled !== undefined && typeof options.enabled !== 'boolean') {
|
|
106
|
+
throw new HttpError('Invalid enabled value', 422, undefined, { code: 'INVALID_RESOURCE_INPUT' });
|
|
107
|
+
}
|
|
108
|
+
return { ...scope, input, variables, suppliedId: createInputId(scope.contract, variables),
|
|
109
|
+
signature: JSON.stringify([scope.key, scope.authScope.cacheKey, variables]) };
|
|
110
|
+
}
|
|
111
|
+
type Scope = ReturnType<typeof capture>;
|
|
112
|
+
type Invocation = {
|
|
113
|
+
scope: Scope | undefined; origin: ReturnType<typeof captureOrigin>;
|
|
114
|
+
token: object; callbacks: Callbacks<S>; readScope(): Scope; markDispatched(): void;
|
|
115
|
+
};
|
|
116
|
+
let latest = $state.raw<Scope>();
|
|
117
|
+
let latestToken: object | undefined;
|
|
118
|
+
let active: { scope: Scope; promise: Promise<Result<S>>; token: object; wasDispatched(): boolean } | undefined;
|
|
119
|
+
function targetCurrent(scope: Scope): boolean {
|
|
120
|
+
if (!mounted || options.enabled === false) return false;
|
|
121
|
+
try {
|
|
122
|
+
return captureScope(scope.input).key === scope.key &&
|
|
123
|
+
context.authProvider === scope.auth && context.routerProvider === scope.router;
|
|
124
|
+
} catch { return false; }
|
|
125
|
+
}
|
|
126
|
+
const sessionCurrent = (scope: Scope) => {
|
|
127
|
+
try { return context.authProvider === scope.auth && scope.authScope.isCurrent(); }
|
|
128
|
+
catch { return false; }
|
|
129
|
+
};
|
|
130
|
+
const isCurrent = (scope: Scope) => targetCurrent(scope) && sessionCurrent(scope);
|
|
131
|
+
function cancelled(scope: Scope | undefined, dispatched: boolean, result?: Result<S>): HttpError {
|
|
132
|
+
return new HttpError('Create scope changed', 409, undefined, {
|
|
133
|
+
code: 'CREATE_CANCELLED', details: {
|
|
134
|
+
writeMayHaveSucceeded: dispatched,
|
|
135
|
+
...definedOptions({
|
|
136
|
+
suppliedId: scope?.suppliedId,
|
|
137
|
+
created: scope && sessionCurrent(scope) && result ? snapshotPlainData(result.data) : undefined,
|
|
138
|
+
}),
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
function copyResult(scope: Scope, result: Result<S>): Result<S> {
|
|
143
|
+
return { data: parseContractRecord(scope.contract, result.data) };
|
|
144
|
+
}
|
|
145
|
+
function notify(scope: Scope, current: () => boolean, type: 'success' | 'error', message: string): void {
|
|
146
|
+
observe(() => {
|
|
147
|
+
if (!current()) return;
|
|
148
|
+
if (scope.notification) return scope.notification.open({ type, message });
|
|
149
|
+
if (type === 'success') toast.success(message);
|
|
150
|
+
else toast.error(message);
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
async function refresh(scope: Scope, id: string | number | undefined) {
|
|
154
|
+
const scopes: RefreshScope[] = ['list', 'many'];
|
|
155
|
+
if (id !== undefined) scopes.push('detail');
|
|
156
|
+
try {
|
|
157
|
+
await invalidateOwnedQueries({
|
|
158
|
+
client, matcher: { ...scope.matcher, resource: scope.contract.name },
|
|
159
|
+
owner: { source: scope.source, authSession: scope.authScope.cacheKey },
|
|
160
|
+
scopes, current: () => sessionCurrent(scope), ...definedOptions({ id }),
|
|
161
|
+
});
|
|
162
|
+
} catch {
|
|
163
|
+
// Refresh failure cannot falsify a write receipt; currentness is checked after settlement.
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
const mutation = createMutation<Result<S>, HttpError, Invocation>(() => ({
|
|
167
|
+
retry: false, onMutate: () => undefined, onSuccess: () => {}, onError: () => {}, onSettled: () => {},
|
|
168
|
+
mutationFn: async invocation => {
|
|
169
|
+
let scope: Scope;
|
|
170
|
+
try { scope = invocation.readScope(); }
|
|
171
|
+
catch (cause) {
|
|
172
|
+
const error = failure(cause);
|
|
173
|
+
const current = () => latestToken === invocation.token && originCurrent(invocation.origin);
|
|
174
|
+
if (!current()) throw cancelled(undefined, false);
|
|
175
|
+
observe(() => { if (current()) return invocation.callbacks.onError?.(copyFailure(error), undefined); });
|
|
176
|
+
observe(() => { if (current()) return invocation.callbacks.onSettled?.(undefined, copyFailure(error), undefined); });
|
|
177
|
+
throw error;
|
|
178
|
+
}
|
|
179
|
+
let dispatched = false;
|
|
180
|
+
let result: Result<S> | undefined;
|
|
181
|
+
let rejected: HttpError | undefined;
|
|
182
|
+
const current = () => isCurrent(scope) && latestToken === invocation.token;
|
|
183
|
+
const ensureCurrent = () => { if (!current() || active?.token !== invocation.token) throw cancelled(scope, dispatched, result); };
|
|
184
|
+
try {
|
|
185
|
+
ensureCurrent();
|
|
186
|
+
const request = {
|
|
187
|
+
resource: scope.contract.name, variables: snapshotPlainData(scope.variables),
|
|
188
|
+
...definedOptions({ meta: scope.meta === undefined ? undefined : decodeBaseRecord(snapshotPlainData(scope.meta)) }),
|
|
189
|
+
};
|
|
190
|
+
dispatched = true;
|
|
191
|
+
invocation.markDispatched();
|
|
192
|
+
const response = await scope.create(request);
|
|
193
|
+
if (!sessionCurrent(scope)) throw cancelled(scope, dispatched);
|
|
194
|
+
result = decodeCreateReceipt(scope.contract, response, scope.suppliedId);
|
|
195
|
+
} catch (cause) {
|
|
196
|
+
rejected = failure(cause, dispatched);
|
|
197
|
+
} finally {
|
|
198
|
+
if (result && sessionCurrent(scope)) {
|
|
199
|
+
const id = parseContractId(scope.contract, result.data.id);
|
|
200
|
+
auditWithProvider({ action: 'create', resource: scope.contract.name, recordId: id,
|
|
201
|
+
...definedOptions({ meta: scope.meta }) }, scope.audit, () => sessionCurrent(scope));
|
|
202
|
+
observe(() => {
|
|
203
|
+
if (sessionCurrent(scope)) return scope.live?.publish?.({ type: 'INSERT', resource: scope.contract.name, payload: { ids: [id] } });
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
if (dispatched && sessionCurrent(scope)) {
|
|
207
|
+
await refresh(scope, result ? parseContractId(scope.contract, result.data.id) : scope.suppliedId);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
ensureCurrent();
|
|
211
|
+
if (rejected || !result) {
|
|
212
|
+
const error = rejected ?? failure(undefined, dispatched);
|
|
213
|
+
observe(async () => {
|
|
214
|
+
await handleAuthError(copyFailure(error), context,
|
|
215
|
+
() => targetCurrent(scope) && latestToken === invocation.token,
|
|
216
|
+
() => clearAuthQueries(client, scope.auth), scope.authScope,
|
|
217
|
+
() => mounted && latestToken !== invocation.token);
|
|
218
|
+
});
|
|
219
|
+
notify(scope, current, 'error', i18n.t('common.operationFailed'));
|
|
220
|
+
observe(() => { if (current()) return invocation.callbacks.onError?.(copyFailure(error), parseCreateParams(scope.contract, scope.input)); });
|
|
221
|
+
observe(() => { if (current()) return invocation.callbacks.onSettled?.(undefined, copyFailure(error), parseCreateParams(scope.contract, scope.input)); });
|
|
222
|
+
throw error;
|
|
223
|
+
}
|
|
224
|
+
const completed = result;
|
|
225
|
+
notify(scope, current, 'success', i18n.t('common.createSuccess'));
|
|
226
|
+
observe(() => { if (current()) return invocation.callbacks.onSuccess?.(copyResult(scope, completed), parseCreateParams(scope.contract, scope.input)); });
|
|
227
|
+
observe(() => { if (current()) return invocation.callbacks.onSettled?.(copyResult(scope, completed), null, parseCreateParams(scope.contract, scope.input)); });
|
|
228
|
+
ensureCurrent();
|
|
229
|
+
return copyResult(scope, completed);
|
|
230
|
+
},
|
|
231
|
+
}));
|
|
232
|
+
$effect.pre(() => {
|
|
233
|
+
if (latest && !isCurrent(latest)) {
|
|
234
|
+
latest = undefined;
|
|
235
|
+
active = undefined;
|
|
236
|
+
mutation.reset();
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
$effect(() => () => { mounted = false; active = undefined; latestToken = undefined; });
|
|
240
|
+
|
|
241
|
+
function detachedPromise(promise: Promise<Result<S>>, scope: Scope, token: object, wasDispatched: () => boolean): Promise<Result<S>> {
|
|
242
|
+
return promise.then(data => {
|
|
243
|
+
if (!isCurrent(scope) || latestToken !== token) throw cancelled(scope, wasDispatched(), data);
|
|
244
|
+
return copyResult(scope, data);
|
|
245
|
+
}, (cause: unknown) => {
|
|
246
|
+
if (!sessionCurrent(scope)) throw cancelled(scope, wasDispatched());
|
|
247
|
+
throw cause instanceof HttpError ? copyFailure(cause) : failure(cause, wasDispatched());
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
function mutateAsync(params: ContractCreateParams<S>, callbacks: Callbacks<S> = {}): Promise<Result<S>> {
|
|
251
|
+
const origin = captureOrigin();
|
|
252
|
+
const observer = { onSuccess: callbacks.onSuccess, onError: callbacks.onError, onSettled: callbacks.onSettled };
|
|
253
|
+
let scope: Scope;
|
|
254
|
+
try { scope = capture(params); }
|
|
255
|
+
catch (cause) {
|
|
256
|
+
const error = failure(cause);
|
|
257
|
+
if (active && isCurrent(active.scope)) return Promise.reject(error);
|
|
258
|
+
latest = undefined;
|
|
259
|
+
active = undefined;
|
|
260
|
+
const token = {};
|
|
261
|
+
latestToken = token;
|
|
262
|
+
return mutation.mutateAsync(Object.freeze({
|
|
263
|
+
scope: undefined, origin, token, callbacks: definedOptions(observer), readScope: () => { throw error; },
|
|
264
|
+
markDispatched: () => {},
|
|
265
|
+
})).catch((cause: unknown) => {
|
|
266
|
+
if (!originCurrent(origin) || latestToken !== token) throw cancelled(undefined, false);
|
|
267
|
+
throw cause instanceof HttpError ? copyFailure(cause) : failure(cause);
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
if (active && isCurrent(active.scope)) {
|
|
271
|
+
if (active.scope.signature === scope.signature) {
|
|
272
|
+
const owner = active.scope;
|
|
273
|
+
const token = active.token;
|
|
274
|
+
return detachedPromise(active.promise, owner, token, active.wasDispatched).then(data => {
|
|
275
|
+
if (isCurrent(owner) && latest === owner) {
|
|
276
|
+
observe(() => observer.onSuccess?.(copyResult(owner, data), parseCreateParams(owner.contract, owner.input)));
|
|
277
|
+
observe(() => {
|
|
278
|
+
if (isCurrent(owner) && latestToken === token) return observer.onSettled?.(copyResult(owner, data), null, parseCreateParams(owner.contract, owner.input));
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
if (!isCurrent(owner) || latestToken !== token) throw cancelled(owner, true, data);
|
|
282
|
+
return data;
|
|
283
|
+
}, (cause: unknown) => {
|
|
284
|
+
const error = cause instanceof HttpError ? cause : failure(cause, true);
|
|
285
|
+
if (isCurrent(owner) && latest === owner) {
|
|
286
|
+
observe(() => observer.onError?.(copyFailure(error), parseCreateParams(owner.contract, owner.input)));
|
|
287
|
+
observe(() => {
|
|
288
|
+
if (isCurrent(owner) && latestToken === token) return observer.onSettled?.(undefined, copyFailure(error), parseCreateParams(owner.contract, owner.input));
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
throw error;
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
return Promise.reject(new HttpError('Create is already running', 409, undefined, { code: 'CREATE_BUSY' }));
|
|
295
|
+
}
|
|
296
|
+
const token = {};
|
|
297
|
+
latestToken = token;
|
|
298
|
+
latest = scope;
|
|
299
|
+
const target = scope;
|
|
300
|
+
let dispatched = false;
|
|
301
|
+
const wasDispatched = () => dispatched;
|
|
302
|
+
// Keep scope and token identities intact through Svelte's mutation-state projection.
|
|
303
|
+
const promise = mutation.mutateAsync(Object.freeze({
|
|
304
|
+
scope, origin: scope, token, callbacks: definedOptions(observer), readScope: () => target,
|
|
305
|
+
markDispatched: () => { dispatched = true; },
|
|
306
|
+
})).finally(() => { if (active?.token === token) active = undefined; });
|
|
307
|
+
active = { scope, token, promise, wasDispatched };
|
|
308
|
+
return detachedPromise(promise, target, token, wasDispatched);
|
|
309
|
+
}
|
|
310
|
+
// Copy reads while retaining TanStack's success/error discriminated union.
|
|
311
|
+
function detachedState(state: typeof mutation): typeof mutation;
|
|
312
|
+
function detachedState(state: typeof mutation): object {
|
|
313
|
+
return replaceReactiveUnionMembers(state, {
|
|
314
|
+
get data() {
|
|
315
|
+
const scope = state.variables?.scope;
|
|
316
|
+
return state.data && scope ? copyResult(scope, state.data) : undefined;
|
|
317
|
+
},
|
|
318
|
+
get error() { return state.error ? copyFailure(state.error) : null; },
|
|
319
|
+
get failureReason() { return state.failureReason ? copyFailure(state.failureReason) : null; },
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
const result = replaceReactiveUnionMembers(detachedState(mutation), {
|
|
323
|
+
get variables() {
|
|
324
|
+
const scope = mutation.variables?.scope;
|
|
325
|
+
return scope ? parseCreateParams(scope.contract, scope.input) : undefined;
|
|
326
|
+
},
|
|
327
|
+
mutateAsync,
|
|
328
|
+
mutate(params: ContractCreateParams<S>, callbacks?: Callbacks<S>): void {
|
|
329
|
+
void mutateAsync(params, callbacks).catch(() => {});
|
|
330
|
+
},
|
|
331
|
+
reset() { active = undefined; latest = undefined; latestToken = undefined; mutation.reset(); },
|
|
332
|
+
});
|
|
333
|
+
const idle: typeof result = {
|
|
334
|
+
context: undefined, data: undefined, error: null, failureCount: 0, failureReason: null,
|
|
335
|
+
isError: false, isIdle: true, isPending: false, isPaused: false, isSuccess: false,
|
|
336
|
+
status: 'idle', submittedAt: 0, variables: undefined,
|
|
337
|
+
mutate: result.mutate, mutateAsync: result.mutateAsync, reset: result.reset,
|
|
338
|
+
};
|
|
339
|
+
const view = createQueryResultView(() => {
|
|
340
|
+
const invocation = mutation.variables;
|
|
341
|
+
if (!mounted || (invocation && (!originCurrent(invocation.origin) || latestToken !== invocation.token ||
|
|
342
|
+
(invocation.scope && !isCurrent(invocation.scope))))) return idle;
|
|
343
|
+
return result;
|
|
344
|
+
});
|
|
345
|
+
const overtime = createOvertimeTracker(() => view.isPending, options.overtimeOptions ?? adminOptions.overtime);
|
|
346
|
+
return { mutation: view, get overtime() { return overtime; } };
|
|
347
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { Type } from '@sinclair/typebox';
|
|
2
|
+
import { snapshotPlainData } from './plain-data';
|
|
3
|
+
import { checkExact } from './schema-validation';
|
|
4
|
+
import { parseContractId, parseContractRecord, parseContractCreateInput, requireContractCreateSchema,
|
|
5
|
+
type ContractSchemas, type ContractCreateInput, type ResourceContract } from './resource-contract';
|
|
6
|
+
import { definedOptions } from './defined-options';
|
|
7
|
+
import { decodeBaseRecord, decodeManyResult, decodeOneResult, rejectProviderResponse } from './record-decoder';
|
|
8
|
+
import { HttpError, type BaseRecord } from './types';
|
|
9
|
+
|
|
10
|
+
const envelope = Type.Object({
|
|
11
|
+
variables: Type.Array(Type.Unknown(), { minItems: 1 }),
|
|
12
|
+
meta: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
|
|
13
|
+
dataProviderName: Type.Optional(Type.String({ minLength: 1 })),
|
|
14
|
+
}, { additionalProperties: false });
|
|
15
|
+
|
|
16
|
+
export interface ContractCreateManyParams<S extends ContractSchemas> {
|
|
17
|
+
variables: [ContractCreateInput<S>] extends [never] ? never : ContractCreateInput<S>[];
|
|
18
|
+
meta?: Record<string, unknown>;
|
|
19
|
+
dataProviderName?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Indexes are zero-based; failures may have written data without a valid receipt. */
|
|
23
|
+
export class CreateManyPartialError extends HttpError {
|
|
24
|
+
readonly succeeded: { index: number; record: BaseRecord }[];
|
|
25
|
+
readonly failedIndexes: number[];
|
|
26
|
+
readonly causes: HttpError[];
|
|
27
|
+
constructor(succeeded: readonly { index: number; record: BaseRecord }[], failedIndexes: readonly number[], causes: readonly HttpError[]) {
|
|
28
|
+
super('Batch create partially failed', 502, undefined, {
|
|
29
|
+
code: 'CREATE_MANY_PARTIAL', details: { writeMayHaveSucceeded: true },
|
|
30
|
+
});
|
|
31
|
+
this.name = 'CreateManyPartialError';
|
|
32
|
+
this.succeeded = succeeded.map(({ index, record }) => ({ index, record: decodeBaseRecord(snapshotPlainData(record)) }));
|
|
33
|
+
this.failedIndexes = [...failedIndexes];
|
|
34
|
+
this.causes = [...causes];
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function snapshotCreateManyParams(value: unknown) {
|
|
39
|
+
try {
|
|
40
|
+
const input = snapshotPlainData(value);
|
|
41
|
+
if (checkExact(envelope, input)) return input;
|
|
42
|
+
} catch { /* Do not execute submitted accessors or expose their failures. */ }
|
|
43
|
+
throw new HttpError('Invalid batch create input', 422, undefined, {
|
|
44
|
+
code: 'INVALID_RESOURCE_INPUT', details: { writeMayHaveSucceeded: false },
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function suppliedCreateId(contract: ResourceContract, input: unknown): string | number | undefined {
|
|
49
|
+
const id: unknown = typeof input === 'object' && input !== null
|
|
50
|
+
? Object.getOwnPropertyDescriptor(input, 'id')?.value : undefined;
|
|
51
|
+
return id === undefined ? undefined : parseContractId(contract, id);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Private schemas establish both payload types and client-supplied identities. */
|
|
55
|
+
export function parseCreateManyParams<S extends ContractSchemas>(contract: ResourceContract<S>, value: unknown): ContractCreateManyParams<S>;
|
|
56
|
+
export function parseCreateManyParams(contract: ResourceContract, value: unknown): object {
|
|
57
|
+
requireContractCreateSchema(contract);
|
|
58
|
+
const input = snapshotCreateManyParams(value);
|
|
59
|
+
const variables = input.variables.map(value => parseContractCreateInput(contract, value));
|
|
60
|
+
const seen = new Set<string | number>();
|
|
61
|
+
for (const value of variables) {
|
|
62
|
+
const id = suppliedCreateId(contract, value);
|
|
63
|
+
if (id === undefined) continue;
|
|
64
|
+
if (seen.has(id)) throw new HttpError('Duplicate create identity', 422, undefined, { code: 'INVALID_RESOURCE_INPUT' });
|
|
65
|
+
seen.add(id);
|
|
66
|
+
}
|
|
67
|
+
return { variables, ...definedOptions({ meta: input.meta, dataProviderName: input.dataProviderName }) };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Native receipts are positional for supplied IDs; generated IDs prove shape, not business correspondence. */
|
|
71
|
+
export function decodeCreateManyReceipt<S extends ContractSchemas>(
|
|
72
|
+
contract: ResourceContract<S>, inputs: readonly unknown[], response: unknown, batch: boolean,
|
|
73
|
+
seen: ReadonlySet<string | number>,
|
|
74
|
+
) {
|
|
75
|
+
try {
|
|
76
|
+
const receipt = snapshotPlainData(response);
|
|
77
|
+
const records = batch
|
|
78
|
+
? decodeManyResult(receipt, value => parseContractRecord(contract, value), true).data
|
|
79
|
+
: [decodeOneResult(receipt, value => parseContractRecord(contract, value), true).data];
|
|
80
|
+
if (records.length !== inputs.length) return rejectProviderResponse(true);
|
|
81
|
+
const identities = new Set(seen);
|
|
82
|
+
for (const [index, record] of records.entries()) {
|
|
83
|
+
const id = parseContractId(contract, record.id);
|
|
84
|
+
const supplied = suppliedCreateId(contract, inputs[index]);
|
|
85
|
+
if (identities.has(id) || (supplied !== undefined && supplied !== id)) return rejectProviderResponse(true);
|
|
86
|
+
identities.add(id);
|
|
87
|
+
}
|
|
88
|
+
return records;
|
|
89
|
+
} catch { return rejectProviderResponse(true); }
|
|
90
|
+
}
|