@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.
Files changed (98) hide show
  1. package/package.json +21 -4
  2. package/src/access-control-contract.ts +250 -0
  3. package/src/adapters/casbin.ts +20 -14
  4. package/src/adapters/casl.ts +22 -19
  5. package/src/admin-tool.ts +7 -7
  6. package/src/audit-contract.ts +105 -0
  7. package/src/audit.ts +88 -128
  8. package/src/auth-hooks.svelte.ts +593 -261
  9. package/src/auth-mutation-contract.ts +143 -0
  10. package/src/auth-query-contract.ts +97 -0
  11. package/src/captured-mutation.svelte.ts +45 -0
  12. package/src/chatProvider.svelte.ts +1 -1
  13. package/src/command-contract.ts +195 -0
  14. package/src/context.svelte.ts +217 -205
  15. package/src/contract-context.svelte.ts +37 -0
  16. package/src/contract-form.svelte.ts +566 -0
  17. package/src/create-contract.ts +56 -0
  18. package/src/create-hooks.svelte.ts +347 -0
  19. package/src/create-many-contract.ts +90 -0
  20. package/src/create-many-hooks.svelte.ts +396 -0
  21. package/src/data-transfer.svelte.ts +1 -215
  22. package/src/defined-options.ts +50 -0
  23. package/src/delete-contract.ts +50 -0
  24. package/src/delete-hooks.svelte.ts +403 -0
  25. package/src/delete-many-contract.ts +60 -0
  26. package/src/delete-many-hooks.svelte.ts +402 -0
  27. package/src/enterprise.ts +4 -4
  28. package/src/export-format.ts +16 -14
  29. package/src/export-hooks.svelte.ts +191 -0
  30. package/src/feature-gate-contract.ts +53 -0
  31. package/src/hook-utils.svelte.ts +53 -105
  32. package/src/hooks.svelte.ts +19 -776
  33. package/src/http.ts +88 -87
  34. package/src/i18n.svelte.ts +64 -2
  35. package/src/import-contract.ts +106 -0
  36. package/src/import-hooks.svelte.ts +310 -0
  37. package/src/index.ts +77 -30
  38. package/src/inferencer-graphql.ts +7 -6
  39. package/src/inferencer.ts +9 -0
  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 -288
  55. package/src/query-invalidation.ts +172 -0
  56. package/src/query-keys.ts +221 -110
  57. package/src/query-session.svelte.ts +220 -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 +103 -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 +190 -180
  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 +68 -110
  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
  98. package/src/schema-form.ts +0 -31
