sveltekit-admin 0.6.0 → 0.9.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 (69) hide show
  1. package/README.md +61 -4
  2. package/dist/index.d.ts +3 -1
  3. package/dist/index.js +1 -1
  4. package/dist/server/adapters/drizzle/dataAdapter.js +196 -42
  5. package/dist/server/adapters/drizzle/index.d.ts +10 -1
  6. package/dist/server/adapters/drizzle/index.js +4 -0
  7. package/dist/server/adapters/prisma/dataAdapter.js +71 -11
  8. package/dist/server/adapters/prisma/handler.d.ts +14 -0
  9. package/dist/server/adapters/prisma/handler.js +37 -0
  10. package/dist/server/adapters/retry.d.ts +27 -0
  11. package/dist/server/adapters/retry.js +53 -0
  12. package/dist/server/adapters/types.d.ts +42 -3
  13. package/dist/server/audit.d.ts +65 -0
  14. package/dist/server/audit.js +106 -0
  15. package/dist/server/csrf.d.ts +33 -0
  16. package/dist/server/csrf.js +55 -0
  17. package/dist/server/data.d.ts +18 -2
  18. package/dist/server/data.js +39 -8
  19. package/dist/server/errors.d.ts +47 -0
  20. package/dist/server/errors.js +90 -0
  21. package/dist/server/handler.d.ts +92 -26
  22. package/dist/server/handler.js +263 -560
  23. package/dist/server/introspection/parser.d.ts +15 -0
  24. package/dist/server/introspection/parser.js +17 -0
  25. package/dist/server/mutations.d.ts +13 -0
  26. package/dist/server/mutations.js +476 -0
  27. package/dist/server/plugin.d.ts +47 -0
  28. package/dist/server/plugin.js +1 -0
  29. package/dist/server/pluginAccess.d.ts +7 -0
  30. package/dist/server/pluginAccess.js +79 -0
  31. package/dist/server/pluginRegistry.d.ts +12 -0
  32. package/dist/server/pluginRegistry.js +72 -0
  33. package/dist/server/query/listColumns.d.ts +19 -0
  34. package/dist/server/query/listColumns.js +43 -0
  35. package/dist/server/query/listQuery.d.ts +1 -1
  36. package/dist/server/query/pageSize.d.ts +19 -0
  37. package/dist/server/query/pageSize.js +26 -0
  38. package/dist/server/query/sortQuery.d.ts +31 -0
  39. package/dist/server/query/sortQuery.js +33 -0
  40. package/dist/server/query/urls.js +9 -1
  41. package/dist/server/relationLoaders.d.ts +42 -0
  42. package/dist/server/relationLoaders.js +188 -0
  43. package/dist/server/router.d.ts +10 -0
  44. package/dist/server/router.js +42 -19
  45. package/dist/server/runtime.d.ts +51 -0
  46. package/dist/server/runtime.js +263 -0
  47. package/dist/server/search.d.ts +14 -0
  48. package/dist/server/search.js +78 -0
  49. package/dist/server/submitted.d.ts +20 -0
  50. package/dist/server/submitted.js +54 -0
  51. package/dist/server/views/FieldInput.svelte +114 -8
  52. package/dist/server/views/FieldInput.svelte.d.ts +7 -0
  53. package/dist/server/views/Form.svelte +96 -5
  54. package/dist/server/views/Form.svelte.d.ts +19 -1
  55. package/dist/server/views/Layout.svelte +32 -4
  56. package/dist/server/views/Layout.svelte.d.ts +2 -0
  57. package/dist/server/views/List.svelte +156 -26
  58. package/dist/server/views/List.svelte.d.ts +7 -2
  59. package/dist/server/views/RelationCheckboxes.svelte +26 -5
  60. package/dist/server/views/RelationCheckboxes.svelte.d.ts +9 -0
  61. package/dist/server/views/RelationSelect.svelte +14 -3
  62. package/dist/server/views/RelationSelect.svelte.d.ts +2 -0
  63. package/dist/server/views/html.d.ts +19 -0
  64. package/dist/server/views/html.js +25 -0
  65. package/dist/server/views/pagination.d.ts +9 -0
  66. package/dist/server/views/pagination.js +31 -0
  67. package/dist/server/views/theme.js +147 -3
  68. package/dist/server/views/types.d.ts +15 -0
  69. package/package.json +24 -21
