@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.
Files changed (97) hide show
  1. package/README.md +32 -0
  2. package/package.json +21 -4
  3. package/src/access-control-contract.ts +250 -0
  4. package/src/adapters/casbin.ts +20 -14
  5. package/src/adapters/casl.ts +22 -19
  6. package/src/admin-tool.ts +7 -7
  7. package/src/audit-contract.ts +105 -0
  8. package/src/audit.ts +88 -128
  9. package/src/auth-hooks.svelte.ts +593 -261
  10. package/src/auth-mutation-contract.ts +143 -0
  11. package/src/auth-query-contract.ts +97 -0
  12. package/src/captured-mutation.svelte.ts +45 -0
  13. package/src/chatProvider.svelte.ts +1 -1
  14. package/src/command-contract.ts +195 -0
  15. package/src/context.svelte.ts +217 -205
  16. package/src/contract-context.svelte.ts +37 -0
  17. package/src/contract-form.svelte.ts +566 -0
  18. package/src/create-contract.ts +56 -0
  19. package/src/create-hooks.svelte.ts +347 -0
  20. package/src/create-many-contract.ts +90 -0
  21. package/src/create-many-hooks.svelte.ts +396 -0
  22. package/src/data-transfer.svelte.ts +1 -215
  23. package/src/defined-options.ts +50 -0
  24. package/src/delete-contract.ts +50 -0
  25. package/src/delete-hooks.svelte.ts +403 -0
  26. package/src/delete-many-contract.ts +60 -0
  27. package/src/delete-many-hooks.svelte.ts +402 -0
  28. package/src/enterprise.ts +4 -4
  29. package/src/export-format.ts +16 -14
  30. package/src/export-hooks.svelte.ts +191 -0
  31. package/src/feature-gate-contract.ts +53 -0
  32. package/src/hook-utils.svelte.ts +53 -105
  33. package/src/hooks.svelte.ts +19 -774
  34. package/src/http.ts +88 -87
  35. package/src/i18n.svelte.ts +56 -2
  36. package/src/import-contract.ts +106 -0
  37. package/src/import-hooks.svelte.ts +310 -0
  38. package/src/index.ts +77 -28
  39. package/src/inferencer-graphql.ts +7 -6
  40. package/src/infinite-query.svelte.ts +228 -0
  41. package/src/invalidation-contract.ts +26 -0
  42. package/src/live-sse.ts +91 -127
  43. package/src/live-subscription.svelte.ts +130 -0
  44. package/src/live-transport.ts +127 -0
  45. package/src/live-websocket.ts +85 -118
  46. package/src/live.svelte.ts +66 -68
  47. package/src/notification.svelte.ts +17 -15
  48. package/src/permission-catalog.ts +121 -0
  49. package/src/permission-hints-contract.ts +48 -0
  50. package/src/permissions.svelte.ts +112 -59
  51. package/src/plain-data.ts +61 -0
  52. package/src/provider-bundle.ts +85 -79
  53. package/src/provider-response.ts +35 -0
  54. package/src/query-hooks.svelte.ts +241 -286
  55. package/src/query-invalidation.ts +172 -0
  56. package/src/query-keys.ts +221 -110
  57. package/src/query-session.svelte.ts +216 -0
  58. package/src/query-snapshot.ts +144 -0
  59. package/src/reactive-projection.ts +53 -0
  60. package/src/record-decoder.ts +63 -0
  61. package/src/resource-binding.svelte.ts +19 -0
  62. package/src/resource-contract.ts +595 -0
  63. package/src/resource-mutation-types.ts +30 -0
  64. package/src/resource-schemas.ts +371 -0
  65. package/src/resource-transport-config.ts +156 -0
  66. package/src/route-parsing.ts +11 -11
  67. package/src/router-provider.ts +64 -59
  68. package/src/router.ts +8 -3
  69. package/src/routing-hooks.svelte.ts +90 -62
  70. package/src/schema-validation.ts +92 -0
  71. package/src/schema.ts +13 -0
  72. package/src/select-options.ts +95 -0
  73. package/src/select-query.svelte.ts +191 -0
  74. package/src/session-query.svelte.ts +88 -0
  75. package/src/strict-command-hooks.svelte.ts +333 -0
  76. package/src/strict-hooks.svelte.ts +198 -0
  77. package/src/table-hooks.svelte.ts +180 -174
  78. package/src/table-state.svelte.ts +117 -117
  79. package/src/task-contract.ts +87 -0
  80. package/src/task-hooks.svelte.ts +193 -149
  81. package/src/task-provider.ts +115 -0
  82. package/src/task-subscription.ts +132 -0
  83. package/src/theme.svelte.ts +155 -154
  84. package/src/toast.svelte.ts +38 -37
  85. package/src/types.ts +55 -103
  86. package/src/unsafe.ts +7 -0
  87. package/src/update-contract.ts +65 -0
  88. package/src/update-hooks.svelte.ts +347 -0
  89. package/src/update-many-contract.ts +79 -0
  90. package/src/update-many-hooks.svelte.ts +373 -0
  91. package/src/url-sync.ts +11 -10
  92. package/src/useCan.ts +96 -31
  93. package/src/useParsed.svelte.ts +18 -14
  94. package/src/useStepsForm.svelte.ts +123 -63
  95. package/src/utility-hooks.svelte.ts +101 -158
  96. package/src/form-hooks.svelte.ts +0 -889
  97. package/src/mutation-hooks.svelte.ts +0 -561