@@ -0,0 +1,37 @@
1
+ import { captureAdminContext } from './context.svelte';
2
+ import { keys } from './query-keys';
3
+ import { contractKey, contractProvider, type ResourceContract } from './resource-contract';
4
+ import { replaceReactiveMembers } from './reactive-projection';
5
+ import { definedOptions } from './defined-options';
6
+
7
+ export interface ContractOptions {
8
+ /** @internal Installed by the public contract-bound hooks. */
9
+ contract?: ResourceContract;
10
+ }
11
+
12
+ export function captureContractContext(options: ContractOptions | (() => ContractOptions)) {
13
+ const context = captureAdminContext();
14
+ const current = () => (typeof options === 'function' ? options() : options).contract;
15
+ return replaceReactiveMembers(context, {
16
+ getDataProviderForResource(resource: string, name?: string) {
17
+ const provider = context.getDataProviderForResource(resource, name);
18
+ const contract = current();
19
+ return contract ? contractProvider(provider, contract) : provider;
20
+ },
21
+ queryKeys(resource?: string, name?: string) {
22
+ const builder = context.queryKeys(resource, name);
23
+ const contract = current();
24
+ if (!contract) return builder;
25
+ const descriptor = builder.data.list(resource ?? '')[0];
26
+ return keys(definedOptions({
27
+ provider: descriptor.provider, tenant: descriptor.tenant, contract: contractKey(contract),
28
+ resource: descriptor.resource, id: descriptor.id, params: descriptor.params, method: descriptor.method,
29
+ }));
30
+ },
31
+ queryKeyMatcher(resource?: string, name?: string) {
32
+ const matcher = context.queryKeyMatcher(resource, name);
33
+ const contract = current();
34
+ return contract ? { ...matcher, contract: contractKey(contract) } : matcher;
35
+ },
36
+ });
37
+ }
@@ -0,0 +1,566 @@
1
+ import { createQuery, useQueryClient } from '@tanstack/svelte-query';
2
+ import { tick } from 'svelte';
3
+ import { captureAdminContext } from './context.svelte';
4
+ import { captureQueryProvider, snapshotOneParams } from './query-snapshot';
5
+ import { contractKey, formatContractRouteId, parseContractId, parseContractRouteId, parseContractRecord, parseContractCreateInput, parseContractUpdateInput,
6
+ getContractFormFields, snapshotContractFormDraft, contractFormInvalidFields,
7
+ type ResourceContract, type ContractSchemas, type ContractId, type ContractRecord,
8
+ type ContractFormAction, type ContractFormDraft, type ContractFormValues } from './resource-contract';
9
+ import { validateUpdateIdentity } from './update-contract';
10
+ import { createInputId } from './create-contract';
11
+ import { decodeBaseRecord, decodeOneResult, rejectProviderResponse } from './record-decoder';
12
+ import { snapshotPlainData } from './plain-data';
13
+ import { definedOptions } from './defined-options';
14
+ import { keys } from './query-keys';
15
+ import { invalidateOwnedQueries } from './query-invalidation';
16
+ import { appendListQueryFromPath } from './url-sync';
17
+ import { HttpError } from './types';
18
+ import { useTranslation } from './i18n.svelte';
19
+ import { Type } from '@sinclair/typebox';
20
+ import { checkExact } from './schema-validation';
21
+ import { auditWithProvider } from './audit';
22
+ import { fireSuccessNotification, fireErrorNotification } from './hook-utils.svelte';
23
+ import { captureAuthLiveScope, clearAuthQueries, handleAuthError } from './auth-hooks.svelte';
24
+ import { getAdminOptions } from './options.svelte';
25
+
26
+ type Redirect = 'list' | 'edit' | 'show' | false;
27
+ type Values<S extends ContractSchemas, A extends ContractFormAction> = ContractFormValues<S, A>;
28
+ type Draft<S extends ContractSchemas, A extends ContractFormAction> = ContractFormDraft<S, A>;
29
+ type Field<S extends ContractSchemas, A extends ContractFormAction> = Extract<keyof Values<S, A>, string>;
30
+ interface FormOptions<S extends ContractSchemas, A extends ContractFormAction> {
31
+ resource: ResourceContract<S>;
32
+ action: A;
33
+ id?: NoInfer<ContractId<S>>;
34
+ defaultValues?: NoInfer<Partial<Values<S, A>>>;
35
+ enabled?: boolean;
36
+ redirect?: Redirect;
37
+ meta?: Record<string, unknown>;
38
+ dataProviderName?: string;
39
+ validate?: (values: NoInfer<Draft<S, A>>) => Record<string, string> | null;
40
+ onMutationSuccess?: (result: { data: NoInfer<ContractRecord<S>> }) => void | Promise<void>;
41
+ onMutationError?: (error: HttpError) => void | Promise<void>;
42
+ warnWhenUnsavedChanges?: boolean;
43
+ }
44
+ export type ContractFormOptions<S extends ContractSchemas, A extends ContractFormAction> =
45
+ FormOptions<S, A> & ([Values<NoInfer<S>, NoInfer<A>>] extends [never] ? never : unknown) &
46
+ (ContractSchemas extends S ? unknown : A extends 'create' ? { id?: never } : { id: NoInfer<ContractId<S>> });
47
+
48
+ function cancelled(dispatched = false): HttpError {
49
+ return new HttpError('Form scope changed', 409, undefined, {
50
+ code: 'FORM_CANCELLED', details: { writeMayHaveSucceeded: dispatched },
51
+ });
52
+ }
53
+ function failure(cause: unknown, dispatched = false): HttpError {
54
+ let code: unknown;
55
+ let status: unknown;
56
+ try {
57
+ if (typeof cause === 'object' && cause !== null) {
58
+ code = Object.getOwnPropertyDescriptor(cause, 'code')?.value;
59
+ status = Object.getOwnPropertyDescriptor(cause, 'statusCode')?.value;
60
+ }
61
+ } catch { /* Untrusted error reflection must not replace the original failure. */ }
62
+ const input = code === 'INVALID_RESOURCE_INPUT' && !dispatched;
63
+ const response = code === 'INVALID_PROVIDER_RESPONSE';
64
+ return new HttpError(input ? 'Invalid form input' : response ? 'Invalid provider response' : 'Form operation failed',
65
+ input ? 422 : response ? 502 : status === 401 || status === 403 ? status : 500, undefined, {
66
+ code: input ? 'INVALID_RESOURCE_INPUT' : response ? 'INVALID_PROVIDER_RESPONSE' : 'FORM_FAILED',
67
+ details: { writeMayHaveSucceeded: dispatched },
68
+ });
69
+ }
70
+ function copyFailure(error: HttpError): HttpError {
71
+ return new HttpError(error.message, error.statusCode, undefined, {
72
+ ...definedOptions({ code: error.code }), details: snapshotPlainData(error.details ?? {}),
73
+ });
74
+ }
75
+ function notify<T>(callback: ((value: T) => void | Promise<void>) | undefined, value: T): void {
76
+ try { void Promise.resolve(callback?.(value)).catch(() => {}); }
77
+ catch { /* Observers cannot turn successful writes into failed submissions. */ }
78
+ }
79
+ const errorsSchema = Type.Record(Type.String(), Type.String());
80
+ const serverErrorsSchema = Type.Record(Type.String(), Type.Union([Type.String(), Type.Array(Type.String())]));
81
+ function serverErrorFields(cause: unknown): string[] {
82
+ try {
83
+ if (typeof cause !== 'object' || cause === null) return [];
84
+ const descriptor = Object.getOwnPropertyDescriptor(cause, 'errors');
85
+ if (!descriptor || !('value' in descriptor)) return [];
86
+ const errors = snapshotPlainData(descriptor.value);
87
+ return checkExact(serverErrorsSchema, errors) ? Object.keys(errors) : [];
88
+ } catch { return []; }
89
+ }
90
+ const submitSchema = Type.Object({
91
+ redirect: Type.Optional(Type.Union([Type.Literal('list'), Type.Literal('edit'), Type.Literal('show'), Type.Literal(false)])),
92
+ }, { additionalProperties: false });
93
+ const editSchema = Type.Object({ taint: Type.Optional(Type.Boolean()) }, { additionalProperties: false });
94
+
95
+ /** 地址栏里的 id 是字符串,需要按路由规则还原成合同身份。 */
96
+ function parseFormTargetId(contract: ResourceContract, value: unknown): string | number {
97
+ return typeof value === 'string' ? parseContractRouteId(contract, value) : parseContractId(contract, value);
98
+ }
99
+
100
+ function ownValue(value: object, field: string): unknown {
101
+ return Object.getOwnPropertyDescriptor(value, field)?.value;
102
+ }
103
+
104
+ export function useContractForm<S extends ContractSchemas, A extends ContractFormAction>(
105
+ options: FormOptions<S, A>,
106
+ workflow?: { readonly key: string; readonly enabled?: boolean; reset: () => void },
107
+ ) {
108
+ const context = captureAdminContext();
109
+ const client = useQueryClient();
110
+ const i18n = useTranslation();
111
+ const adminOptions = getAdminOptions();
112
+ const enabled = () => (options.enabled === undefined || options.enabled === true) &&
113
+ (workflow?.enabled === undefined || workflow.enabled === true);
114
+ contractKey(options.resource);
115
+ getContractFormFields(options.resource, options.action);
116
+ let mounted = true;
117
+ let initial = snapshotContractFormDraft(options.resource, options.action, options.defaultValues ?? {});
118
+ let values = $state<Draft<S, A>>(snapshotContractFormDraft(options.resource, options.action, initial));
119
+ let errors = $state<Record<string, string>>({});
120
+ let tainted = $state<Record<string, boolean>>({});
121
+ let error = $state<HttpError | null>(null);
122
+ let submitting = $state(false);
123
+ let hydratedKey = $state<string | undefined>();
124
+ let active: { token: object; scope: Scope; promise: Promise<{ data: ContractRecord<S> }>; wasDispatched: () => boolean } | undefined;
125
+ let latest: object | undefined;
126
+ let draftRevision = {};
127
+
128
+ function captureScope() {
129
+ const contract = options.resource;
130
+ const action = options.action;
131
+ if (!['create', 'edit', 'clone', 'show'].includes(action)) throw new HttpError('Invalid form action', 422, undefined, { code: 'INVALID_RESOURCE_INPUT' });
132
+ getContractFormFields(contract, action);
133
+ const id = action === 'create' ? undefined : parseFormTargetId(contract, options.id);
134
+ if (action === 'create' && options.id !== undefined) throw new HttpError('Create forms cannot target an ID', 422, undefined, { code: 'INVALID_RESOURCE_INPUT' });
135
+ const captured = captureQueryProvider(context, { resource: contract.name,
136
+ ...definedOptions({ dataProviderName: options.dataProviderName, meta: options.meta }) });
137
+ const providerName = context.resolveDataProviderName(contract.name, options.dataProviderName);
138
+ const matcher = definedOptions({ ...context.queryKeyMatcher(contract.name, options.dataProviderName), contract: contractKey(contract) });
139
+ const raw = context.providers?.[providerName];
140
+ if (!raw) throw new HttpError('Data provider unavailable', 400, undefined, { code: 'DATA_PROVIDER_REQUIRED' });
141
+ const authScope = captureAuthLiveScope(context.authProvider);
142
+ const targetKey = JSON.stringify([contractKey(contract), action, id, providerName, captured.source, matcher.tenant, captured.meta, enabled(), workflow?.key]);
143
+ return {
144
+ contract, action, id, matcher, ...captured,
145
+ defaults: snapshotContractFormDraft(contract, action, options.defaultValues ?? {}),
146
+ create: raw.create.bind(raw), update: raw.update.bind(raw),
147
+ authProvider: context.authProvider, authScope, router: context.routerProvider,
148
+ notificationProvider: context.notificationProvider, auditLogProvider: context.auditLogProvider,
149
+ liveProvider: context.liveProvider,
150
+ workflowKey: workflow?.key,
151
+ targetKey, key: JSON.stringify([targetKey, authScope.cacheKey]),
152
+ };
153
+ }
154
+ type Scope = ReturnType<typeof captureScope>;
155
+ const state = $derived.by(() => {
156
+ try { return { ok: true as const, scope: captureScope() }; }
157
+ catch (cause) { return { ok: false as const, error: failure(cause) }; }
158
+ });
159
+ function targetCurrent(scope: Scope): boolean {
160
+ return mounted && enabled() && state.ok && state.scope.targetKey === scope.targetKey &&
161
+ context.authProvider === scope.authProvider && context.routerProvider === scope.router;
162
+ }
163
+ function sessionCurrent(scope: Scope): boolean {
164
+ return context.authProvider === scope.authProvider && scope.authScope.isCurrent();
165
+ }
166
+ function isCurrent(scope: Scope): boolean {
167
+ return targetCurrent(scope) && sessionCurrent(scope);
168
+ }
169
+ function ready(): boolean {
170
+ return state.ok && isCurrent(state.scope) && hydratedKey === state.scope.key;
171
+ }
172
+ function draft(scope: Scope, value: unknown) {
173
+ return snapshotContractFormDraft(scope.contract, scope.action, value);
174
+ }
175
+ function recordDraft(scope: Scope, record: ContractRecord<S>): Draft<S, A> {
176
+ const names: readonly string[] = getContractFormFields(scope.contract, scope.action);
177
+ const skip = new Set(scope.action === 'clone'
178
+ ? ['id', '_id', 'createdAt', 'updatedAt', 'created_at', 'updated_at', 'createdBy', 'updatedBy', 'created_by', 'updated_by'] : []);
179
+ const source = Object.fromEntries(Object.entries(record).filter(([key]) => names.includes(key) && !skip.has(key)));
180
+ return draft(scope, { ...scope.defaults, ...source });
181
+ }
182
+ function queryKey(scope: Scope) {
183
+ return keys(scope.matcher).data.one(scope.contract.name, scope.id ?? '', {
184
+ source: scope.source, authSession: scope.authScope.cacheKey, ...definedOptions({ meta: scope.meta }),
185
+ ...definedOptions({ formWorkflow: scope.workflowKey }),
186
+ });
187
+ }
188
+ const query = createQuery<{ data: ContractRecord<S> }, unknown>(() => {
189
+ const current = state;
190
+ if (!current.ok) return {
191
+ queryKey: ['svadmin-form-disabled', contractKey(options.resource)],
192
+ queryFn: async (): Promise<{ data: ContractRecord<S> }> => { throw current.error; },
193
+ enabled: false, retry: false,
194
+ };
195
+ const scope = current.scope;
196
+ return {
197
+ queryKey: queryKey(scope),
198
+ queryFn: async () => {
199
+ if (!isCurrent(scope)) throw cancelled();
200
+ if (scope.id === undefined) throw new HttpError('A form read requires an ID', 422, undefined, { code: 'INVALID_RESOURCE_INPUT' });
201
+ const response = await scope.provider.getOne(snapshotOneParams({
202
+ resource: scope.contract.name, id: scope.id, ...definedOptions({ meta: scope.meta }),
203
+ }));
204
+ if (!isCurrent(scope)) throw cancelled();
205
+ const result = decodeOneResult(snapshotPlainData(response), value => parseContractRecord(scope.contract, value));
206
+ if (result.data.id !== scope.id) return rejectProviderResponse();
207
+ return result;
208
+ },
209
+ enabled: enabled() && scope.authScope.available && scope.action !== 'create',
210
+ retry: false,
211
+ };
212
+ });
213
+ function currentQuery() {
214
+ if (!state.ok || !isCurrent(state.scope) || state.scope.action === 'create') return undefined;
215
+ const cached = client.getQueryState(queryKey(state.scope));
216
+ // Reactive observer results can still belong to the previous key before the next flush.
217
+ return cached && cached.data === query.data && cached.error === query.error ? query : undefined;
218
+ }
219
+ let previousKey: string | undefined;
220
+ let previousTarget: string | undefined;
221
+ let previousAuth = context.authProvider;
222
+ let previousRouter = context.routerProvider;
223
+ $effect.pre(() => {
224
+ const key = state.ok ? state.scope.key : undefined;
225
+ const auth = context.authProvider;
226
+ const router = context.routerProvider;
227
+ if (key !== previousKey || auth !== previousAuth || router !== previousRouter) {
228
+ const target = state.ok ? state.scope.targetKey : undefined;
229
+ const authTransition = latest !== undefined && auth === previousAuth && !captureAuthLiveScope(auth).isCurrent();
230
+ if ((target !== previousTarget || auth !== previousAuth || router !== previousRouter) && !authTransition) latest = undefined;
231
+ previousTarget = target;
232
+ previousKey = key;
233
+ previousAuth = auth;
234
+ previousRouter = router;
235
+ active = undefined;
236
+ draftRevision = {};
237
+ submitting = false;
238
+ error = null;
239
+ errors = {};
240
+ tainted = {};
241
+ hydratedKey = undefined;
242
+ values = snapshotContractFormDraft(options.resource, options.action, options.defaultValues ?? {});
243
+ initial = snapshotContractFormDraft(options.resource, options.action, values);
244
+ workflow?.reset();
245
+ }
246
+ if (!state.ok || !isCurrent(state.scope)) return;
247
+ const scope = state.scope;
248
+ if (scope.action === 'create') { hydratedKey = scope.key; return; }
249
+ const result = checkedQueryData();
250
+ if (hydratedKey !== scope.key && result) {
251
+ values = recordDraft(scope, result.data);
252
+ initial = draft(scope, values);
253
+ errors = {};
254
+ tainted = {};
255
+ hydratedKey = scope.key;
256
+ }
257
+ });
258
+ $effect(() => () => { mounted = false; active = undefined; latest = undefined; });
259
+
260
+ function isTainted(field?: string): boolean {
261
+ return ready() && (field === undefined ? Object.values(tainted).some(Boolean) : tainted[field] === true);
262
+ }
263
+ function setValues(newValues: Partial<Values<S, A>>, opts: { taint?: boolean } = {}) {
264
+ updateField(newValues, opts);
265
+ }
266
+ function setFieldValue<K extends Field<S, A>>(field: K, value: NoInfer<Values<S, A>[K]> | undefined, opts?: { taint?: boolean }) {
267
+ const next = { [field]: value };
268
+ // The typed setter delegates its runtime check to the same draft boundary as bulk edits.
269
+ updateField(next, opts);
270
+ }
271
+ function updateField(next: Record<string, unknown>, opts?: { taint?: boolean }) {
272
+ if (!state.ok || !isCurrent(state.scope) || state.scope.action === 'show' || hydratedKey !== state.scope.key) return;
273
+ const scope = state.scope;
274
+ const revision = draftRevision;
275
+ const settings = snapshotPlainData(opts === undefined ? {} : opts);
276
+ if (!checkExact(editSchema, settings)) throw new HttpError('Invalid form edit options', 422, undefined, { code: 'INVALID_RESOURCE_INPUT' });
277
+ const patch = draft(scope, next);
278
+ const changed = Object.keys(next);
279
+ const updated = draft(scope, { ...Object.fromEntries(Object.entries(values).filter(([key]) => !changed.includes(key))), ...patch });
280
+ if (!isCurrent(scope) || revision !== draftRevision) return;
281
+ draftRevision = {};
282
+ values = updated;
283
+ if (settings.taint !== false) tainted = { ...tainted, ...Object.fromEntries(changed.map(key => [key, true])) };
284
+ errors = Object.fromEntries(Object.entries(errors).filter(([key]) => !changed.includes(key)));
285
+ error = null;
286
+ }
287
+ function reset() {
288
+ draftRevision = {};
289
+ active = undefined;
290
+ latest = undefined;
291
+ submitting = false;
292
+ if (!state.ok || !ready()) return;
293
+ values = draft(state.scope, initial);
294
+ tainted = {};
295
+ errors = {};
296
+ error = null;
297
+ }
298
+ function validationErrors(scope: Scope, submitted: Draft<S, A>): Record<string, string> {
299
+ let result: Record<string, string> = {};
300
+ const custom = options.validate?.(draft(scope, submitted));
301
+ if (custom !== undefined && custom !== null) {
302
+ const checked = snapshotPlainData(custom);
303
+ if (!checkExact(errorsSchema, checked)) throw new HttpError('Invalid form validator result', 422, undefined, { code: 'INVALID_RESOURCE_INPUT' });
304
+ result = checked;
305
+ }
306
+ for (const field of contractFormInvalidFields(scope.contract, scope.action, submitted)) {
307
+ if (!result[field]) result = { ...result, [field]: i18n.t('validation.invalidFormat') };
308
+ }
309
+ return result;
310
+ }
311
+ function validate(scope: Scope, submitted: Draft<S, A>, current: () => boolean): unknown {
312
+ const checked = validationErrors(scope, submitted);
313
+ if (!current()) throw cancelled();
314
+ errors = checked;
315
+ if (Object.keys(errors).length) throw new HttpError('Invalid form input', 422, undefined, { code: 'INVALID_RESOURCE_INPUT' });
316
+ return scope.action === 'edit' ? parseContractUpdateInput(scope.contract, submitted) : parseContractCreateInput(scope.contract, submitted);
317
+ }
318
+ function validateFields(fields?: readonly Field<S, A>[]): boolean {
319
+ if (!state.ok || !isCurrent(state.scope) || hydratedKey !== state.scope.key || submitting || state.scope.action === 'show') return false;
320
+ const scope = state.scope;
321
+ const revision = draftRevision;
322
+ const current = () => isCurrent(scope) && revision === draftRevision;
323
+ try {
324
+ const names: readonly string[] = getContractFormFields(state.scope.contract, state.scope.action);
325
+ const selected = snapshotPlainData(fields ?? names);
326
+ if (!checkExact(Type.Array(Type.String()), selected) || selected.some(field => !names.includes(field))) {
327
+ throw new HttpError('Invalid form fields', 422, undefined, { code: 'INVALID_RESOURCE_INPUT' });
328
+ }
329
+ const checked = validationErrors(scope, draft(scope, values));
330
+ if (!current()) return false;
331
+ errors = Object.fromEntries(Object.entries(checked).filter(([field]) => selected.includes(field) || field === '_root'));
332
+ error = null;
333
+ return Object.keys(errors).length === 0;
334
+ } catch (cause) {
335
+ if (current()) error = failure(cause);
336
+ return false;
337
+ }
338
+ }
339
+ async function refresh(scope: Scope, ids: readonly (string | number)[]) {
340
+ const owned = {
341
+ client, matcher: { ...scope.matcher, resource: scope.contract.name },
342
+ owner: { source: scope.source, authSession: scope.authScope.cacheKey },
343
+ current: () => sessionCurrent(scope),
344
+ };
345
+ await Promise.allSettled([
346
+ invalidateOwnedQueries({ ...owned, scopes: ['list', 'many'] }),
347
+ ...ids.map(id => invalidateOwnedQueries({ ...owned, scopes: ['detail'], id })),
348
+ ]);
349
+ }
350
+ function redirect(scope: Scope, to: Redirect, id: string | number, current: () => boolean) {
351
+ if (!to || !current() || isTainted()) return;
352
+ const base = `/${encodeURIComponent(scope.contract.name)}`;
353
+ const target = to === 'list' ? base : `${base}/${to}/${encodeURIComponent(formatContractRouteId(scope.contract, parseContractId(scope.contract, id)))}`;
354
+ notify(() => {
355
+ const path = appendListQueryFromPath(target, context.currentPath());
356
+ if (current() && !isTainted()) return context.navigate(path);
357
+ }, undefined);
358
+ }
359
+ function submit(overrides: { redirect?: Redirect } = {}): Promise<{ data: ContractRecord<S> }> {
360
+ function joined(operation: NonNullable<typeof active>) {
361
+ const current = () => isCurrent(operation.scope) && latest === operation.token;
362
+ return operation.promise.then(result => {
363
+ if (!current()) throw cancelled(operation.wasDispatched());
364
+ return { data: parseContractRecord(operation.scope.contract, result.data) };
365
+ }, (cause: unknown) => {
366
+ if (!current()) throw cancelled(operation.wasDispatched());
367
+ throw cause instanceof HttpError ? copyFailure(cause) : failure(cause, operation.wasDispatched());
368
+ });
369
+ }
370
+ const origin = state.ok ? state.scope : undefined;
371
+ if (origin && !isCurrent(origin)) {
372
+ latest = undefined;
373
+ return Promise.reject(cancelled());
374
+ }
375
+ let input: { redirect?: Redirect };
376
+ try {
377
+ const checked = snapshotPlainData(overrides);
378
+ if (!checkExact(submitSchema, checked)) throw new HttpError('Invalid form input', 422, undefined, { code: 'INVALID_RESOURCE_INPUT' });
379
+ input = checked;
380
+ } catch (cause) {
381
+ const rejected = failure(cause);
382
+ if (!active) latest = undefined;
383
+ error = rejected;
384
+ notify(options.onMutationError, copyFailure(rejected));
385
+ if (origin && !isCurrent(origin)) return Promise.reject(cancelled());
386
+ return Promise.reject(copyFailure(rejected));
387
+ }
388
+ if (active && isCurrent(active.scope)) return joined(active);
389
+ const token = {};
390
+ latest = token;
391
+ let dispatched = false;
392
+ let scope: Scope;
393
+ let submitted: Draft<S, A>;
394
+ let payload: unknown;
395
+ let destination: Redirect;
396
+ let suppliedId: string | number | undefined;
397
+ const onSuccess = options.onMutationSuccess;
398
+ const onError = options.onMutationError;
399
+ try {
400
+ if (!state.ok) throw state.error;
401
+ scope = state.scope;
402
+ if (!isCurrent(scope)) return Promise.reject(cancelled());
403
+ if (scope.action === 'show' || hydratedKey !== scope.key) throw new HttpError('Form is not writable', 422, undefined, { code: 'INVALID_RESOURCE_INPUT' });
404
+ destination = input.redirect ?? options.redirect ?? (scope.action === 'edit' ? adminOptions.redirect?.afterEdit
405
+ : scope.action === 'clone' ? adminOptions.redirect?.afterClone : adminOptions.redirect?.afterCreate) ?? 'list';
406
+ if (![false, 'list', 'edit', 'show'].includes(destination)) throw new HttpError('Invalid redirect', 422, undefined, { code: 'INVALID_RESOURCE_INPUT' });
407
+ submitted = draft(scope, values);
408
+ const captured = scope;
409
+ const revision = draftRevision;
410
+ payload = validate(scope, submitted, () => latest === token && isCurrent(captured) && revision === draftRevision);
411
+ if (scope.action === 'edit' && scope.id !== undefined) validateUpdateIdentity(payload, scope.id);
412
+ if (scope.action !== 'edit') suppliedId = createInputId(scope.contract, payload);
413
+ } catch (cause) {
414
+ if (latest !== token || (origin && !isCurrent(origin)) || (cause instanceof HttpError && cause.code === 'FORM_CANCELLED')) {
415
+ return Promise.reject(cancelled());
416
+ }
417
+ const rejected = failure(cause);
418
+ error = rejected;
419
+ notify(onError, copyFailure(rejected));
420
+ if (latest !== token || (origin && !isCurrent(origin))) return Promise.reject(cancelled());
421
+ return Promise.reject(copyFailure(rejected));
422
+ }
423
+ const target = scope;
424
+ const source = submitted;
425
+ const current = () => isCurrent(target) && latest === token;
426
+ if (!current()) return Promise.reject(cancelled());
427
+ submitting = true;
428
+ error = null;
429
+ const promise = Promise.resolve().then(async () => {
430
+ let record: ContractRecord<S> | undefined;
431
+ let rejected: { cause: unknown; error: HttpError } | undefined;
432
+ let resultId: string | number | undefined;
433
+ const refreshIds: (string | number)[] = target.action === 'edit' && target.id !== undefined
434
+ ? [target.id] : suppliedId === undefined ? [] : [suppliedId];
435
+ try {
436
+ if (!isCurrent(target) || active?.token !== token) throw cancelled();
437
+ const request = { resource: target.contract.name, variables: snapshotPlainData(payload),
438
+ ...definedOptions({ meta: target.meta === undefined ? undefined : decodeBaseRecord(snapshotPlainData(target.meta)) }) };
439
+ dispatched = true;
440
+ const response = target.action === 'edit' && target.id !== undefined
441
+ ? await target.update({ ...request, id: target.id }) : await target.create(request);
442
+ if (!sessionCurrent(target)) throw cancelled(dispatched);
443
+ try {
444
+ record = decodeOneResult(snapshotPlainData(response), value => parseContractRecord(target.contract, value), true).data;
445
+ resultId = parseContractId(target.contract, record.id);
446
+ if (resultId === undefined) return rejectProviderResponse(true);
447
+ if (target.action === 'edit' && resultId !== target.id) rejectProviderResponse(true);
448
+ if (target.action !== 'edit' && suppliedId !== undefined && suppliedId !== resultId) rejectProviderResponse(true);
449
+ if (!refreshIds.includes(resultId)) refreshIds.push(resultId);
450
+ } catch { return rejectProviderResponse(true); }
451
+ auditWithProvider({ action: target.action === 'edit' ? 'update' : 'create', resource: target.contract.name,
452
+ recordId: resultId, ...definedOptions({ meta: target.meta }) }, target.auditLogProvider, () => sessionCurrent(target));
453
+ notify(() => { if (sessionCurrent(target)) return target.liveProvider?.publish?.({
454
+ type: target.action === 'edit' ? 'UPDATE' : 'INSERT', resource: target.contract.name, payload: { ids: [resultId] },
455
+ }); }, undefined);
456
+ } catch (cause) {
457
+ rejected = { cause, error: failure(cause, dispatched) };
458
+ } finally {
459
+ if (dispatched && sessionCurrent(target)) await refresh(target, refreshIds);
460
+ }
461
+ if (!isCurrent(target) || active?.token !== token) throw cancelled(dispatched);
462
+ if (rejected) {
463
+ const failed = rejected;
464
+ error = failed.error;
465
+ const fields: readonly string[] = getContractFormFields(target.contract, target.action);
466
+ for (const field of serverErrorFields(failed.cause)) {
467
+ if (fields.includes(field) && JSON.stringify(ownValue(values, field)) === JSON.stringify(ownValue(source, field))) {
468
+ errors = { ...errors, [field]: i18n.t('validation.invalidFormat') };
469
+ }
470
+ }
471
+ notify(async () => { await handleAuthError(copyFailure(failed.error), context,
472
+ () => targetCurrent(target) && latest === token,
473
+ () => clearAuthQueries(client, target.authProvider), target.authScope,
474
+ () => mounted && latest !== token); }, undefined);
475
+ if (current()) notify(() => fireErrorNotification({ config: undefined, defaultMessage: i18n.t('common.operationFailed'), error: copyFailure(failed.error),
476
+ resource: target.contract.name, ...definedOptions({ provider: target.notificationProvider }) }), undefined);
477
+ if (current()) notify(onError, copyFailure(failed.error));
478
+ throw failed.error;
479
+ }
480
+ if (!record || resultId === undefined) return rejectProviderResponse(true);
481
+ const persisted = recordDraft(target, record);
482
+ const changed = Object.keys(values).filter(key => JSON.stringify(ownValue(values, key)) !== JSON.stringify(ownValue(source, key)));
483
+ for (const key of Object.keys(source)) {
484
+ if (!Object.hasOwn(values, key) && !changed.includes(key)) changed.push(key);
485
+ }
486
+ const reconciled = Object.fromEntries([
487
+ ...Object.entries(persisted).filter(([key]) => !changed.includes(key)),
488
+ ...Object.entries(values).filter(([key]) => changed.includes(key)),
489
+ ]);
490
+ values = draft(target, reconciled);
491
+ initial = draft(target, persisted);
492
+ tainted = Object.fromEntries(changed.map(key => [key, true]));
493
+ notify(() => fireSuccessNotification({ config: undefined, defaultMessage: i18n.t(target.action === 'edit' ? 'common.updateSuccess' : 'common.createSuccess'),
494
+ resource: target.contract.name, ...definedOptions({ provider: target.notificationProvider }) }), undefined);
495
+ if (current()) notify(onSuccess, { data: parseContractRecord(target.contract, record) });
496
+ if (current()) redirect(target, destination, resultId, current);
497
+ if (!current()) throw cancelled(dispatched);
498
+ return { data: parseContractRecord(target.contract, record) };
499
+ }).finally(() => {
500
+ if (active?.token === token) { active = undefined; submitting = false; }
501
+ });
502
+ active = { token, scope: target, promise, wasDispatched: () => dispatched };
503
+ return joined(active);
504
+ }
505
+ $effect(() => {
506
+ if (!(options.warnWhenUnsavedChanges ?? adminOptions.warnWhenUnsavedChanges) || !isTainted() || !enabled() || typeof window === 'undefined') return;
507
+ const scope = state.ok ? state.scope : undefined;
508
+ const handler = (event: BeforeUnloadEvent) => {
509
+ if (!scope || !isCurrent(scope) || !isTainted()) return;
510
+ event.preventDefault(); event.returnValue = '';
511
+ };
512
+ window.addEventListener('beforeunload', handler);
513
+ return () => window.removeEventListener('beforeunload', handler);
514
+ });
515
+ return {
516
+ get values(): Readonly<Draft<S, A>> {
517
+ return state.ok && isCurrent(state.scope) && hydratedKey === state.scope.key
518
+ ? draft(state.scope, values) : snapshotContractFormDraft(options.resource, options.action, {});
519
+ },
520
+ setValues, setFieldValue, reset, submit, validateFields,
521
+ get errors(): Readonly<Record<string, string>> { return ready() ? { ...errors } : {}; },
522
+ setFieldError(field: Field<S, A> | '_root', message: string) {
523
+ if (!ready()) return;
524
+ const names: readonly string[] = getContractFormFields(options.resource, options.action);
525
+ if (typeof field !== 'string' || (field !== '_root' && !names.includes(field)) || typeof message !== 'string') {
526
+ throw new HttpError('Invalid form error', 422, undefined, { code: 'INVALID_RESOURCE_INPUT' });
527
+ }
528
+ errors = { ...errors, [field]: message };
529
+ },
530
+ clearFieldError(field: string) { if (ready()) errors = Object.fromEntries(Object.entries(errors).filter(([key]) => key !== field)); },
531
+ clearErrors() { if (ready()) errors = {}; },
532
+ get tainted(): Readonly<Record<string, boolean>> { return ready() ? { ...tainted } : {}; }, isTainted, untaint() { if (ready()) tainted = {}; },
533
+ get isDirty() { return isTainted(); },
534
+ get submitting() { return ready() && submitting; },
535
+ get loading() { return state.ok && isCurrent(state.scope) && state.scope.action !== 'create' && !(currentQuery()?.isSuccess || currentQuery()?.isError); },
536
+ get error() { return !state.ok ? copyFailure(state.error) : !isCurrent(state.scope) ? null
537
+ : error && ready() ? copyFailure(error) : currentQuery()?.isError ? failure(query.error) : null; },
538
+ get ready() { return ready(); },
539
+ get id() { return state.ok ? state.scope.id : undefined; },
540
+ get action() { return options.action; },
541
+ get resource() { return options.resource.name; },
542
+ query: {
543
+ get data() { return checkedQueryData(); },
544
+ get error() { return currentQuery()?.isError ? failure(query.error) : null; },
545
+ get isPending() { return currentQuery()?.isPending ?? true; },
546
+ get isError() { return currentQuery()?.isError ?? false; },
547
+ get isSuccess() { return currentQuery()?.isSuccess ?? false; },
548
+ get refetch() {
549
+ const scope = state.ok ? state.scope : undefined;
550
+ return async () => {
551
+ if (!scope || !isCurrent(scope) || scope.action === 'create') throw cancelled();
552
+ await tick();
553
+ if (!isCurrent(scope)) throw cancelled();
554
+ await query.refetch();
555
+ if (!isCurrent(scope)) throw cancelled();
556
+ return { data: checkedQueryData(), error: currentQuery()?.isError ? failure(query.error) : null };
557
+ };
558
+ },
559
+ },
560
+ };
561
+
562
+ function checkedQueryData() {
563
+ if (!state.ok || !currentQuery()?.isSuccess || !query.isSuccess) return undefined;
564
+ return { data: parseContractRecord(state.scope.contract, query.data.data) };
565
+ }
566
+ }
@@ -0,0 +1,56 @@
1
+ import { Type } from '@sinclair/typebox';
2
+ import { checkExact } from './schema-validation';
3
+ import { snapshotPlainData } from './plain-data';
4
+ import { definedOptions } from './defined-options';
5
+ import { parseContractCreateInput, parseContractId, parseContractRecord,
6
+ type ContractSchemas, type ContractCreateInput, type ResourceContract } from './resource-contract';
7
+ import { decodeOneResult, rejectProviderResponse } from './record-decoder';
8
+ import { HttpError } from './types';
9
+
10
+ const envelope = Type.Object({
11
+ variables: Type.Unknown(),
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 ContractCreateParams<S extends ContractSchemas> {
17
+ variables: ContractCreateInput<S>;
18
+ meta?: Record<string, unknown>;
19
+ dataProviderName?: string;
20
+ }
21
+
22
+ export function snapshotCreateParams(value: unknown) {
23
+ try {
24
+ const input = snapshotPlainData(value);
25
+ if (checkExact(envelope, input)) return input;
26
+ } catch { /* Submitted accessors and serialization hooks are not input data. */ }
27
+ throw new HttpError('Invalid create input', 422, undefined, {
28
+ code: 'INVALID_RESOURCE_INPUT', details: { phase: 'input', writeMayHaveSucceeded: false },
29
+ });
30
+ }
31
+
32
+ /** Receipts and inputs both use the private record ID schema, not caller-selected types. */
33
+ export function createInputId(contract: ResourceContract, value: unknown): string | number | undefined {
34
+ const id: unknown = typeof value === 'object' && value !== null
35
+ ? Object.getOwnPropertyDescriptor(value, 'id')?.value : undefined;
36
+ return id === undefined ? undefined : parseContractId(contract, id);
37
+ }
38
+
39
+ export function parseCreateParams<S extends ContractSchemas>(contract: ResourceContract<S>, value: unknown): ContractCreateParams<S>;
40
+ export function parseCreateParams(contract: ResourceContract, value: unknown): object {
41
+ const input = snapshotCreateParams(value);
42
+ const variables = parseContractCreateInput(contract, input.variables);
43
+ createInputId(contract, variables);
44
+ return { variables, ...definedOptions({ meta: input.meta, dataProviderName: input.dataProviderName }) };
45
+ }
46
+
47
+ export function decodeCreateReceipt<S extends ContractSchemas>(
48
+ contract: ResourceContract<S>, response: unknown, suppliedId: string | number | undefined,
49
+ ) {
50
+ try {
51
+ const result = decodeOneResult(snapshotPlainData(response), value => parseContractRecord(contract, value), true);
52
+ const id = parseContractId(contract, result.data.id);
53
+ if (suppliedId !== undefined && suppliedId !== id) return rejectProviderResponse(true);
54
+ return result;
55
+ } catch { return rejectProviderResponse(true); }
56
+ }