@svadmin/core 0.54.0 → 0.56.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 +1 -1
- package/src/admin-config-runtime.svelte.ts +14 -0
- package/src/admin-config.ts +333 -0
- package/src/admin-manifest.ts +132 -0
- package/src/index.ts +35 -1
- package/src/resource-contract.ts +111 -2
- package/src/resource-scope.svelte.ts +39 -0
package/package.json
CHANGED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime binding for `defineAdminConfig`.
|
|
3
|
+
*
|
|
4
|
+
* `provideAdminConfig` resolves the application config once during component
|
|
5
|
+
* initialization and installs the resulting ProviderBundle + ResourceDefinition
|
|
6
|
+
* list into the scoped `AdminContext` tree. It is intentionally thin: all
|
|
7
|
+
* composition and diagnostics live in the pure `admin-config` module.
|
|
8
|
+
*/
|
|
9
|
+
import { resolveAdminConfig, toAdminContextSource, type AdminConfig } from './admin-config';
|
|
10
|
+
import { provideAdminContext, type AdminContextValue } from './context.svelte';
|
|
11
|
+
|
|
12
|
+
export function provideAdminConfig(config: AdminConfig): AdminContextValue {
|
|
13
|
+
return provideAdminContext(toAdminContextSource(resolveAdminConfig(config)));
|
|
14
|
+
}
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Application composition layer.
|
|
3
|
+
*
|
|
4
|
+
* `defineAdminConfig` is the single, AI-readable application entrypoint: it
|
|
5
|
+
* composes provider bundles, resource contracts, and plugins into one typed
|
|
6
|
+
* object without taking over business routing. `resolveAdminConfig` folds
|
|
7
|
+
* plugin contributions into a deterministic snapshot plus diagnostics, which
|
|
8
|
+
* the CLI (`doctor` / `generate`) and DevTools can inspect.
|
|
9
|
+
*/
|
|
10
|
+
import type { DataProvider, ResourceDefinition } from './types';
|
|
11
|
+
import type { DataProviderInput, ProviderBundle } from './provider-bundle';
|
|
12
|
+
import type { ResourceContract } from './resource-contract';
|
|
13
|
+
|
|
14
|
+
/** Machine-readable capability vocabulary shared by plugins, generators, and `doctor`. */
|
|
15
|
+
export type AdminPluginCapability =
|
|
16
|
+
| 'data'
|
|
17
|
+
| 'auth'
|
|
18
|
+
| 'access-control'
|
|
19
|
+
| 'live'
|
|
20
|
+
| 'storage'
|
|
21
|
+
| 'audit-log'
|
|
22
|
+
| 'admin-route'
|
|
23
|
+
| 'notification'
|
|
24
|
+
| 'task'
|
|
25
|
+
| 'i18n'
|
|
26
|
+
| 'ai'
|
|
27
|
+
| 'dashboard'
|
|
28
|
+
| 'import-export'
|
|
29
|
+
| 'feature-flag';
|
|
30
|
+
|
|
31
|
+
export interface AdminPluginDocs {
|
|
32
|
+
readonly setup?: string;
|
|
33
|
+
readonly examples?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Runtime contributions a plugin may add to the application. */
|
|
37
|
+
export interface AdminPluginContribution {
|
|
38
|
+
readonly resources?: readonly AdminResourceInput[];
|
|
39
|
+
readonly providers?: ProviderBundle;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Declarative plugin manifest. The descriptor is data, not behavior: it lets
|
|
44
|
+
* `add`, `generate`, `doctor`, docs, and AI tooling discover what a plugin can
|
|
45
|
+
* do without importing its runtime implementation.
|
|
46
|
+
*/
|
|
47
|
+
export interface AdminPluginDescriptor {
|
|
48
|
+
readonly name: string;
|
|
49
|
+
readonly version: string;
|
|
50
|
+
readonly capabilities: readonly AdminPluginCapability[];
|
|
51
|
+
/** Optional JSON-schema-like config contract (for example a TypeBox schema). */
|
|
52
|
+
readonly configSchema?: unknown;
|
|
53
|
+
readonly providers?: readonly string[];
|
|
54
|
+
readonly commands?: readonly string[];
|
|
55
|
+
readonly generators?: readonly string[];
|
|
56
|
+
readonly docs?: AdminPluginDocs;
|
|
57
|
+
readonly contributes?: AdminPluginContribution;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function defineSvadminPlugin<const TPlugin extends AdminPluginDescriptor>(
|
|
61
|
+
plugin: TPlugin,
|
|
62
|
+
): TPlugin {
|
|
63
|
+
if (!plugin.name.trim()) {
|
|
64
|
+
throw new Error('defineSvadminPlugin requires a non-empty name');
|
|
65
|
+
}
|
|
66
|
+
if (!plugin.version.trim()) {
|
|
67
|
+
throw new Error(`Plugin "${plugin.name}" requires a non-empty version`);
|
|
68
|
+
}
|
|
69
|
+
const duplicates = duplicateValues(plugin.capabilities);
|
|
70
|
+
if (duplicates.length > 0) {
|
|
71
|
+
throw new Error(`Plugin "${plugin.name}" declares duplicate capabilities: ${duplicates.join(', ')}`);
|
|
72
|
+
}
|
|
73
|
+
return plugin;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface AdminConfigInput {
|
|
77
|
+
readonly name?: string;
|
|
78
|
+
/** Either a first-class ProviderBundle or several bundles composed in order. */
|
|
79
|
+
readonly providers: ProviderBundle | readonly ProviderBundle[];
|
|
80
|
+
/**
|
|
81
|
+
* Schema-first resources. A bare `ResourceContract` is expanded into a minimal
|
|
82
|
+
* `ResourceDefinition`; a full definition may embed `contract` for menu/label metadata.
|
|
83
|
+
*/
|
|
84
|
+
readonly resources?: readonly AdminResourceInput[];
|
|
85
|
+
readonly plugins?: readonly AdminPluginDescriptor[];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** A full resource definition is required to carry its runtime contract. */
|
|
89
|
+
export interface AdminResourceDefinition extends ResourceDefinition {
|
|
90
|
+
readonly contract: ResourceContract;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Schema-first resources are preferred, but plain definitions remain accepted so
|
|
95
|
+
* existing projects migrate without rewrites. Bare contracts are expanded.
|
|
96
|
+
*/
|
|
97
|
+
export type AdminResourceInput = ResourceContract | ResourceDefinition;
|
|
98
|
+
|
|
99
|
+
export interface AdminConfig extends AdminConfigInput {
|
|
100
|
+
readonly __svadminConfig: true;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Typed, identity-preserving application configuration. Returns the input
|
|
105
|
+
* object with the config brand so downstream resolution can assert the shape.
|
|
106
|
+
*/
|
|
107
|
+
export function defineAdminConfig<const TConfig extends AdminConfigInput>(
|
|
108
|
+
config: TConfig,
|
|
109
|
+
): TConfig & { readonly __svadminConfig: true } {
|
|
110
|
+
if (isProviderBundleList(config.providers) && config.providers.length === 0) {
|
|
111
|
+
throw new Error('defineAdminConfig requires at least one provider bundle');
|
|
112
|
+
}
|
|
113
|
+
return { ...config, __svadminConfig: true };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export type AdminConfigDiagnosticCode =
|
|
117
|
+
| 'duplicate-resource'
|
|
118
|
+
| 'duplicate-plugin'
|
|
119
|
+
| 'invalid-resource'
|
|
120
|
+
| 'invalid-plugin-contribution';
|
|
121
|
+
|
|
122
|
+
export interface AdminConfigDiagnostic {
|
|
123
|
+
readonly code: AdminConfigDiagnosticCode;
|
|
124
|
+
readonly message: string;
|
|
125
|
+
readonly source?: string;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface ResolvedAdminConfig {
|
|
129
|
+
readonly name: string | undefined;
|
|
130
|
+
/** Composed bundle; later bundles override earlier scalar providers. */
|
|
131
|
+
readonly providers: ProviderBundle;
|
|
132
|
+
/** Normalized definitions ready to pass to `provideAdminContext`. */
|
|
133
|
+
readonly resources: readonly ResourceDefinition[];
|
|
134
|
+
readonly plugins: readonly AdminPluginDescriptor[];
|
|
135
|
+
readonly diagnostics: readonly AdminConfigDiagnostic[];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The `provideAdminContext` source derived from a resolved config. */
|
|
139
|
+
export interface ResolvedAdminContextSource {
|
|
140
|
+
readonly providerBundle: ProviderBundle;
|
|
141
|
+
readonly resources: ResourceDefinition[];
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function isSingleDataProvider(value: DataProviderInput): value is DataProvider {
|
|
145
|
+
return typeof Reflect.get(value, 'getList') === 'function';
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function duplicateValues<T>(values: readonly T[]): T[] {
|
|
149
|
+
const seen = new Set<T>();
|
|
150
|
+
const duplicates = new Set<T>();
|
|
151
|
+
for (const value of values) {
|
|
152
|
+
if (seen.has(value)) duplicates.add(value);
|
|
153
|
+
else seen.add(value);
|
|
154
|
+
}
|
|
155
|
+
return [...duplicates];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function isProviderBundleList(
|
|
159
|
+
value: ProviderBundle | readonly ProviderBundle[],
|
|
160
|
+
): value is readonly ProviderBundle[] {
|
|
161
|
+
return Array.isArray(value);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function providerBundlesOf(config: AdminConfigInput): readonly ProviderBundle[] {
|
|
165
|
+
return isProviderBundleList(config.providers) ? config.providers : [config.providers];
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function mergeDataProvider(inputs: readonly DataProviderInput[]): DataProviderInput {
|
|
169
|
+
const named: Record<string, DataProvider> = {};
|
|
170
|
+
let fallback: DataProvider | undefined;
|
|
171
|
+
for (const input of inputs) {
|
|
172
|
+
if (isSingleDataProvider(input)) fallback = input;
|
|
173
|
+
else Object.assign(named, input);
|
|
174
|
+
}
|
|
175
|
+
if (Object.keys(named).length === 0) {
|
|
176
|
+
if (fallback === undefined) throw new Error('resolveAdminConfig requires a data provider');
|
|
177
|
+
return fallback;
|
|
178
|
+
}
|
|
179
|
+
if (fallback !== undefined && named['default'] === undefined) {
|
|
180
|
+
return { ...named, default: fallback };
|
|
181
|
+
}
|
|
182
|
+
return named;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function mergeProviderBundles(
|
|
186
|
+
bundles: readonly ProviderBundle[],
|
|
187
|
+
): ProviderBundle {
|
|
188
|
+
const dataProviderInputs: DataProviderInput[] = [];
|
|
189
|
+
let merged: ProviderBundle | undefined;
|
|
190
|
+
for (const bundle of bundles) {
|
|
191
|
+
dataProviderInputs.push(bundle.dataProvider);
|
|
192
|
+
merged = merged === undefined ? { ...bundle } : mergeOptionalProviders(merged, bundle);
|
|
193
|
+
}
|
|
194
|
+
if (merged === undefined) throw new Error('resolveAdminConfig requires a data provider');
|
|
195
|
+
const dataProvider = mergeDataProvider(dataProviderInputs);
|
|
196
|
+
return { ...merged, dataProvider };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function mergeOptionalProviders(base: ProviderBundle, override: ProviderBundle): ProviderBundle {
|
|
200
|
+
return {
|
|
201
|
+
dataProvider: override.dataProvider,
|
|
202
|
+
...(override.authProvider !== undefined ? { authProvider: override.authProvider } : base.authProvider !== undefined ? { authProvider: base.authProvider } : {}),
|
|
203
|
+
...(override.accessControlProvider !== undefined ? { accessControlProvider: override.accessControlProvider } : base.accessControlProvider !== undefined ? { accessControlProvider: base.accessControlProvider } : {}),
|
|
204
|
+
...(override.liveProvider !== undefined ? { liveProvider: override.liveProvider } : base.liveProvider !== undefined ? { liveProvider: base.liveProvider } : {}),
|
|
205
|
+
...(override.auditLogProvider !== undefined ? { auditLogProvider: override.auditLogProvider } : base.auditLogProvider !== undefined ? { auditLogProvider: base.auditLogProvider } : {}),
|
|
206
|
+
...(override.notificationProvider !== undefined ? { notificationProvider: override.notificationProvider } : base.notificationProvider !== undefined ? { notificationProvider: base.notificationProvider } : {}),
|
|
207
|
+
...(override.chatProvider !== undefined ? { chatProvider: override.chatProvider } : base.chatProvider !== undefined ? { chatProvider: base.chatProvider } : {}),
|
|
208
|
+
...(override.agentProvider !== undefined ? { agentProvider: override.agentProvider } : base.agentProvider !== undefined ? { agentProvider: base.agentProvider } : {}),
|
|
209
|
+
...(override.taskProvider !== undefined ? { taskProvider: override.taskProvider } : base.taskProvider !== undefined ? { taskProvider: base.taskProvider } : {}),
|
|
210
|
+
...(override.routerProvider !== undefined ? { routerProvider: override.routerProvider } : base.routerProvider !== undefined ? { routerProvider: base.routerProvider } : {}),
|
|
211
|
+
...(override.tenantAdapter !== undefined ? { tenantAdapter: override.tenantAdapter } : base.tenantAdapter !== undefined ? { tenantAdapter: base.tenantAdapter } : {}),
|
|
212
|
+
...(override.organizationProvider !== undefined ? { organizationProvider: override.organizationProvider } : base.organizationProvider !== undefined ? { organizationProvider: base.organizationProvider } : {}),
|
|
213
|
+
...(override.identityGovernanceProvider !== undefined ? { identityGovernanceProvider: override.identityGovernanceProvider } : base.identityGovernanceProvider !== undefined ? { identityGovernanceProvider: base.identityGovernanceProvider } : {}),
|
|
214
|
+
...(override.sessionProvider !== undefined ? { sessionProvider: override.sessionProvider } : base.sessionProvider !== undefined ? { sessionProvider: base.sessionProvider } : {}),
|
|
215
|
+
...(override.credentialProvider !== undefined ? { credentialProvider: override.credentialProvider } : base.credentialProvider !== undefined ? { credentialProvider: base.credentialProvider } : {}),
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function isResourceContract(value: AdminResourceInput): value is ResourceContract {
|
|
220
|
+
return !Array.isArray(Reflect.get(value, 'fields'));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Expands bare contracts into minimal definitions; keeps full definitions intact. */
|
|
224
|
+
export function normalizeAdminResource(value: AdminResourceInput): ResourceDefinition {
|
|
225
|
+
if (!isResourceContract(value)) return value;
|
|
226
|
+
return { name: value.name, label: value.name, fields: [], contract: value };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function collectPluginResources(
|
|
230
|
+
plugins: readonly AdminPluginDescriptor[],
|
|
231
|
+
diagnostics: AdminConfigDiagnostic[],
|
|
232
|
+
): AdminResourceInput[] {
|
|
233
|
+
const resources: AdminResourceInput[] = [];
|
|
234
|
+
for (const plugin of plugins) {
|
|
235
|
+
for (const resource of plugin.contributes?.resources ?? []) {
|
|
236
|
+
if (!resource.name.trim()) {
|
|
237
|
+
diagnostics.push({
|
|
238
|
+
code: 'invalid-plugin-contribution',
|
|
239
|
+
message: `Plugin "${plugin.name}" contributes a resource without a name`,
|
|
240
|
+
source: plugin.name,
|
|
241
|
+
});
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
resources.push(resource);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return resources;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function collectPluginProviders(
|
|
251
|
+
plugins: readonly AdminPluginDescriptor[],
|
|
252
|
+
diagnostics: AdminConfigDiagnostic[],
|
|
253
|
+
): ProviderBundle[] {
|
|
254
|
+
const bundles: ProviderBundle[] = [];
|
|
255
|
+
for (const plugin of plugins) {
|
|
256
|
+
const providers = plugin.contributes?.providers;
|
|
257
|
+
if (providers === undefined) continue;
|
|
258
|
+
if (isSingleDataProvider(providers.dataProvider) === false && Object.keys(providers.dataProvider).length === 0) {
|
|
259
|
+
diagnostics.push({
|
|
260
|
+
code: 'invalid-plugin-contribution',
|
|
261
|
+
message: `Plugin "${plugin.name}" contributes an empty provider bundle`,
|
|
262
|
+
source: plugin.name,
|
|
263
|
+
});
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
bundles.push(providers);
|
|
267
|
+
}
|
|
268
|
+
return bundles;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Folds plugin contributions into a deterministic application snapshot.
|
|
273
|
+
* Never throws on recoverable conflicts; inspect `diagnostics` instead.
|
|
274
|
+
*/
|
|
275
|
+
export function resolveAdminConfig(config: AdminConfig): ResolvedAdminConfig {
|
|
276
|
+
const diagnostics: AdminConfigDiagnostic[] = [];
|
|
277
|
+
const plugins = config.plugins ?? [];
|
|
278
|
+
|
|
279
|
+
const pluginNames = plugins.map((plugin) => plugin.name);
|
|
280
|
+
for (const duplicate of duplicateValues(pluginNames)) {
|
|
281
|
+
diagnostics.push({
|
|
282
|
+
code: 'duplicate-plugin',
|
|
283
|
+
message: `Plugin "${duplicate}" is registered more than once`,
|
|
284
|
+
source: duplicate,
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const pluginResources = collectPluginResources(plugins, diagnostics);
|
|
289
|
+
const resources = [...(config.resources ?? []), ...pluginResources].map(normalizeAdminResource);
|
|
290
|
+
const resourceNames = new Set<string>();
|
|
291
|
+
for (const resource of resources) {
|
|
292
|
+
if (!resource.name.trim()) {
|
|
293
|
+
diagnostics.push({ code: 'invalid-resource', message: 'A resource is defined without a name' });
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
if (resourceNames.has(resource.name)) {
|
|
297
|
+
diagnostics.push({
|
|
298
|
+
code: 'duplicate-resource',
|
|
299
|
+
message: `Resource "${resource.name}" is registered more than once`,
|
|
300
|
+
source: resource.name,
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
resourceNames.add(resource.name);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const providers = mergeProviderBundles(
|
|
307
|
+
[...providerBundlesOf(config), ...collectPluginProviders(plugins, diagnostics)],
|
|
308
|
+
);
|
|
309
|
+
|
|
310
|
+
return {
|
|
311
|
+
name: config.name,
|
|
312
|
+
providers,
|
|
313
|
+
resources,
|
|
314
|
+
plugins,
|
|
315
|
+
diagnostics,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Builds the `provideAdminContext` source from a resolved config. */
|
|
320
|
+
export function toAdminContextSource(config: ResolvedAdminConfig): ResolvedAdminContextSource {
|
|
321
|
+
return { providerBundle: config.providers, resources: [...config.resources] };
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Throws when resolution produced error diagnostics; useful at app startup and in `doctor`. */
|
|
325
|
+
export function assertAdminConfig(config: AdminConfig): ResolvedAdminConfig {
|
|
326
|
+
const resolved = resolveAdminConfig(config);
|
|
327
|
+
if (resolved.diagnostics.length > 0) {
|
|
328
|
+
throw new Error(
|
|
329
|
+
`Invalid admin config:\n${resolved.diagnostics.map((entry) => `- [${entry.code}] ${entry.message}`).join('\n')}`,
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
return resolved;
|
|
333
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AI manifest builder.
|
|
3
|
+
*
|
|
4
|
+
* Serializes a resolved application config into the stable, JSON-safe entrypoint
|
|
5
|
+
* that AI tooling and `svadmin doctor` read instead of scanning the repository.
|
|
6
|
+
* The output is intentionally small and deterministic: providers, resources,
|
|
7
|
+
* plugin capabilities, project commands, and generated-file guardrails.
|
|
8
|
+
*/
|
|
9
|
+
import { getContractFormFields, type ResourceContract } from './resource-contract';
|
|
10
|
+
import type { AdminPluginCapability, ResolvedAdminConfig } from './admin-config';
|
|
11
|
+
import type { DataProvider } from './types';
|
|
12
|
+
|
|
13
|
+
/** Project-level facts that are not part of the runtime config. */
|
|
14
|
+
export interface AdminManifestProject {
|
|
15
|
+
readonly name?: string;
|
|
16
|
+
readonly commands?: Readonly<Record<string, string>>;
|
|
17
|
+
/** Internal import paths AI-generated code must never reach into. */
|
|
18
|
+
readonly forbiddenImports?: readonly string[];
|
|
19
|
+
readonly testCommand?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface AdminManifestField {
|
|
23
|
+
readonly key: string;
|
|
24
|
+
readonly label: string;
|
|
25
|
+
readonly type: string;
|
|
26
|
+
readonly required: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface AdminManifestResource {
|
|
30
|
+
readonly name: string;
|
|
31
|
+
readonly label: string;
|
|
32
|
+
readonly fields: readonly AdminManifestField[];
|
|
33
|
+
/** Field names that must be present per operation, derived from the contract. */
|
|
34
|
+
readonly contract: {
|
|
35
|
+
readonly record: readonly string[];
|
|
36
|
+
readonly create: readonly string[];
|
|
37
|
+
readonly update: readonly string[];
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface AdminManifestPlugin {
|
|
42
|
+
readonly name: string;
|
|
43
|
+
readonly version: string;
|
|
44
|
+
readonly capabilities: readonly AdminPluginCapability[];
|
|
45
|
+
readonly configSchema?: unknown;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface AdminManifest {
|
|
49
|
+
readonly version: 1;
|
|
50
|
+
readonly project: {
|
|
51
|
+
readonly name: string | undefined;
|
|
52
|
+
readonly commands: Readonly<Record<string, string>>;
|
|
53
|
+
readonly testCommand: string | undefined;
|
|
54
|
+
readonly forbiddenImports: readonly string[];
|
|
55
|
+
};
|
|
56
|
+
readonly providers: {
|
|
57
|
+
readonly dataProviders: readonly string[];
|
|
58
|
+
readonly scalarProviders: readonly string[];
|
|
59
|
+
};
|
|
60
|
+
readonly resources: readonly AdminManifestResource[];
|
|
61
|
+
readonly plugins: readonly AdminManifestPlugin[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function isSingleDataProvider(value: unknown): value is DataProvider {
|
|
65
|
+
return typeof Reflect.get(value as object, 'getList') === 'function';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function dataProviderNames(config: ResolvedAdminConfig): string[] {
|
|
69
|
+
const dataProvider = config.providers.dataProvider;
|
|
70
|
+
if (isSingleDataProvider(dataProvider)) return ['default'];
|
|
71
|
+
return Object.keys(dataProvider);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function scalarProviderNames(config: ResolvedAdminConfig): string[] {
|
|
75
|
+
const names: string[] = [];
|
|
76
|
+
for (const key of Object.keys(config.providers)) {
|
|
77
|
+
if (key === 'dataProvider') continue;
|
|
78
|
+
if (Reflect.get(config.providers, key) !== undefined) names.push(key);
|
|
79
|
+
}
|
|
80
|
+
return names.sort();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function contractFields(contract: ResourceContract, action: 'show' | 'create' | 'edit'): string[] {
|
|
84
|
+
try {
|
|
85
|
+
return getContractFormFields(contract, action);
|
|
86
|
+
} catch {
|
|
87
|
+
return [];
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Builds the stable AI entrypoint from a resolved config. */
|
|
92
|
+
export function buildAdminManifest(
|
|
93
|
+
config: ResolvedAdminConfig,
|
|
94
|
+
project: AdminManifestProject = {},
|
|
95
|
+
): AdminManifest {
|
|
96
|
+
return {
|
|
97
|
+
version: 1,
|
|
98
|
+
project: {
|
|
99
|
+
name: project.name ?? config.name,
|
|
100
|
+
commands: { ...(project.commands ?? {}) },
|
|
101
|
+
testCommand: project.testCommand,
|
|
102
|
+
forbiddenImports: [...(project.forbiddenImports ?? [])],
|
|
103
|
+
},
|
|
104
|
+
providers: {
|
|
105
|
+
dataProviders: dataProviderNames(config),
|
|
106
|
+
scalarProviders: scalarProviderNames(config),
|
|
107
|
+
},
|
|
108
|
+
resources: config.resources.map((resource) => ({
|
|
109
|
+
name: resource.name,
|
|
110
|
+
label: resource.label,
|
|
111
|
+
fields: resource.fields.map((field) => ({
|
|
112
|
+
key: field.key,
|
|
113
|
+
label: field.label,
|
|
114
|
+
type: field.type,
|
|
115
|
+
required: field.required ?? false,
|
|
116
|
+
})),
|
|
117
|
+
contract: resource.contract === undefined
|
|
118
|
+
? { record: [], create: [], update: [] }
|
|
119
|
+
: {
|
|
120
|
+
record: contractFields(resource.contract, 'show'),
|
|
121
|
+
create: contractFields(resource.contract, 'create'),
|
|
122
|
+
update: contractFields(resource.contract, 'edit'),
|
|
123
|
+
},
|
|
124
|
+
})),
|
|
125
|
+
plugins: config.plugins.map((plugin) => ({
|
|
126
|
+
name: plugin.name,
|
|
127
|
+
version: plugin.version,
|
|
128
|
+
capabilities: plugin.capabilities,
|
|
129
|
+
...(plugin.configSchema !== undefined ? { configSchema: plugin.configSchema } : {}),
|
|
130
|
+
})),
|
|
131
|
+
};
|
|
132
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -67,7 +67,40 @@ export {
|
|
|
67
67
|
useCreate, useUpdate, useDelete, useCreateMany, useUpdateMany, useDeleteMany, useForm,
|
|
68
68
|
useInvalidate,
|
|
69
69
|
} from './strict-hooks.svelte';
|
|
70
|
-
export { defineResource, getContractFormFields } from './resource-contract';
|
|
70
|
+
export { defineResource, getContractFormFields, withContractProjection } from './resource-contract';
|
|
71
|
+
export {
|
|
72
|
+
defineAdminConfig,
|
|
73
|
+
defineSvadminPlugin,
|
|
74
|
+
resolveAdminConfig,
|
|
75
|
+
assertAdminConfig,
|
|
76
|
+
toAdminContextSource,
|
|
77
|
+
normalizeAdminResource,
|
|
78
|
+
} from './admin-config';
|
|
79
|
+
export type {
|
|
80
|
+
AdminConfig,
|
|
81
|
+
AdminConfigDiagnostic,
|
|
82
|
+
AdminConfigDiagnosticCode,
|
|
83
|
+
AdminConfigInput,
|
|
84
|
+
AdminPluginCapability,
|
|
85
|
+
AdminPluginContribution,
|
|
86
|
+
AdminPluginDescriptor,
|
|
87
|
+
AdminPluginDocs,
|
|
88
|
+
AdminResourceDefinition,
|
|
89
|
+
AdminResourceInput,
|
|
90
|
+
ResolvedAdminConfig,
|
|
91
|
+
ResolvedAdminContextSource,
|
|
92
|
+
} from './admin-config';
|
|
93
|
+
export { provideAdminConfig } from './admin-config-runtime.svelte';
|
|
94
|
+
export { provideResourceScope } from './resource-scope.svelte';
|
|
95
|
+
export type { ResourceScope } from './resource-scope.svelte';
|
|
96
|
+
export { buildAdminManifest } from './admin-manifest';
|
|
97
|
+
export type {
|
|
98
|
+
AdminManifest,
|
|
99
|
+
AdminManifestField,
|
|
100
|
+
AdminManifestPlugin,
|
|
101
|
+
AdminManifestProject,
|
|
102
|
+
AdminManifestResource,
|
|
103
|
+
} from './admin-manifest';
|
|
71
104
|
export { UpdateManyPartialError } from './update-many-contract';
|
|
72
105
|
export { CreateManyPartialError } from './create-many-contract';
|
|
73
106
|
export type { ContractCreateOptions as UseCreateOptions } from './create-hooks.svelte';
|
|
@@ -77,6 +110,7 @@ export type { ContractUpdateParams as UseUpdateMutateParams } from './update-con
|
|
|
77
110
|
export type { ContractDeleteOptions as UseDeleteOptions } from './delete-hooks.svelte';
|
|
78
111
|
export type { ContractDeleteParams as UseDeleteMutateParams } from './delete-contract';
|
|
79
112
|
export type { ContractFormAction, ContractFormDraft, ContractFormValues } from './resource-contract';
|
|
113
|
+
export type { ContractProjectionOptions } from './resource-contract';
|
|
80
114
|
export type { ResourceContract, ContractSchemas, ContractRecord, ContractId, ContractInput } from './resource-contract';
|
|
81
115
|
export type { ContractFilter, ContractSort } from './strict-hooks.svelte';
|
|
82
116
|
export { defineCommand } from './command-contract';
|
package/src/resource-contract.ts
CHANGED
|
@@ -2,8 +2,8 @@ import { CloneType, Kind, TransformKind, Type, type Static, type TObject, type T
|
|
|
2
2
|
import { checkExact, createExactSchemaValidator } from './schema-validation';
|
|
3
3
|
import { TypeGuard } from '@sinclair/typebox/type';
|
|
4
4
|
import { DeleteManyPartialError, HttpError, type DataProvider, type KnownResources, type BaseRecord,
|
|
5
|
-
type GetManyParams, type CreateManyParams, type UpdateManyParams, type DeleteManyParams,
|
|
6
|
-
type GetOneResult, type GetManyResult } from './types';
|
|
5
|
+
type GetListResult, type GetManyParams, type CreateManyParams, type UpdateManyParams, type DeleteManyParams,
|
|
6
|
+
type GetOneParams, type GetOneResult, type GetManyResult } from './types';
|
|
7
7
|
import { withResourceSchemas, createResourceRequestValidator, type ResourceSchemas } from './resource-schemas';
|
|
8
8
|
import { decodeBaseRecord } from './record-decoder';
|
|
9
9
|
import { snapshotPlainData } from './plain-data';
|
|
@@ -166,6 +166,115 @@ export function parseContractRecord(contract: ResourceContract, value: unknown):
|
|
|
166
166
|
throw new HttpError('Invalid provider response', 502, undefined, { code: 'INVALID_PROVIDER_RESPONSE' });
|
|
167
167
|
}
|
|
168
168
|
|
|
169
|
+
export interface ContractProjectionOptions {
|
|
170
|
+
/** Supplies the id when the backend record has no declared contract id. */
|
|
171
|
+
readonly getId?: (input: {
|
|
172
|
+
resource: string;
|
|
173
|
+
requestedId: string | number | undefined;
|
|
174
|
+
record: unknown;
|
|
175
|
+
index: number;
|
|
176
|
+
}) => string | number | undefined;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function projectBySchema(schema: TSchema, value: unknown): unknown {
|
|
180
|
+
if (TypeGuard.IsObject(schema)) {
|
|
181
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return value;
|
|
182
|
+
const source = value as Record<string, unknown>;
|
|
183
|
+
const projected: Record<string, unknown> = {};
|
|
184
|
+
for (const [key, property] of Object.entries(schema.properties)) {
|
|
185
|
+
if (Object.hasOwn(source, key)) projected[key] = projectBySchema(property, source[key]);
|
|
186
|
+
}
|
|
187
|
+
return projected;
|
|
188
|
+
}
|
|
189
|
+
if (TypeGuard.IsArray(schema)) {
|
|
190
|
+
return Array.isArray(value) ? value.map(item => projectBySchema(schema.items, item)) : value;
|
|
191
|
+
}
|
|
192
|
+
if (TypeGuard.IsTuple(schema)) {
|
|
193
|
+
return Array.isArray(value)
|
|
194
|
+
? value.map((item, index) => schema.items?.[index] ? projectBySchema(schema.items[index], item) : item)
|
|
195
|
+
: value;
|
|
196
|
+
}
|
|
197
|
+
if (TypeGuard.IsUnion(schema)) {
|
|
198
|
+
const branch = schema.anyOf.find(candidate => checkExact(candidate, value));
|
|
199
|
+
return branch ? projectBySchema(branch, value) : value;
|
|
200
|
+
}
|
|
201
|
+
return value;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function projectRecord(
|
|
205
|
+
contract: ResourceContract,
|
|
206
|
+
resource: string,
|
|
207
|
+
record: unknown,
|
|
208
|
+
index: number,
|
|
209
|
+
requestedId: string | number | undefined,
|
|
210
|
+
options: ContractProjectionOptions,
|
|
211
|
+
): unknown {
|
|
212
|
+
const projected = projectBySchema(definitionOf(contract).schemas.record, record);
|
|
213
|
+
if (typeof projected !== 'object' || projected === null || Array.isArray(projected)) return projected;
|
|
214
|
+
const result = projected as Record<string, unknown>;
|
|
215
|
+
if (!Object.hasOwn(result, 'id')) {
|
|
216
|
+
const id = options.getId?.({ resource, requestedId, record, index })
|
|
217
|
+
?? requestedId
|
|
218
|
+
?? (typeof record === 'object' && record !== null && !Array.isArray(record)
|
|
219
|
+
? ['id', '_id', 'uuid', 'key', 'name'].map(key => (record as Record<string, unknown>)[key])
|
|
220
|
+
.find(candidate => typeof candidate === 'string' || typeof candidate === 'number')
|
|
221
|
+
: undefined)
|
|
222
|
+
?? `${resource}:${index}`;
|
|
223
|
+
result['id'] = id;
|
|
224
|
+
}
|
|
225
|
+
return result;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Projects read responses into the exact closed shape required by resource contracts.
|
|
230
|
+
* Write responses remain untouched so mutation validation keeps its existing semantics.
|
|
231
|
+
*/
|
|
232
|
+
export function withContractProjection(
|
|
233
|
+
provider: DataProvider,
|
|
234
|
+
contracts: Readonly<Record<string, ResourceContract>>,
|
|
235
|
+
options: ContractProjectionOptions = {},
|
|
236
|
+
): Readonly<DataProvider> {
|
|
237
|
+
function contractFor(resource: string): ResourceContract | undefined {
|
|
238
|
+
return contracts[resource];
|
|
239
|
+
}
|
|
240
|
+
function projectList(resource: string, result: GetListResult): GetListResult {
|
|
241
|
+
const contract = contractFor(resource);
|
|
242
|
+
if (!contract) return result;
|
|
243
|
+
return {
|
|
244
|
+
...result,
|
|
245
|
+
data: result.data.map((record, index) =>
|
|
246
|
+
projectRecord(contract, resource, record, index, undefined, options) as BaseRecord),
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
function projectMany(resource: string, result: GetManyResult): GetManyResult {
|
|
250
|
+
const contract = contractFor(resource);
|
|
251
|
+
if (!contract) return result;
|
|
252
|
+
return {
|
|
253
|
+
...result,
|
|
254
|
+
data: result.data.map((record, index) =>
|
|
255
|
+
projectRecord(contract, resource, record, index, undefined, options) as BaseRecord),
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
function projectOne(params: GetOneParams, result: GetOneResult): GetOneResult {
|
|
259
|
+
const contract = contractFor(params.resource);
|
|
260
|
+
return contract
|
|
261
|
+
? { ...result, data: projectRecord(contract, params.resource, result.data, 0, params.id, options) as BaseRecord }
|
|
262
|
+
: result;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const adapted: DataProvider = {
|
|
266
|
+
...provider,
|
|
267
|
+
getList: async params => projectList(params.resource, await provider.getList(params)),
|
|
268
|
+
getOne: async params => projectOne(params, await provider.getOne(params)),
|
|
269
|
+
getApiUrl: () => provider.getApiUrl(),
|
|
270
|
+
};
|
|
271
|
+
if (provider.getMany) {
|
|
272
|
+
const getMany = provider.getMany.bind(provider);
|
|
273
|
+
adapted.getMany = async params => projectMany(params.resource, await getMany(params));
|
|
274
|
+
}
|
|
275
|
+
return Object.freeze(adapted);
|
|
276
|
+
}
|
|
277
|
+
|
|
169
278
|
function idSchema(schemas: ContractSchemas): TSchema {
|
|
170
279
|
const schema = schemas.record.properties['id'];
|
|
171
280
|
if (!schema) throw new HttpError('Missing contract ID schema', 400, undefined, { code: 'INVALID_RESOURCE_CONTRACT' });
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scoped provider/resource overrides.
|
|
3
|
+
*
|
|
4
|
+
* `provideAdminConfig` (or `AdminApp`) establishes the application scope. Calling
|
|
5
|
+
* `provideResourceScope` in any descendant subtree adds a narrower scope without
|
|
6
|
+
* touching global state, giving the four practical levels:
|
|
7
|
+
*
|
|
8
|
+
* Application (provideAdminConfig)
|
|
9
|
+
* -> Route (provideResourceScope in the route component)
|
|
10
|
+
* -> Resource (provideResourceScope with one resource)
|
|
11
|
+
* -> Component (provideResourceScope in a widget)
|
|
12
|
+
*
|
|
13
|
+
* Providers and the resource list inherit from the nearest parent unless
|
|
14
|
+
* explicitly overridden.
|
|
15
|
+
*/
|
|
16
|
+
import { getAdminContext, provideAdminContext, type AdminContextValue } from './context.svelte';
|
|
17
|
+
import type { ProviderBundle } from './provider-bundle';
|
|
18
|
+
import type { ResourceDefinition } from './types';
|
|
19
|
+
|
|
20
|
+
export interface ResourceScope {
|
|
21
|
+
/** Restrict the visible resource registry for this subtree. */
|
|
22
|
+
readonly resources?: readonly ResourceDefinition[];
|
|
23
|
+
/** Swap the provider bundle for this subtree (for example a different backend). */
|
|
24
|
+
readonly providerBundle?: ProviderBundle;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function provideResourceScope(scope: ResourceScope): AdminContextValue {
|
|
28
|
+
const parent = getAdminContext();
|
|
29
|
+
if (parent === undefined) {
|
|
30
|
+
throw new Error(
|
|
31
|
+
'provideResourceScope requires an existing AdminContext; call provideAdminConfig or render AdminApp first',
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
return provideAdminContext({
|
|
35
|
+
providerBundle: scope.providerBundle ?? parent.providerBundle,
|
|
36
|
+
resources: scope.resources !== undefined ? [...scope.resources] : parent.resources,
|
|
37
|
+
...(parent.tenant === undefined ? {} : { tenant: parent.tenant }),
|
|
38
|
+
});
|
|
39
|
+
}
|