@@ -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 { snapshotUpdateParams, parseUpdateParams, decodeUpdateReceipt, type ContractUpdateParams } from './update-contract';
5
+ import { contractKey, parseContractRecord, parseContractId,
6
+ type ContractSchemas, type ContractId, type ContractRecord, type ResourceContract } from './resource-contract';
7
+ import { snapshotPlainData } from './plain-data';
8
+ import { decodeBaseRecord } from './record-decoder';
9
+ import { definedOptions } from './defined-options';
10
+ import { replaceReactiveUnionMembers } from './reactive-projection';
11
+ import { invalidateOwnedQueries } from './query-invalidation';
12
+ import { createQueryResultView } from './query-session.svelte';
13
+ import { createOvertimeTracker, type OvertimeOptions } from './hook-utils.svelte';
14
+ import { getAdminOptions } from './options.svelte';
15
+ import { captureAuthLiveScope, clearAuthQueries, handleAuthError } from './auth-hooks.svelte';
16
+ import { auditWithProvider } from './audit';
17
+ import { HttpError } from './types';
18
+ import { useTranslation } from './i18n.svelte';
19
+ import { toast } from './toast.svelte';
20
+
21
+ export type { ContractUpdateParams } from './update-contract';
22
+ export interface ContractUpdateOptions<S extends ContractSchemas> {
23
+ resource: ResourceContract<S>;
24
+ id: NoInfer<ContractId<S>>;
25
+ enabled?: boolean;
26
+ overtimeOptions?: OvertimeOptions;
27
+ }
28
+ type Result<S extends ContractSchemas> = { data: ContractRecord<S> };
29
+ export interface Callbacks<S extends ContractSchemas> {
30
+ onSuccess?: (data: Result<S>, params: ContractUpdateParams<S>) => void | Promise<void>;
31
+ onError?: (error: HttpError, params: ContractUpdateParams<S> | undefined) => void | Promise<void>;
32
+ onSettled?: (data: Result<S> | undefined, error: HttpError | null, params: ContractUpdateParams<S> | undefined) => void | Promise<void>;
33
+ }
34
+ function observe(callback: () => void | Promise<void>): void {
35
+ try { void Promise.resolve(callback()).catch(() => {}); }
36
+ catch { /* Observers cannot change a completed write's outcome. */ }
37
+ }
38
+ function failure(cause: unknown, dispatched = false): HttpError {
39
+ let code: unknown;
40
+ let status: unknown;
41
+ try {
42
+ if (typeof cause === 'object' && cause !== null) {
43
+ code = Object.getOwnPropertyDescriptor(cause, 'code')?.value;
44
+ status = Object.getOwnPropertyDescriptor(cause, 'statusCode')?.value;
45
+ }
46
+ } catch { /* Reflection errors remain private. */ }
47
+ const input = code === 'INVALID_RESOURCE_INPUT' && !dispatched;
48
+ const provider = code === 'DATA_PROVIDER_REQUIRED' && !dispatched;
49
+ const response = code === 'INVALID_PROVIDER_RESPONSE';
50
+ return new HttpError(input ? 'Invalid update input' : provider ? 'Data provider unavailable'
51
+ : response ? 'Invalid provider response' : 'Update failed',
52
+ input ? 422 : provider ? 400 : response ? 502 : status === 401 || status === 403 ? status : 500, undefined, {
53
+ code: input ? 'INVALID_RESOURCE_INPUT' : provider ? 'DATA_PROVIDER_REQUIRED'
54
+ : response ? 'INVALID_PROVIDER_RESPONSE' : 'UPDATE_FAILED',
55
+ details: { phase: dispatched ? 'response' : 'input', writeMayHaveSucceeded: dispatched },
56
+ });
57
+ }
58
+ function copyFailure(error: HttpError): HttpError {
59
+ return new HttpError(error.message, error.statusCode, undefined, {
60
+ ...definedOptions({ code: error.code }), details: snapshotPlainData(error.details ?? {}),
61
+ });
62
+ }
63
+
64
+ /** A single update retains its checked identity and originating authentication revision. */
65
+ export function useContractUpdate<S extends ContractSchemas>(options: ContractUpdateOptions<S>) {
66
+ contractKey(options.resource);
67
+ const context = captureAdminContext();
68
+ const client = useQueryClient();
69
+ const adminOptions = getAdminOptions();
70
+ const i18n = useTranslation();
71
+ let mounted = true;
72
+ function captureOrigin() {
73
+ const auth = context.authProvider;
74
+ return {
75
+ auth, authScope: captureAuthLiveScope(auth), router: context.routerProvider,
76
+ tenant: context.tenantCacheKey?.__svadminTenant, contract: options.resource, id: options.id,
77
+ };
78
+ }
79
+ function originCurrent(origin: ReturnType<typeof captureOrigin>): boolean {
80
+ try {
81
+ return mounted && options.enabled !== false && origin.auth === context.authProvider &&
82
+ origin.authScope.isCurrent() && origin.router === context.routerProvider &&
83
+ origin.tenant === context.tenantCacheKey?.__svadminTenant && origin.contract === options.resource &&
84
+ Object.is(origin.id, options.id);
85
+ } catch { return false; }
86
+ }
87
+ function captureScope(input: ReturnType<typeof snapshotUpdateParams>) {
88
+ const origin = captureOrigin();
89
+ const contract = origin.contract;
90
+ const id = parseContractId(contract, origin.id);
91
+ const captured = captureQueryProvider(context, {
92
+ resource: contract.name, ...definedOptions({ meta: input.meta, dataProviderName: input.dataProviderName }),
93
+ });
94
+ const name = context.resolveDataProviderName(contract.name, input.dataProviderName);
95
+ const raw = context.providers?.[name];
96
+ if (!raw) throw new HttpError('Data provider unavailable', 400, undefined, { code: 'DATA_PROVIDER_REQUIRED' });
97
+ return {
98
+ ...origin, id, ...captured, update: raw.update.bind(raw),
99
+ matcher: { ...context.queryKeyMatcher(contract.name, input.dataProviderName), contract: contractKey(contract) },
100
+ notification: context.notificationProvider, audit: context.auditLogProvider, live: context.liveProvider,
101
+ key: JSON.stringify([contractKey(contract), id, name, captured.source, context.tenantCacheKey?.__svadminTenant, captured.meta]),
102
+ };
103
+ }
104
+ function capture(params: ContractUpdateParams<S>) {
105
+ const input = snapshotUpdateParams(params);
106
+ const scope = captureScope(input);
107
+ const { variables } = parseUpdateParams(scope.contract, scope.id, input);
108
+ if (options.enabled !== undefined && typeof options.enabled !== 'boolean') {
109
+ throw new HttpError('Invalid enabled value', 422, undefined, { code: 'INVALID_RESOURCE_INPUT' });
110
+ }
111
+ return { ...scope, input, variables, signature: JSON.stringify([scope.key, scope.authScope.cacheKey, variables]) };
112
+ }
113
+ type Scope = ReturnType<typeof capture>;
114
+ type Invocation = {
115
+ scope: Scope | undefined; origin: ReturnType<typeof captureOrigin>;
116
+ token: object; callbacks: Callbacks<S>; readScope(): Scope; markDispatched(): void;
117
+ };
118
+ let latest = $state.raw<Scope>();
119
+ let latestToken: object | undefined;
120
+ let active: { scope: Scope; promise: Promise<Result<S>>; token: object; wasDispatched(): boolean } | undefined;
121
+ function targetCurrent(scope: Scope): boolean {
122
+ if (!mounted || options.enabled === false) return false;
123
+ try {
124
+ return captureScope(scope.input).key === scope.key &&
125
+ context.authProvider === scope.auth && context.routerProvider === scope.router;
126
+ } catch { return false; }
127
+ }
128
+ const sessionCurrent = (scope: Scope) => {
129
+ try { return context.authProvider === scope.auth && scope.authScope.isCurrent(); }
130
+ catch { return false; }
131
+ };
132
+ const isCurrent = (scope: Scope) => targetCurrent(scope) && sessionCurrent(scope);
133
+ function cancelled(scope: Scope | undefined, dispatched: boolean, result?: Result<S>): HttpError {
134
+ return new HttpError('Update scope changed', 409, undefined, {
135
+ code: 'UPDATE_CANCELLED', details: {
136
+ writeMayHaveSucceeded: dispatched,
137
+ ...definedOptions({
138
+ id: scope?.id,
139
+ updated: scope && sessionCurrent(scope) && result ? snapshotPlainData(result.data) : undefined,
140
+ }),
141
+ },
142
+ });
143
+ }
144
+ function copyResult(scope: Scope, result: Result<S>): Result<S> {
145
+ return { data: parseContractRecord(scope.contract, result.data) };
146
+ }
147
+ function copyParams(scope: Scope): ContractUpdateParams<S> {
148
+ return parseUpdateParams(scope.contract, scope.id, scope.input);
149
+ }
150
+ function notify(scope: Scope, current: () => boolean, type: 'success' | 'error', message: string): void {
151
+ observe(() => {
152
+ if (!current()) return;
153
+ if (scope.notification) return scope.notification.open({ type, message });
154
+ if (type === 'success') toast.success(message);
155
+ else toast.error(message);
156
+ });
157
+ }
158
+ async function refresh(scope: Scope) {
159
+ try {
160
+ await invalidateOwnedQueries({
161
+ client, matcher: { ...scope.matcher, resource: scope.contract.name },
162
+ owner: { source: scope.source, authSession: scope.authScope.cacheKey },
163
+ scopes: ['list', 'many', 'detail'], id: scope.id, current: () => sessionCurrent(scope),
164
+ });
165
+ } catch {
166
+ // Refresh failure cannot falsify a write receipt; currentness is checked after settlement.
167
+ }
168
+ }
169
+ const mutation = createMutation<Result<S>, HttpError, Invocation>(() => ({
170
+ retry: false, onMutate: () => undefined, onSuccess: () => {}, onError: () => {}, onSettled: () => {},
171
+ mutationFn: async invocation => {
172
+ let scope: Scope;
173
+ try { scope = invocation.readScope(); }
174
+ catch (cause) {
175
+ const error = failure(cause);
176
+ const current = () => latestToken === invocation.token && originCurrent(invocation.origin);
177
+ if (!current()) throw cancelled(undefined, false);
178
+ observe(() => { if (current()) return invocation.callbacks.onError?.(copyFailure(error), undefined); });
179
+ observe(() => { if (current()) return invocation.callbacks.onSettled?.(undefined, copyFailure(error), undefined); });
180
+ throw error;
181
+ }
182
+ let dispatched = false;
183
+ let result: Result<S> | undefined;
184
+ let rejected: HttpError | undefined;
185
+ const current = () => isCurrent(scope) && latestToken === invocation.token;
186
+ const ensureCurrent = () => { if (!current() || active?.token !== invocation.token) throw cancelled(scope, dispatched, result); };
187
+ try {
188
+ ensureCurrent();
189
+ const request = {
190
+ resource: scope.contract.name, id: scope.id, variables: snapshotPlainData(scope.variables),
191
+ ...definedOptions({ meta: scope.meta === undefined ? undefined : decodeBaseRecord(snapshotPlainData(scope.meta)) }),
192
+ };
193
+ dispatched = true;
194
+ invocation.markDispatched();
195
+ const response = await scope.update(request);
196
+ if (!sessionCurrent(scope)) throw cancelled(scope, dispatched);
197
+ result = decodeUpdateReceipt(scope.contract, scope.id, response);
198
+ } catch (cause) {
199
+ rejected = failure(cause, dispatched);
200
+ } finally {
201
+ if (result && sessionCurrent(scope)) {
202
+ auditWithProvider({ action: 'update', resource: scope.contract.name, recordId: scope.id,
203
+ ...definedOptions({ meta: scope.meta }) }, scope.audit, () => sessionCurrent(scope));
204
+ observe(() => {
205
+ if (sessionCurrent(scope)) return scope.live?.publish?.({ type: 'UPDATE', resource: scope.contract.name, payload: { ids: [scope.id] } });
206
+ });
207
+ }
208
+ if (dispatched && sessionCurrent(scope)) await refresh(scope);
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), copyParams(scope)); });
221
+ observe(() => { if (current()) return invocation.callbacks.onSettled?.(undefined, copyFailure(error), copyParams(scope)); });
222
+ throw error;
223
+ }
224
+ const completed = result;
225
+ notify(scope, current, 'success', i18n.t('common.updateSuccess'));
226
+ observe(() => { if (current()) return invocation.callbacks.onSuccess?.(copyResult(scope, completed), copyParams(scope)); });
227
+ observe(() => { if (current()) return invocation.callbacks.onSettled?.(copyResult(scope, completed), null, copyParams(scope)); });
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: ContractUpdateParams<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), copyParams(owner)));
277
+ observe(() => {
278
+ if (isCurrent(owner) && latestToken === token) return observer.onSettled?.(copyResult(owner, data), null, copyParams(owner));
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), copyParams(owner)));
287
+ observe(() => {
288
+ if (isCurrent(owner) && latestToken === token) return observer.onSettled?.(undefined, copyFailure(error), copyParams(owner));
289
+ });
290
+ }
291
+ throw error;
292
+ });
293
+ }
294
+ return Promise.reject(new HttpError('Update is already running', 409, undefined, { code: 'UPDATE_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, 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 ? copyParams(scope) : undefined;
326
+ },
327
+ mutateAsync,
328
+ mutate(params: ContractUpdateParams<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,79 @@
1
+ import { Type } from '@sinclair/typebox';
2
+ import { snapshotPlainData } from './plain-data';
3
+ import { checkExact } from './schema-validation';
4
+ import { parseContractId, parseContractRecord, parseContractUpdateInput,
5
+ type ContractSchemas, type ContractId, type ContractUpdateInput, type ResourceContract } from './resource-contract';
6
+ import { validateUpdateIdentity } from './update-contract';
7
+ import { definedOptions } from './defined-options';
8
+ import { decodeManyResult, decodeOneResult, rejectProviderResponse } from './record-decoder';
9
+ import { HttpError } from './types';
10
+
11
+ const envelope = Type.Object({
12
+ ids: Type.Array(Type.Union([Type.String(), Type.Number()]), { minItems: 1, uniqueItems: true }),
13
+ variables: Type.Unknown(),
14
+ meta: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
15
+ dataProviderName: Type.Optional(Type.String({ minLength: 1 })),
16
+ }, { additionalProperties: false });
17
+
18
+ export interface ContractUpdateManyParams<S extends ContractSchemas> {
19
+ ids: NoInfer<ContractId<S>>[];
20
+ variables: ContractUpdateInput<S>;
21
+ meta?: Record<string, unknown>;
22
+ dataProviderName?: string;
23
+ }
24
+
25
+ /** A fallback may update only part of a batch; preserve checked identities for recovery. */
26
+ export class UpdateManyPartialError extends HttpError {
27
+ readonly succeededIds: (string | number)[];
28
+ readonly failedIds: (string | number)[];
29
+ readonly causes: HttpError[];
30
+ constructor(succeededIds: readonly (string | number)[], failedIds: readonly (string | number)[], causes: readonly HttpError[]) {
31
+ super('Batch update partially failed', 502, undefined, {
32
+ code: 'UPDATE_MANY_PARTIAL', details: { writeMayHaveSucceeded: true },
33
+ });
34
+ this.name = 'UpdateManyPartialError';
35
+ this.succeededIds = [...succeededIds];
36
+ this.failedIds = [...failedIds];
37
+ this.causes = [...causes];
38
+ }
39
+ }
40
+
41
+ export function snapshotUpdateManyParams(value: unknown) {
42
+ try {
43
+ const input = snapshotPlainData(value);
44
+ if (checkExact(envelope, input)) return input;
45
+ } catch { /* Never execute submitted accessors or disclose their failures. */ }
46
+ throw new HttpError('Invalid batch update input', 422, undefined, {
47
+ code: 'INVALID_RESOURCE_INPUT', details: { writeMayHaveSucceeded: false },
48
+ });
49
+ }
50
+
51
+ /** Private ID and update schemas establish the public operation parameter type. */
52
+ export function parseUpdateManyParams<S extends ContractSchemas>(contract: ResourceContract<S>, value: unknown): ContractUpdateManyParams<S>;
53
+ export function parseUpdateManyParams(contract: ResourceContract, value: unknown): object {
54
+ const input = snapshotUpdateManyParams(value);
55
+ const ids = input.ids.map(id => parseContractId(contract, id));
56
+ const variables = parseContractUpdateInput(contract, input.variables);
57
+ for (const id of ids) validateUpdateIdentity(variables, id);
58
+ return { ids, variables, ...definedOptions({ meta: input.meta, dataProviderName: input.dataProviderName }) };
59
+ }
60
+
61
+ export function decodeUpdateManyReceipt<S extends ContractSchemas>(
62
+ contract: ResourceContract<S>, ids: readonly (string | number)[], response: unknown, batch: boolean,
63
+ ) {
64
+ try {
65
+ const snapshot = snapshotPlainData(response);
66
+ const records = batch
67
+ ? decodeManyResult(snapshot, value => parseContractRecord(contract, value), true).data
68
+ : [decodeOneResult(snapshot, value => parseContractRecord(contract, value), true).data];
69
+ if (records.length !== ids.length) return rejectProviderResponse(true);
70
+ const expected = new Set(ids);
71
+ const seen = new Set<string | number>();
72
+ for (const record of records) {
73
+ const id = parseContractId(contract, record.id);
74
+ if (!expected.has(id) || seen.has(id)) return rejectProviderResponse(true);
75
+ seen.add(id);
76
+ }
77
+ return records;
78
+ } catch { return rejectProviderResponse(true); }
79
+ }