@@ -1,27 +1,50 @@
1
- export function parseRoute(pathname, basePath) {
1
+ export const BUILTIN_ROUTES = [
2
+ { pattern: [], view: 'dashboard' },
3
+ { pattern: ['_search'], view: 'search' },
4
+ { pattern: ['_logout'], view: 'logout' },
5
+ { pattern: [':model', 'new'], view: 'create' },
6
+ { pattern: [':model', ':id'], view: 'edit' },
7
+ { pattern: [':model'], view: 'list' }
8
+ ];
9
+ function relativeSegments(pathname, basePath) {
2
10
  // Le `replace` n'est PAS redondant avec le `filter(Boolean)` plus bas : il est ce
3
11
  // qui fait que `/admin/` et `/admin///` donnent un `path` vide, donc le dashboard.
4
12
  // Sans lui, `path` vaudrait '/' — truthy — et le chemin tomberait sur `notFound`.
5
13
  const path = pathname.slice(basePath.length).replace(/^\/+|\/+$/g, '');
6
- if (!path) {
7
- return { view: 'dashboard' };
8
- }
9
- const segments = path.split('/').filter(Boolean);
10
- if (segments.length === 1) {
11
- if (segments[0] === '_search') {
12
- return { view: 'search' };
13
- }
14
- if (segments[0] === '_logout') {
15
- return { view: 'logout' };
16
- }
17
- return { view: 'list', model: segments[0] };
18
- }
19
- if (segments.length === 2) {
20
- if (segments[1] === 'new') {
21
- return { view: 'create', model: segments[0] };
14
+ if (!path)
15
+ return [];
16
+ return path.split('/').filter(Boolean);
17
+ }
18
+ export function matchRoute(pathname, basePath, routes) {
19
+ const segments = relativeSegments(pathname, basePath);
20
+ for (const route of routes) {
21
+ if (route.pattern.length !== segments.length)
22
+ continue;
23
+ const captured = {};
24
+ let ok = true;
25
+ for (let i = 0; i < route.pattern.length; i++) {
26
+ const token = route.pattern[i];
27
+ const seg = segments[i];
28
+ if (token === ':model') {
29
+ captured.model = seg;
30
+ }
31
+ else if (token === ':id') {
32
+ captured.id = seg;
33
+ }
34
+ else if (token !== seg) {
35
+ ok = false;
36
+ break;
37
+ }
22
38
  }
23
- return { view: 'edit', model: segments[0], id: segments[1] };
39
+ if (ok)
40
+ return { view: route.view, ...captured };
24
41
  }
25
- // 3 segments ou plus : aucune vue ne correspond.
26
42
  return { view: 'notFound' };
27
43
  }
44
+ // Builtins-only helper — NOT the handler's dispatch path. `handler.ts` calls
45
+ // `matchRoute` directly with `[...pluginRoutes, ...BUILTIN_ROUTES]` so plugin
46
+ // patterns are considered first; this function stays around for tests and
47
+ // any caller that only cares about the builtin route table.
48
+ export function parseRoute(pathname, basePath) {
49
+ return matchRoute(pathname, basePath, BUILTIN_ROUTES);
50
+ }
@@ -0,0 +1,51 @@
1
+ import type { Schema, Model } from './types/schema.js';
2
+ import type { ActiveSort } from './query/sortQuery.js';
3
+ import { type RelationGraph } from './introspection/relations.js';
4
+ import type { ViewModel } from './views/types.js';
5
+ import type { DataAdapter, SchemaIntrospector, Filter } from './adapters/types.js';
6
+ import type { AdminHandlerConfig } from './handler.js';
7
+ export declare function scopeFrom(relConfig: {
8
+ where?: (ctx: any) => any;
9
+ } | undefined, ctx: {
10
+ locals?: any;
11
+ }): any;
12
+ export declare function listScopeFrom(runtime: AdminRuntime, model: Model, ctx: {
13
+ locals?: any;
14
+ }): Record<string, unknown> | undefined;
15
+ export declare function modelScopeFrom(runtime: AdminRuntime, model: Model, ctx: {
16
+ locals?: any;
17
+ }): Filter | undefined;
18
+ /** Extract equality predicates so create can force tenant-owned columns. */
19
+ export declare function modelScopeValues(runtime: AdminRuntime, model: Model, ctx: {
20
+ locals?: any;
21
+ }): Record<string, unknown>;
22
+ export interface AdminRuntime {
23
+ adapter: {
24
+ introspector: SchemaIntrospector;
25
+ data: DataAdapter;
26
+ };
27
+ schema: Schema | null;
28
+ relationGraph: RelationGraph | null;
29
+ models: Model[];
30
+ modelList: Array<{
31
+ name: string;
32
+ label: string;
33
+ }>;
34
+ config: AdminHandlerConfig;
35
+ basePath: string;
36
+ perPage: number;
37
+ /** Tailles sélectionnables, vide quand le mécanisme est désactivé. */
38
+ pageSizes: number[];
39
+ /** `models[].defaultSort` validé au démarrage, par nom de modèle. */
40
+ defaultSortOf(model: Model): ActiveSort | undefined;
41
+ selectThreshold: number;
42
+ filterLinkThreshold: number;
43
+ labelFieldCandidates: string[];
44
+ findModel(name?: string): Model | undefined;
45
+ labelOf(model: Model): string;
46
+ hiddenFieldsOf(model: Model): Set<string>;
47
+ viewModel(model: Model): ViewModel;
48
+ resolveLabel(target: Model, row: Record<string, unknown>, labelTemplate?: string): string;
49
+ resolveFilterableFields(model: Model): Set<string>;
50
+ }
51
+ export declare function createAdminRuntime(config: AdminHandlerConfig): AdminRuntime;
@@ -0,0 +1,263 @@
1
+ import { isSensitiveFieldName } from './introspection/parser.js';
2
+ import { resolveListColumns } from './query/listColumns.js';
3
+ import { resolvePageSizes } from './query/pageSize.js';
4
+ import { buildRelationGraph } from './introspection/relations.js';
5
+ import { primaryKeyOf } from './data.js';
6
+ import { validateListFilterConfig } from './query/filterDetection.js';
7
+ import { isCompositeFilter, isLeafFilter, normalizeScope } from './adapters/filter.js';
8
+ import { toLabel } from './views/html.js';
9
+ import { AdminConfigError } from './errors.js';
10
+ export function scopeFrom(relConfig, ctx) {
11
+ return relConfig?.where ? normalizeScope(relConfig.where(ctx)) : undefined;
12
+ }
13
+ export function listScopeFrom(runtime, model, ctx) {
14
+ const listScope = runtime.config.models?.[model.name]?.listWhere?.(ctx);
15
+ // A scope function returning `{}` (falsy-looking but truthy as
16
+ // an object) would otherwise silently fail OPEN — `{}` composed
17
+ // into an AND matches every row, exactly the opposite of what a
18
+ // caller configuring listWhere expects (real gap found in
19
+ // review: `locals.userId` undefined after a session expires is
20
+ // a realistic way to hit this). Fail loud instead: a scope
21
+ // function is either omitted entirely, or must return at least
22
+ // one condition every time it runs.
23
+ if (listScope && Object.keys(listScope).length === 0) {
24
+ throw new AdminConfigError(`[sveltekit-admin] models.${model.name}.listWhere returned an empty object ({}), ` +
25
+ `which would silently disable list scoping (fail-open). Return undefined/omit the ` +
26
+ `scope entirely if there is genuinely nothing to scope by for this request, or a ` +
27
+ `condition that actually restricts rows otherwise.`);
28
+ }
29
+ return listScope;
30
+ }
31
+ export function modelScopeFrom(runtime, model, ctx) {
32
+ const scope = runtime.config.models?.[model.name]?.scope;
33
+ if (!scope)
34
+ return undefined;
35
+ const raw = scope(ctx);
36
+ if (!raw || (typeof raw === 'object' && !Array.isArray(raw) && Object.keys(raw).length === 0)) {
37
+ throw new AdminConfigError(`[sveltekit-admin] models.${model.name}.scope must return a non-empty condition; ` +
38
+ 'refusing to fail open.');
39
+ }
40
+ const normalized = normalizeScope(raw);
41
+ const valid = (node) => {
42
+ if (isLeafFilter(node))
43
+ return node.value !== undefined;
44
+ return isCompositeFilter(node) && node.clauses.length > 0 && node.clauses.every(valid);
45
+ };
46
+ if (!valid(normalized)) {
47
+ throw new AdminConfigError(`[sveltekit-admin] models.${model.name}.scope returned an invalid condition; refusing to fail open.`);
48
+ }
49
+ return normalized;
50
+ }
51
+ /** Extract equality predicates so create can force tenant-owned columns. */
52
+ export function modelScopeValues(runtime, model, ctx) {
53
+ const normalized = modelScopeFrom(runtime, model, ctx);
54
+ if (!normalized)
55
+ return {};
56
+ const values = {};
57
+ const visit = (node) => {
58
+ if (isLeafFilter(node)) {
59
+ if (node.op !== 'eq')
60
+ return false;
61
+ if (node.field in values && values[node.field] !== node.value)
62
+ return false;
63
+ values[node.field] = node.value;
64
+ return true;
65
+ }
66
+ if (!isCompositeFilter(node) || node.op === 'or')
67
+ return false;
68
+ return node.clauses.every(visit);
69
+ };
70
+ if (!visit(normalized) || Object.keys(values).length === 0) {
71
+ throw new AdminConfigError(`[sveltekit-admin] models.${model.name}.scope must contain only equality conditions for creation`);
72
+ }
73
+ return values;
74
+ }
75
+ export function createAdminRuntime(config) {
76
+ const { basePath = '/admin', exclude = [], hidePivotTables = true, models: modelsConfig = {} } = config;
77
+ const adapter = config.adapter;
78
+ const introspector = adapter.introspector;
79
+ // Introspect the schema once at startup — same failure handling as before:
80
+ // a broken/missing schema source degrades to "no models known" rather than
81
+ // throwing out of `createAdminHandler` itself.
82
+ let schema = null;
83
+ let relationGraph = null;
84
+ try {
85
+ const introspected = introspector.introspect();
86
+ if (introspected instanceof Promise) {
87
+ throw new Error('[sveltekit-admin] SchemaIntrospector.introspect() returned a Promise — ' +
88
+ 'createAdminHandler only supports synchronous introspection today.');
89
+ }
90
+ schema = introspected;
91
+ relationGraph = buildRelationGraph(schema);
92
+ for (const d of relationGraph.diagnostics) {
93
+ console.warn(`[sveltekit-admin] ${d}`);
94
+ }
95
+ }
96
+ catch (e) {
97
+ console.warn('[sveltekit-admin] Could not introspect schema:', e);
98
+ }
99
+ /**
100
+ * Résolu une fois ici plutôt que dans `viewModel` : un `schema?.enums ?? …`
101
+ * par appel serait une branche que rien ne peut exercer (un schéma nul donne
102
+ * `models` vide, donc aucune vue à construire), alors qu'à ce niveau les deux
103
+ * cas sont ceux du démarrage — schéma lu, ou introspection en échec.
104
+ */
105
+ const schemaEnums = schema?.enums ?? new Map();
106
+ const models = schema?.models.filter((m) => {
107
+ // Exclude explicitly excluded models
108
+ if (exclude.includes(m.name))
109
+ return false;
110
+ // Exclude pivot tables if option is enabled
111
+ if (hidePivotTables && m.isPivotTable)
112
+ return false;
113
+ return true;
114
+ }) || [];
115
+ // Valider `listFilter` au démarrage : une config invalide (champ
116
+ // inexistant, sensible, relation, type non supporté) doit échouer fort
117
+ // ici plutôt que produire silencieusement un filtre mort à chaque rendu
118
+ // de liste (docs/design §8, même politique que le groupe ambigu de
119
+ // relations.ts).
120
+ const hiddenFieldsOf = (m) => new Set(modelsConfig[m.name]?.hidden ?? []);
121
+ for (const m of models) {
122
+ const entries = modelsConfig[m.name]?.listFilter;
123
+ // Non-null par construction : `models` n'existe que si le schéma
124
+ // a été parsé, et le graphe est construit dans la même branche de boot.
125
+ if (entries)
126
+ validateListFilterConfig(m.name, entries, m, relationGraph, hiddenFieldsOf(m));
127
+ }
128
+ /**
129
+ * `defaultSort` validé ici pour la même raison que `listFilter` : une colonne
130
+ * inexistante, ou que la liste n'affiche pas, produirait un tri mort à chaque
131
+ * rendu sans qu'aucun en-tête ne l'annonce — et l'utilisateur n'aurait aucun
132
+ * moyen d'en sortir, puisque seule une colonne affichée porte un lien. La
133
+ * liste des colonnes autorisées est la même que celle du tri par URL.
134
+ */
135
+ const defaultSortOf = (m) => {
136
+ const configured = modelsConfig[m.name]?.defaultSort;
137
+ if (!configured)
138
+ return undefined;
139
+ const sortable = resolveListColumns(m.fields, {
140
+ hidden: modelsConfig[m.name]?.hidden,
141
+ listFields: modelsConfig[m.name]?.listFields
142
+ }).map((f) => f.name);
143
+ if (!sortable.includes(configured.field)) {
144
+ throw new AdminConfigError(`[sveltekit-admin] models.${m.name}.defaultSort targets "${configured.field}", ` +
145
+ `which the list view does not display. Displayed columns: [${sortable.join(', ')}].`);
146
+ }
147
+ if (configured.dir !== undefined && configured.dir !== 'asc' && configured.dir !== 'desc') {
148
+ throw new AdminConfigError(`[sveltekit-admin] models.${m.name}.defaultSort.dir must be "asc" or "desc".`);
149
+ }
150
+ return { field: configured.field, dir: configured.dir ?? 'asc' };
151
+ };
152
+ const defaultSorts = new Map(models.map((m) => [m.name, defaultSortOf(m)]));
153
+ const labelOf = (m) => {
154
+ const configured = modelsConfig[m.name]?.label;
155
+ if (configured)
156
+ return configured;
157
+ const label = toLabel(m.name);
158
+ return label.charAt(0).toUpperCase() + label.slice(1);
159
+ };
160
+ const modelList = models.map((m) => ({ name: m.name, label: labelOf(m) }));
161
+ const findModel = (name) => models.find((m) => m.name.toLowerCase() === name?.toLowerCase());
162
+ const viewModel = (m) => ({
163
+ name: m.name,
164
+ label: labelOf(m),
165
+ fields: m.fields,
166
+ primaryKey: primaryKeyOf(m),
167
+ enums: schemaEnums,
168
+ // Non-null par construction : `m` vient toujours de `models`,
169
+ // dérivé du schéma qu'on vient de parser avec succès.
170
+ relationGraph: relationGraph
171
+ });
172
+ /**
173
+ * Plafond dur. Au-delà ce n'est plus une page mais un export, et sur une
174
+ * table volumineuse une requête qui tient la connexion. Vaut pour la valeur
175
+ * configurée comme pour chaque option proposée.
176
+ */
177
+ const MAX_PAGE_SIZE = 200;
178
+ const validPageSize = (n) => typeof n === 'number' && Number.isSafeInteger(n) && n >= 1 && n <= MAX_PAGE_SIZE;
179
+ if (config.perPage !== undefined && !validPageSize(config.perPage)) {
180
+ throw new AdminConfigError(`[sveltekit-admin] perPage must be an integer between 1 and ${MAX_PAGE_SIZE}.`);
181
+ }
182
+ const perPage = config.perPage ?? 20;
183
+ const configuredSizes = config.pageSizeOptions ?? [10, 20, 50, 100];
184
+ if (!Array.isArray(configuredSizes) || !configuredSizes.every(validPageSize)) {
185
+ throw new AdminConfigError(`[sveltekit-admin] pageSizeOptions must be integers between 1 and ${MAX_PAGE_SIZE}.`);
186
+ }
187
+ const pageSizes = resolvePageSizes(perPage, configuredSizes);
188
+ const selectThreshold = config.relationDefaults?.selectThreshold ?? 200;
189
+ const filterLinkThreshold = config.listFilterDefaults?.linkThreshold ?? 20;
190
+ const labelFieldCandidates = config.relationDefaults?.labelFields ?? [
191
+ 'name', 'title', 'label', 'email', 'username', 'slug'
192
+ ];
193
+ /**
194
+ * Champs qu'un `?f.<field>=` est autorisé à cibler pour ce modèle : tout
195
+ * champ scalaire non-liste, non-relation, de type filtrable
196
+ * (String/Int/Float/Decimal/BigInt/Boolean/DateTime/enum — donc pas
197
+ * Json/Bytes), non sensible, et non listé dans `hidden` pour ce modèle.
198
+ * Sans ce dernier point, `hidden: ['internalNotes']` ne fait que masquer
199
+ * l'affichage : le champ reste un oracle de confirmation de valeur via
200
+ * `?f.internalNotes=...contains...`, exactement la faille §0.a fermée
201
+ * ailleurs pour les champs sensibles par nom — `hidden` et le prédicat
202
+ * de sensibilité sont deux sources distinctes, toutes deux doivent
203
+ * fermer l'oracle (docs/design §10, "deux sources, un seul prédicat
204
+ * partagé, sinon divergence garantie"). Défense en profondeur :
205
+ * `listQuery.ts` revérifie lui-même la sensibilité par nom, ce set est
206
+ * la première passe et la seule à connaître la config `hidden`.
207
+ */
208
+ const resolveFilterableFields = (model) => {
209
+ const hidden = hiddenFieldsOf(model);
210
+ const out = new Set();
211
+ for (const f of model.fields) {
212
+ if (f.relation || f.isList)
213
+ continue;
214
+ if (['Json', 'Bytes'].includes(f.type))
215
+ continue;
216
+ if (isSensitiveFieldName(f.name))
217
+ continue;
218
+ if (hidden.has(f.name))
219
+ continue;
220
+ out.add(f.name);
221
+ }
222
+ return out;
223
+ };
224
+ /**
225
+ * Résout le label BRUT (non échappé) d'une ligne : premier champ String
226
+ * candidat présent, sinon template `{a} {b}` si configuré, sinon la PK.
227
+ * Déterministe. Svelte échappe automatiquement à l'interpolation dans les
228
+ * composants — pas besoin d'échapper ici.
229
+ */
230
+ const resolveLabel = (targetModel, row, labelTemplate) => {
231
+ if (labelTemplate) {
232
+ return labelTemplate.replace(/\{(\w+)\}/g, (_, k) => String(row[k] ?? ''));
233
+ }
234
+ for (const candidate of labelFieldCandidates) {
235
+ const field = targetModel.fields.find((f) => f.name === candidate);
236
+ if (field && field.type === 'String' && row[candidate] != null) {
237
+ return String(row[candidate]);
238
+ }
239
+ }
240
+ return String(row[primaryKeyOf(targetModel)]);
241
+ };
242
+ return {
243
+ adapter,
244
+ schema,
245
+ relationGraph,
246
+ models,
247
+ modelList,
248
+ config,
249
+ basePath,
250
+ perPage,
251
+ pageSizes,
252
+ defaultSortOf: (m) => defaultSorts.get(m.name),
253
+ selectThreshold,
254
+ filterLinkThreshold,
255
+ labelFieldCandidates,
256
+ findModel,
257
+ labelOf,
258
+ hiddenFieldsOf,
259
+ viewModel,
260
+ resolveLabel,
261
+ resolveFilterableFields
262
+ };
263
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * `_search` JSON endpoint — split out of `handler.ts`, pure orchestration
3
+ * over `AdminRuntime`.
4
+ */
5
+ import { type AdminRuntime } from './runtime.js';
6
+ /**
7
+ * Endpoint de recherche `GET {basePath}/_search?rel=Model.field&q=...&page=N`.
8
+ * Sert les options d'une relation to-one-owning ou m2m en JSON
9
+ * paginé — la voie prévue pour un futur widget autocomplete côté client
10
+ * quand le nombre d'options dépasse `selectThreshold`. Respecte le `where`
11
+ * de scoping configuré sur la relation, comme le select et la validation
12
+ * POST : même garantie anti-IDOR sur les trois chemins.
13
+ */
14
+ export declare function handleSearch(runtime: AdminRuntime, event: any): Promise<Response>;
@@ -0,0 +1,78 @@
1
+ /**
2
+ * `_search` JSON endpoint — split out of `handler.ts`, pure orchestration
3
+ * over `AdminRuntime`.
4
+ */
5
+ import { primaryKeyOf, paginate } from './data.js';
6
+ import { scopeFrom, modelScopeFrom } from './runtime.js';
7
+ /**
8
+ * Endpoint de recherche `GET {basePath}/_search?rel=Model.field&q=...&page=N`.
9
+ * Sert les options d'une relation to-one-owning ou m2m en JSON
10
+ * paginé — la voie prévue pour un futur widget autocomplete côté client
11
+ * quand le nombre d'options dépasse `selectThreshold`. Respecte le `where`
12
+ * de scoping configuré sur la relation, comme le select et la validation
13
+ * POST : même garantie anti-IDOR sur les trois chemins.
14
+ */
15
+ export async function handleSearch(runtime, event) {
16
+ const modelsConfig = runtime.config.models ?? {};
17
+ const relParam = event.url.searchParams.get('rel') ?? '';
18
+ const [modelName, fieldName] = relParam.split('.');
19
+ const q = event.url.searchParams.get('q') ?? '';
20
+ const { page } = paginate(event.url.searchParams.get('page'), runtime.perPage);
21
+ const model = runtime.findModel(modelName);
22
+ const edge = model && runtime.relationGraph
23
+ ? runtime.relationGraph.edges.get(`${model.name}.${fieldName}`)
24
+ : undefined;
25
+ if (!model || !edge || (edge.kind !== 'to-one-owning' && edge.kind !== 'm2m') || edge.unsupported) {
26
+ return new Response(JSON.stringify({ error: 'unknown relation' }), {
27
+ status: 404,
28
+ headers: { 'Content-Type': 'application/json' }
29
+ });
30
+ }
31
+ const targetModel = runtime.schema.models.find((m) => m.name === edge.target);
32
+ const relConfig = modelsConfig[model.name]?.relations?.[edge.field];
33
+ const configWhere = scopeFrom(relConfig, { locals: event.locals });
34
+ const modelWhere = modelScopeFrom(runtime, targetModel, { locals: event.locals });
35
+ // Recherche sur le premier champ String candidat du modèle cible — le
36
+ // même champ que celui utilisé pour construire le label par défaut.
37
+ const searchField = runtime.labelFieldCandidates.find((c) => targetModel.fields.some((f) => f.name === c && f.type === 'String'));
38
+ // `_search` must stay case-sensitive on every adapter/provider. A
39
+ // `{ op: 'contains' }` leaf would pick up the adapter-wide
40
+ // `caseInsensitiveSearch` flag (Prisma `mode: 'insensitive'`, Drizzle
41
+ // `ilike`). `containsExact` compiles to `{ contains }` / `LIKE` with
42
+ // no case-folding — same observable Prisma behavior as the previous
43
+ // opaque `{ [field]: { contains: q } }` pass-through.
44
+ const containsFilter = q && searchField
45
+ ? { op: 'containsExact', field: searchField, value: q }
46
+ : undefined;
47
+ const searchClauses = [modelWhere, configWhere, containsFilter].filter(Boolean);
48
+ const searchFilter = searchClauses.length > 1
49
+ ? { op: 'and', clauses: searchClauses }
50
+ : searchClauses[0];
51
+ try {
52
+ // Count + fetch are independent reads — run them in parallel (as this
53
+ // endpoint always has, pre-refactor) rather than doubling latency with
54
+ // two sequential awaits.
55
+ const [total, rows] = await Promise.all([
56
+ runtime.adapter.data.countRecords(targetModel, searchFilter),
57
+ runtime.adapter.data.findMany(targetModel, {
58
+ filter: searchFilter,
59
+ orderBy: relConfig?.orderBy,
60
+ skip: (page - 1) * runtime.perPage,
61
+ take: runtime.perPage
62
+ })
63
+ ]);
64
+ const options = rows.map((row) => ({
65
+ id: row[primaryKeyOf(targetModel)],
66
+ label: runtime.resolveLabel(targetModel, row, relConfig?.labelTemplate)
67
+ }));
68
+ return new Response(JSON.stringify({ options, total, page }), {
69
+ headers: { 'Content-Type': 'application/json' }
70
+ });
71
+ }
72
+ catch {
73
+ return new Response(JSON.stringify({ error: 'search failed' }), {
74
+ status: 500,
75
+ headers: { 'Content-Type': 'application/json' }
76
+ });
77
+ }
78
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Extraction des valeurs réellement soumises par un POST de formulaire admin,
3
+ * pour les re-rendre après un échec de mutation.
4
+ *
5
+ * Ce module ne rend rien : il traduit un `FormData` en la forme minimale dont
6
+ * les vues ont besoin. Il porte en revanche la décision de sécurité de ce
7
+ * chemin — ce qui NE doit pas repartir dans le HTML.
8
+ */
9
+ export interface SubmittedForm {
10
+ /** Scalaires et scalaires de relation, par nom de champ. */
11
+ values: Record<string, string>;
12
+ /**
13
+ * IDs cochés par arête m2m. Une entrée présente avec un tableau vide dit
14
+ * « l'utilisateur a tout décoché » ; une arête absente dit « le widget
15
+ * n'était pas dans le formulaire ». Même distinction, et même raison, que
16
+ * le sentinelle côté écriture.
17
+ */
18
+ m2m: Record<string, string[]>;
19
+ }
20
+ export declare function readSubmittedForm(formData: FormData, hidden: ReadonlySet<string>): SubmittedForm;
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Extraction des valeurs réellement soumises par un POST de formulaire admin,
3
+ * pour les re-rendre après un échec de mutation.
4
+ *
5
+ * Ce module ne rend rien : il traduit un `FormData` en la forme minimale dont
6
+ * les vues ont besoin. Il porte en revanche la décision de sécurité de ce
7
+ * chemin — ce qui NE doit pas repartir dans le HTML.
8
+ */
9
+ import { isSensitiveFieldName } from './introspection/parser.js';
10
+ /** Clé de dispatch de `handleMutation`, jamais un champ du modèle. */
11
+ const ACTION_KEY = '_action';
12
+ /** Préfixes posés par `RelationCheckboxes.svelte`, cf. `mutations.ts`. */
13
+ const M2M_VALUE_PREFIX = '__rel__';
14
+ const M2M_SENTINEL_PREFIX = '__rel_present__';
15
+ /**
16
+ * Un champ jamais rendu par le formulaire (`hidden`) ne doit pas non plus
17
+ * reparaître par ce chemin, et une valeur sensible ne repart pas du tout :
18
+ * `isSensitiveFieldName` est le prédicat partagé du dépôt (affichage en liste,
19
+ * whitelist de recherche/filtres, rédaction d'audit), et un second heuristique
20
+ * local finirait par diverger du premier. Conséquence assumée, celle de Django
21
+ * (`PasswordInput(render_value=False)`) : un mot de passe est à retaper après
22
+ * une erreur.
23
+ */
24
+ function isEchoable(name, hidden) {
25
+ return !hidden.has(name) && !isSensitiveFieldName(name);
26
+ }
27
+ export function readSubmittedForm(formData, hidden) {
28
+ const values = {};
29
+ const m2m = {};
30
+ // Deux passes : les valeurs `__rel__` peuvent précéder leur sentinelle dans
31
+ // le corps (l'ordre est celui du DOM, pas un contrat), donc on ne peut pas
32
+ // décider de les garder au fil de la première itération.
33
+ for (const key of formData.keys()) {
34
+ if (!key.startsWith(M2M_SENTINEL_PREFIX))
35
+ continue;
36
+ const field = key.slice(M2M_SENTINEL_PREFIX.length);
37
+ if (!isEchoable(field, hidden))
38
+ continue;
39
+ m2m[field] = formData.getAll(`${M2M_VALUE_PREFIX}${field}`).map(String);
40
+ }
41
+ for (const [key, raw] of formData.entries()) {
42
+ if (key === ACTION_KEY)
43
+ continue;
44
+ // Les deux préfixes, testés séparément : `__rel_present__` ne commence pas
45
+ // par `__rel__` (5e caractère `p` contre `_`), donc un seul test laisserait
46
+ // le sentinelle passer pour un scalaire.
47
+ if (key.startsWith(M2M_VALUE_PREFIX) || key.startsWith(M2M_SENTINEL_PREFIX))
48
+ continue;
49
+ if (!isEchoable(key, hidden))
50
+ continue;
51
+ values[key] = String(raw);
52
+ }
53
+ return { values, m2m };
54
+ }