sveltekit-admin 0.8.1 → 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 (40) hide show
  1. package/dist/server/adapters/drizzle/dataAdapter.js +76 -7
  2. package/dist/server/adapters/prisma/dataAdapter.js +19 -1
  3. package/dist/server/adapters/types.d.ts +33 -1
  4. package/dist/server/data.d.ts +18 -2
  5. package/dist/server/data.js +39 -8
  6. package/dist/server/handler.d.ts +35 -0
  7. package/dist/server/handler.js +67 -15
  8. package/dist/server/introspection/parser.d.ts +15 -0
  9. package/dist/server/introspection/parser.js +17 -0
  10. package/dist/server/mutations.d.ts +8 -4
  11. package/dist/server/mutations.js +203 -22
  12. package/dist/server/query/listColumns.d.ts +19 -0
  13. package/dist/server/query/listColumns.js +43 -0
  14. package/dist/server/query/pageSize.d.ts +19 -0
  15. package/dist/server/query/pageSize.js +26 -0
  16. package/dist/server/query/sortQuery.d.ts +31 -0
  17. package/dist/server/query/sortQuery.js +33 -0
  18. package/dist/server/query/urls.js +9 -1
  19. package/dist/server/runtime.d.ts +5 -0
  20. package/dist/server/runtime.js +54 -1
  21. package/dist/server/submitted.d.ts +20 -0
  22. package/dist/server/submitted.js +54 -0
  23. package/dist/server/views/FieldInput.svelte +114 -8
  24. package/dist/server/views/FieldInput.svelte.d.ts +7 -0
  25. package/dist/server/views/Form.svelte +71 -4
  26. package/dist/server/views/Form.svelte.d.ts +17 -0
  27. package/dist/server/views/Layout.svelte +5 -2
  28. package/dist/server/views/List.svelte +128 -23
  29. package/dist/server/views/List.svelte.d.ts +5 -0
  30. package/dist/server/views/RelationCheckboxes.svelte +26 -5
  31. package/dist/server/views/RelationCheckboxes.svelte.d.ts +9 -0
  32. package/dist/server/views/RelationSelect.svelte +14 -3
  33. package/dist/server/views/RelationSelect.svelte.d.ts +2 -0
  34. package/dist/server/views/html.d.ts +19 -0
  35. package/dist/server/views/html.js +25 -0
  36. package/dist/server/views/pagination.d.ts +9 -0
  37. package/dist/server/views/pagination.js +31 -0
  38. package/dist/server/views/theme.js +147 -3
  39. package/dist/server/views/types.d.ts +7 -0
  40. package/package.json +2 -1
@@ -1,17 +1,23 @@
1
1
  /**
2
2
  * POST create/update/delete handling — split out of `handler.ts`, pure
3
- * orchestration over `AdminRuntime`. Reads `formData` unconditionally: the
4
- * handler only calls this on `event.request.method === 'POST'`, so the
5
- * request body is never consumed on GET.
3
+ * orchestration over `AdminRuntime`.
4
+ *
5
+ * Le corps est lu par l'appelant et reçu en paramètre, pas consommé ici : le
6
+ * handler doit garder ce qui a été soumis pour le re-rendre si cette fonction
7
+ * lève (`readSubmittedForm`), et un corps de requête ne se lit qu'une fois.
8
+ * L'appelant n'invoque cette fonction que sur `POST`, donc aucun GET ne lit de
9
+ * corps.
6
10
  */
7
11
  import { primaryKeyOf, coerceId, formDataToPrisma } from './data.js';
12
+ import { isSensitiveStringField } from './introspection/parser.js';
8
13
  import { AdminMutationError, classifyWriteError } from './errors.js';
9
14
  import { buildAuditEvent, emitAudit, readAuditSnapshot } from './audit.js';
10
15
  import { scopeFrom, modelScopeFrom, modelScopeValues } from './runtime.js';
11
- export async function handleMutation(runtime, event, route) {
16
+ /** Plafond de sélection : la page la plus large de l'admin fait 200 lignes. */
17
+ const MAX_BULK_IDS = 200;
18
+ export async function handleMutation(runtime, event, route, formData) {
12
19
  const modelsConfig = runtime.config.models ?? {};
13
20
  const audit = runtime.config.audit;
14
- const formData = await event.request.formData();
15
21
  const action = formData.get('_action');
16
22
  if (!route.model)
17
23
  return null;
@@ -43,10 +49,10 @@ export async function handleMutation(runtime, event, route) {
43
49
  await runtime.adapter.data.deleteRecord(model, route.id, modelScopeFrom(runtime, model, { locals: event.locals }));
44
50
  }
45
51
  catch (e) {
46
- // Classé ici et non dans `handler.ts` : seul ce site connaît l'action réelle
47
- // (`handleMutation` a déjà consommé le corps de la requête, donc le handler
48
- // ne peut plus lire `_action`). Un code non reconnu est relayé tel quel, et
49
- // c'est le handler qui le masquera.
52
+ // Classé ici et non dans `handler.ts` : seul ce site connaît l'action
53
+ // réellement en cours, et `reference` / `restrict` partagent le même code
54
+ // SQLSTATE c'est l'action qui les sépare. Un code non reconnu est relayé
55
+ // tel quel, et c'est le handler qui le masquera.
50
56
  throw classifyWriteError(e, 'delete') ?? e;
51
57
  }
52
58
  if (audit) {
@@ -61,8 +67,88 @@ export async function handleMutation(runtime, event, route) {
61
67
  }
62
68
  return redirectToList(route.model);
63
69
  }
70
+ /**
71
+ * Suppression en masse. Une seule opération d'écriture, pas une boucle de
72
+ * `deleteRecord` : une boucle qui casse au septième id sur une contrainte de
73
+ * clé étrangère laisserait six lignes supprimées et rien pour revenir en
74
+ * arrière. Ici, tout part ou rien ne part.
75
+ *
76
+ * La portée du modèle est composée avec les ids DANS le `where` de
77
+ * l'adapter, jamais vérifiée à part : un id hors portée ne matche pas, sans
78
+ * erreur — donc rien ne distingue « n'existe pas » de « appartient à un
79
+ * autre tenant ». Le compte rendu est celui des lignes réellement
80
+ * supprimées ; un écart avec la sélection ne peut venir que d'un POST forgé,
81
+ * puisque l'interface n'offre que des lignes en portée.
82
+ */
83
+ if (action === 'bulk-delete') {
84
+ const ids = formData.getAll('ids').map(String);
85
+ if (ids.length === 0) {
86
+ throw new AdminMutationError('validation', 'Select at least one record to delete.');
87
+ }
88
+ // L'interface ne peut cocher que ce qu'elle affiche, et une page plafonne
89
+ // à 200 lignes : au-delà la requête est forgée, et un `IN (…)` de plusieurs
90
+ // milliers d'éléments est un vecteur de charge à lui seul.
91
+ if (ids.length > MAX_BULK_IDS) {
92
+ throw new AdminMutationError('validation', `Cannot delete more than ${MAX_BULK_IDS} records at once.`);
93
+ }
94
+ const coerced = ids.map((id) => coerceId(id, model));
95
+ const scope = modelScopeFrom(runtime, model, { locals: event.locals });
96
+ // Instantané AVANT suppression, et seulement si un puits d'audit existe :
97
+ // sans lui le journal serait muet sur l'opération la plus destructive.
98
+ // Lu avec la même portée que la suppression, donc exactement les lignes qui
99
+ // vont partir.
100
+ const idFilter = { op: 'in', field: primaryKeyOf(model), value: coerced };
101
+ const before = audit
102
+ ? await runtime.adapter.data.findMany(model, {
103
+ filter: scope ? { op: 'and', clauses: [idFilter, scope] } : idFilter
104
+ })
105
+ : [];
106
+ let deleted;
107
+ try {
108
+ deleted = await runtime.adapter.data.deleteMany(model, coerced, scope);
109
+ }
110
+ catch (e) {
111
+ throw classifyWriteError(e, 'delete') ?? e;
112
+ }
113
+ for (const row of before) {
114
+ await emitAudit(audit, buildAuditEvent({
115
+ event,
116
+ action: 'delete',
117
+ model,
118
+ id: row[primaryKeyOf(model)],
119
+ hidden: runtime.hiddenFieldsOf(model),
120
+ before: row
121
+ }));
122
+ }
123
+ return new Response(null, {
124
+ status: 303,
125
+ headers: {
126
+ Location: `${runtime.basePath}/${route.model.toLowerCase()}?deleted=${deleted}`
127
+ }
128
+ });
129
+ }
64
130
  if (action === 'create' || action === 'update') {
65
- const data = formDataToPrisma(formData, model);
131
+ const { data, invalid } = formDataToPrisma(formData, model);
132
+ // Refusé d'entrée : la valeur ne se convertit pas vers le type de la
133
+ // colonne (JSON illisible, nombre qui n'en est pas un), donc il n'y a même
134
+ // pas de payload à valider plus loin. Un seul champ est rapporté, comme
135
+ // partout ailleurs sur ce chemin — le formulaire re-rendu porte une erreur,
136
+ // pas une liste.
137
+ //
138
+ // Aucun conflit avec l'imposition du scope plus bas : le formulaire de
139
+ // création rend la colonne de tenant VIDE, et un vide n'est pas une valeur
140
+ // illisible (voir `formDataToPrisma`), donc rien ne devient incréable ici.
141
+ //
142
+ // Les scalaires de relation en sont exclus : la boucle FK plus bas fait sa
143
+ // propre coercion en relisant le FormData, et rattache son refus à l'arête
144
+ // (`author: invalid id`) plutôt qu'au scalaire (`authorId`). Contrairement
145
+ // au contrôle du vide, ce garde-ci n'est pas redondant avec l'ordre des
146
+ // boucles : `parseInt('abc')` produit un NaN que celle-ci verrait en
147
+ // premier, et le message perdrait le nom de la relation.
148
+ const firstInvalid = invalid.find((name) => !runtime.relationGraph?.scalarToRelation.has(name));
149
+ if (firstInvalid !== undefined) {
150
+ throw new AdminMutationError('validation', `${firstInvalid}: invalid value`, firstInvalid);
151
+ }
66
152
  // Appelé tôt pour échouer vite sur un scope non injectable (`or`, opérateur
67
153
  // autre que `eq`, tenant absent), avant tout travail de validation.
68
154
  // Volontairement PAS appliqué ici : `data` doit conserver ce que le client
@@ -72,6 +158,68 @@ export async function handleMutation(runtime, event, route) {
72
158
  const scopeValues = modelScopeValues(runtime, model, { locals: event.locals });
73
159
  const m2mInput = {};
74
160
  const targetGuards = [];
161
+ // Colonnes de texte sensibles (`password`, `passwordHash`, `apiToken`…).
162
+ // Le formulaire d'édition ne les rend pas — cf. `Form.svelte` — donc rien
163
+ // de légitime n'arrive par là ; une valeur présente vient d'un POST forgé
164
+ // ou d'un client qui invente un champ, et l'écrire remplacerait un
165
+ // credential par ce que l'appelant a choisi. La clé sort du payload, sans
166
+ // lever : ce n'est pas une donnée refusée, c'est une donnée non demandée.
167
+ //
168
+ // À la création, à l'inverse, le champ est offert. Un vide y était écrit
169
+ // comme `''`, ce qui produisait un compte au credential inutilisable avec
170
+ // un `303` d'apparence réussie. On refuse plutôt, et seulement quand la
171
+ // colonne est réellement obligatoire — une colonne optionnelle garde le
172
+ // droit d'être vide.
173
+ for (const field of model.fields) {
174
+ if (!isSensitiveStringField(field))
175
+ continue;
176
+ if (action === 'update') {
177
+ delete data[field.name];
178
+ continue;
179
+ }
180
+ const submitted = data[field.name];
181
+ const isEmpty = submitted === undefined || submitted === null || submitted === '';
182
+ if (isEmpty && field.isRequired && !field.hasDefault) {
183
+ throw new AdminMutationError('validation', `${field.name} is required`, field.name);
184
+ }
185
+ // Optionnelle et vide : ne rien écrire plutôt qu'une chaîne vide, qui
186
+ // serait indistinguable d'un secret réellement égal à ''.
187
+ if (isEmpty)
188
+ delete data[field.name];
189
+ }
190
+ /**
191
+ * Revalidation des enums. Le `<select>` rendu par `FieldInput` ne propose
192
+ * que des valeurs déclarées, mais un POST forgé n'y est pas tenu — même
193
+ * raison que la revalidation des cibles FK/m2m plus bas : ce que
194
+ * l'interface n'aurait pas offert ne doit pas passer sous prétexte qu'elle
195
+ * ne l'aurait pas offert. Sans ça, la valeur inventée part au pilote et
196
+ * ressort en message générique, sans désigner le champ fautif.
197
+ *
198
+ * `schema!` et `get(...)!` sont sûrs par construction : `isEnum` n'est posé
199
+ * qu'à partir de la table des enums du schéma (`parser.ts:220`, et
200
+ * `inspect.ts` côté Drizzle qui alimente les deux ensemble), et un modèle
201
+ * résolu implique un schéma introspecté.
202
+ *
203
+ * Le vide se décide sur `isRequired` seul, sans le `!hasDefault` de la
204
+ * boucle au-dessus : celle-ci couvre une création où le défaut de la base
205
+ * peut encore remplir la colonne, alors qu'ici « vide » vise une colonne
206
+ * qui n'accepte pas NULL — un `@default` n'y change rien.
207
+ */
208
+ const schemaEnums = runtime.schema.enums;
209
+ for (const field of model.fields) {
210
+ if (!field.isEnum || !(field.name in data))
211
+ continue;
212
+ // Le vide (ce que poste le « — aucun — » du widget) ne se décide pas ici
213
+ // mais dans le contrôle unique en fin de fonction, après l'imposition du
214
+ // scope : une colonne de tenant qui se trouve être un enum est rendue
215
+ // vide par le formulaire de création, et la refuser ici rendrait le
216
+ // modèle incréable. Cette boucle ne valide plus que le domaine.
217
+ if (data[field.name] === null)
218
+ continue;
219
+ if (!schemaEnums.get(field.type).includes(String(data[field.name]))) {
220
+ throw new AdminMutationError('validation', `${field.name}: invalid value`, field.name);
221
+ }
222
+ }
75
223
  // Validation des FK owning : coercion + existence + self-ref.
76
224
  // Rejoue le `where` de scoping : un ID hors du where est rejeté,
77
225
  // pas seulement caché du select (IDOR par POST forgé).
@@ -209,10 +357,10 @@ export async function handleMutation(runtime, event, route) {
209
357
  // coercée ne divergent pas sur le seul type.
210
358
  //
211
359
  // Seule une valeur réellement affirmée par le client est confrontée au
212
- // scope. `formDataToPrisma` renvoie `''` (String) ou `null` (Int, Float,
213
- // DateTime) pour un champ présent mais vide — et le formulaire de création
214
- // rend justement la colonne de scope vide. Traiter ce vide comme un conflit
215
- // rendrait toute création impossible dès que la colonne est visible.
360
+ // scope. `formDataToPrisma` renvoie `null` pour un champ présent mais vide,
361
+ // quel que soit son type — et le formulaire de création rend justement la
362
+ // colonne de scope vide. Traiter ce vide comme un conflit rendrait toute
363
+ // création impossible dès que la colonne est visible.
216
364
  // Un vide veut dire « le formulaire n'a rien fourni », pas « le client
217
365
  // revendique un autre tenant » : on impose alors la valeur sans lever.
218
366
  //
@@ -228,6 +376,39 @@ export async function handleMutation(runtime, event, route) {
228
376
  }
229
377
  data[field] = value;
230
378
  }
379
+ /**
380
+ * Vide sur une colonne qui n'accepte pas NULL : refus, et le champ fautif
381
+ * est nommé. `formDataToPrisma` distingue déjà les deux sens du mot vide —
382
+ * une clé ABSENTE n'a pas été soumise (readonly, masquée, colonne à défaut
383
+ * que le formulaire de création n'affiche pas) et n'écrit rien ; une clé
384
+ * PRÉSENTE à `null` est une saisie vidée.
385
+ *
386
+ * Placé ici, en dernier, et pas plus haut avec les autres validations :
387
+ *
388
+ * - après l'imposition du scope, parce que le formulaire de création rend
389
+ * la colonne de tenant vide (voir le bloc juste au-dessus). À ce point la
390
+ * valeur imposée par le serveur est déjà posée, donc plus rien à refuser ;
391
+ * - après la boucle FK, qui rattache son refus à l'arête (`author is
392
+ * required`) et non au scalaire (`authorId`). C'est cet ordre qui lui en
393
+ * laisse la propriété, pas un garde ici : une arête optionnelle a un
394
+ * scalaire optionnel (Prisma lie les deux), donc le seul scalaire de
395
+ * relation qui puisse encore être `null` en arrivant ici appartient à une
396
+ * arête que la boucle FK ne gère pas — une FK composite, qu'aucun widget
397
+ * ne rend et que seul ce contrôle-ci peut alors nommer.
398
+ *
399
+ * Les champs sensibles n'y passent pas non plus : leur boucle, tout en
400
+ * haut, supprime déjà la clé d'une colonne optionnelle vide plutôt que d'y
401
+ * écrire `null` — `''` y serait indistinguable d'un secret réellement vide.
402
+ *
403
+ * `hasDefault` n'entre pas dans la décision : à la création la colonne à
404
+ * défaut n'est pas rendue, donc la clé est absente ; à l'édition la ligne a
405
+ * déjà une valeur, et la vider est une saisie, pas une absence.
406
+ */
407
+ for (const field of model.fields) {
408
+ if (!field.isRequired || !(field.name in data) || data[field.name] !== null)
409
+ continue;
410
+ throw new AdminMutationError('validation', `${field.name} is required`, field.name);
411
+ }
231
412
  if (action === 'create') {
232
413
  let created;
233
414
  try {
@@ -238,10 +419,10 @@ export async function handleMutation(runtime, event, route) {
238
419
  });
239
420
  }
240
421
  catch (e) {
241
- // Classé ici et non dans `handler.ts` : seul ce site connaît l'action réelle
242
- // (`handleMutation` a déjà consommé le corps de la requête, donc le handler
243
- // ne peut plus lire `_action`). Un code non reconnu est relayé tel quel, et
244
- // c'est le handler qui le masquera.
422
+ // Classé ici et non dans `handler.ts` : seul ce site connaît l'action
423
+ // réellement en cours, et `reference` / `restrict` partagent le même code
424
+ // SQLSTATE c'est l'action qui les sépare. Un code non reconnu est relayé
425
+ // tel quel, et c'est le handler qui le masquera.
245
426
  throw classifyWriteError(e, 'create') ?? e;
246
427
  }
247
428
  if (audit) {
@@ -269,10 +450,10 @@ export async function handleMutation(runtime, event, route) {
269
450
  updated = await runtime.adapter.data.updateRecord(model, route.id, { scalars: data, m2m: m2mInput, targetGuards }, modelScopeFrom(runtime, model, { locals: event.locals }));
270
451
  }
271
452
  catch (e) {
272
- // Classé ici et non dans `handler.ts` : seul ce site connaît l'action réelle
273
- // (`handleMutation` a déjà consommé le corps de la requête, donc le handler
274
- // ne peut plus lire `_action`). Un code non reconnu est relayé tel quel, et
275
- // c'est le handler qui le masquera.
453
+ // Classé ici et non dans `handler.ts` : seul ce site connaît l'action
454
+ // réellement en cours, et `reference` / `restrict` partagent le même code
455
+ // SQLSTATE c'est l'action qui les sépare. Un code non reconnu est relayé
456
+ // tel quel, et c'est le handler qui le masquera.
276
457
  throw classifyWriteError(e, 'update') ?? e;
277
458
  }
278
459
  if (audit) {
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Colonnes réellement rendues par la vue liste.
3
+ *
4
+ * Extrait de `List.svelte`, où cette composition vivait dans un `$derived` de
5
+ * composant : le handler en a besoin CÔTÉ SERVEUR pour décider quels champs un
6
+ * `?sort=` a le droit de désigner. Deux implémentations de « ce que la liste
7
+ * affiche » finiraient par diverger, et un tri autorisé sur une colonne que la
8
+ * liste n'affiche pas est un oracle — `?sort=passwordHash` plus la pagination
9
+ * suffit à ordonner des secrets par dichotomie. Même raison que le prédicat de
10
+ * sensibilité partagé (`parser.ts`) : une seule source, jamais deux.
11
+ *
12
+ * `getDisplayFields` reste la première passe (relations, listes, noms
13
+ * sensibles) ; cette fonction y ajoute ce que seule la config connaît.
14
+ */
15
+ import { type PrismaField } from '../introspection/parser.js';
16
+ export declare function resolveListColumns(fields: PrismaField[], opts: {
17
+ hidden?: string[];
18
+ listFields?: string[];
19
+ }): PrismaField[];
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Colonnes réellement rendues par la vue liste.
3
+ *
4
+ * Extrait de `List.svelte`, où cette composition vivait dans un `$derived` de
5
+ * composant : le handler en a besoin CÔTÉ SERVEUR pour décider quels champs un
6
+ * `?sort=` a le droit de désigner. Deux implémentations de « ce que la liste
7
+ * affiche » finiraient par diverger, et un tri autorisé sur une colonne que la
8
+ * liste n'affiche pas est un oracle — `?sort=passwordHash` plus la pagination
9
+ * suffit à ordonner des secrets par dichotomie. Même raison que le prédicat de
10
+ * sensibilité partagé (`parser.ts`) : une seule source, jamais deux.
11
+ *
12
+ * `getDisplayFields` reste la première passe (relations, listes, noms
13
+ * sensibles) ; cette fonction y ajoute ce que seule la config connaît.
14
+ */
15
+ import { getDisplayFields } from '../introspection/parser.js';
16
+ /** Types qu'aucune cellule ne sait rendre lisiblement. */
17
+ const UNRENDERABLE_TYPES = ['Json', 'Bytes'];
18
+ /** Au-delà, la table déborde horizontalement sur un écran ordinaire. */
19
+ const MAX_COLUMNS = 6;
20
+ export function resolveListColumns(fields, opts) {
21
+ const hidden = opts.hidden ?? [];
22
+ const listFields = opts.listFields;
23
+ const explicit = new Set(listFields ?? []);
24
+ const safeNames = new Set(getDisplayFields({ fields }).map((f) => f.name));
25
+ let columns = fields.filter((f) =>
26
+ // `listFields` explicite l'emporte sur l'heuristique de nom sensible —
27
+ // échappatoire documentée pour ses faux positifs (`tokenCount`,
28
+ // `hashtagCount`), et couverte par les tests de `List.svelte`. Elle ne
29
+ // l'emporte jamais sur `hidden`, qui est un refus explicite.
30
+ //
31
+ // C'est aussi ce qui rend la whitelist de tri sûre sans règle en plus :
32
+ // trier ne porte que sur des colonnes DÉJÀ rendues, donc sur des valeurs
33
+ // que le lecteur peut lire de toute façon. Le tri n'ouvre aucun oracle
34
+ // qui ne soit pas déjà une lecture directe.
35
+ (explicit.has(f.name) || safeNames.has(f.name)) &&
36
+ !hidden.includes(f.name) &&
37
+ !f.relation &&
38
+ !UNRENDERABLE_TYPES.includes(f.type));
39
+ if (listFields?.length) {
40
+ columns = columns.filter((f) => listFields.includes(f.name));
41
+ }
42
+ return columns.slice(0, MAX_COLUMNS);
43
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Taille de page : celle configurée, et celles qu'un `?perPage=` a le droit de
3
+ * demander.
4
+ *
5
+ * Le point qui compte : la valeur venue de l'URL n'est jamais utilisée telle
6
+ * quelle. Elle doit appartenir à la liste proposée, sinon `?perPage=100000`
7
+ * devient un `take` non borné — un déni de service à un paramètre près, et sur
8
+ * une table volumineuse une requête qui tient la connexion. C'est la même règle
9
+ * d'or que les opérateurs de filtre et les colonnes de tri : l'URL choisit dans
10
+ * une liste finie, elle ne décrit rien.
11
+ */
12
+ /**
13
+ * Tailles sélectionnables : les options configurées, plus la taille par défaut
14
+ * (sinon elle serait active sans figurer dans le sélecteur), triées et
15
+ * dédoublonnées. Une liste d'options vide désactive entièrement le mécanisme —
16
+ * pas de sélecteur, et `?perPage=` sans effet.
17
+ */
18
+ export declare function resolvePageSizes(perPage: number, options: number[]): number[];
19
+ export declare function parsePageSize(params: URLSearchParams, fallback: number, selectable: number[]): number;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Taille de page : celle configurée, et celles qu'un `?perPage=` a le droit de
3
+ * demander.
4
+ *
5
+ * Le point qui compte : la valeur venue de l'URL n'est jamais utilisée telle
6
+ * quelle. Elle doit appartenir à la liste proposée, sinon `?perPage=100000`
7
+ * devient un `take` non borné — un déni de service à un paramètre près, et sur
8
+ * une table volumineuse une requête qui tient la connexion. C'est la même règle
9
+ * d'or que les opérateurs de filtre et les colonnes de tri : l'URL choisit dans
10
+ * une liste finie, elle ne décrit rien.
11
+ */
12
+ /**
13
+ * Tailles sélectionnables : les options configurées, plus la taille par défaut
14
+ * (sinon elle serait active sans figurer dans le sélecteur), triées et
15
+ * dédoublonnées. Une liste d'options vide désactive entièrement le mécanisme —
16
+ * pas de sélecteur, et `?perPage=` sans effet.
17
+ */
18
+ export function resolvePageSizes(perPage, options) {
19
+ if (options.length === 0)
20
+ return [];
21
+ return [...new Set([...options, perPage])].sort((a, b) => a - b);
22
+ }
23
+ export function parsePageSize(params, fallback, selectable) {
24
+ const requested = Number(params.get('perPage'));
25
+ return selectable.includes(requested) ? requested : fallback;
26
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * `?sort=<colonne>&dir=asc|desc` → ordre de tri de la vue liste.
3
+ *
4
+ * Même règle d'or que les filtres (`listQuery.ts`) : la chaîne venue de l'URL
5
+ * ne devient jamais une clé de requête. Elle est cherchée dans la liste des
6
+ * colonnes que la vue rend réellement (`resolveListColumns`), et seul un membre
7
+ * de cette liste ressort. Une colonne masquée par `hidden`, écartée par
8
+ * l'heuristique de nom, ou simplement tronquée par le plafond de colonnes n'y
9
+ * est pas — donc pas triable.
10
+ *
11
+ * Trier ne peut ainsi ordonner que des valeurs déjà lisibles à l'écran : le tri
12
+ * n'ouvre aucune lecture que la liste n'offrait pas.
13
+ */
14
+ export type SortDirection = 'asc' | 'desc';
15
+ export interface ActiveSort {
16
+ field: string;
17
+ dir: SortDirection;
18
+ }
19
+ export interface SortState {
20
+ /** null = ordre par défaut (clé primaire décroissante, côté adapter). */
21
+ active: ActiveSort | null;
22
+ /** true quand l'URL a demandé une colonne non triable — rendu comme message. */
23
+ ignored: boolean;
24
+ }
25
+ /**
26
+ * `fallback` est le `models[].defaultSort` déjà validé au démarrage. Il
27
+ * s'applique quand l'URL ne demande rien, ET quand elle demande une colonne
28
+ * refusée : le refus reste signalé (`ignored`), mais la liste garde un ordre
29
+ * intentionnel plutôt que de retomber sur la clé primaire.
30
+ */
31
+ export declare function parseSortQuery(params: URLSearchParams, sortable: string[], fallback?: ActiveSort): SortState;
@@ -0,0 +1,33 @@
1
+ /**
2
+ * `?sort=<colonne>&dir=asc|desc` → ordre de tri de la vue liste.
3
+ *
4
+ * Même règle d'or que les filtres (`listQuery.ts`) : la chaîne venue de l'URL
5
+ * ne devient jamais une clé de requête. Elle est cherchée dans la liste des
6
+ * colonnes que la vue rend réellement (`resolveListColumns`), et seul un membre
7
+ * de cette liste ressort. Une colonne masquée par `hidden`, écartée par
8
+ * l'heuristique de nom, ou simplement tronquée par le plafond de colonnes n'y
9
+ * est pas — donc pas triable.
10
+ *
11
+ * Trier ne peut ainsi ordonner que des valeurs déjà lisibles à l'écran : le tri
12
+ * n'ouvre aucune lecture que la liste n'offrait pas.
13
+ */
14
+ /**
15
+ * `fallback` est le `models[].defaultSort` déjà validé au démarrage. Il
16
+ * s'applique quand l'URL ne demande rien, ET quand elle demande une colonne
17
+ * refusée : le refus reste signalé (`ignored`), mais la liste garde un ordre
18
+ * intentionnel plutôt que de retomber sur la clé primaire.
19
+ */
20
+ export function parseSortQuery(params, sortable, fallback) {
21
+ const requested = params.get('sort');
22
+ // Vide == absent : `?sort=` est un artefact d'interface (un form GET qui
23
+ // sérialise un champ non renseigné), pas une demande à refuser bruyamment.
24
+ if (!requested)
25
+ return { active: fallback ?? null, ignored: false };
26
+ if (!sortable.includes(requested))
27
+ return { active: fallback ?? null, ignored: true };
28
+ // Domaine à deux valeurs : tout ce qui n'est pas `desc` est ascendant. Rien à
29
+ // refuser ici — contrairement au champ, une direction inconnue ne désigne
30
+ // aucune colonne et ne peut donc rien révéler.
31
+ const dir = params.get('dir') === 'desc' ? 'desc' : 'asc';
32
+ return { active: { field: requested, dir }, ignored: false };
33
+ }
@@ -7,6 +7,12 @@
7
7
  * "always drop `page` on filter change" invariant (docs/design §3.3, §7.1)
8
8
  * and risks parameter-order drift, which makes snapshots flaky.
9
9
  */
10
+ /**
11
+ * Paramètres qui ne sont pas un état de liste : un compte rendu d'action posé
12
+ * une fois par une redirection après écriture. Retiré de tout lien construit
13
+ * ensuite, sinon « 3 supprimés » réapparaît à chaque clic de filtre ou de page.
14
+ */
15
+ const ONE_SHOT_PARAMS = ['deleted'];
10
16
  /**
11
17
  * Build a list-view URL from the current one, applying a patch of query
12
18
  * params. `null` in the patch removes that key. `page` is ALWAYS dropped
@@ -23,6 +29,8 @@ export function buildListUrl(currentUrl, patch) {
23
29
  if (!('page' in patch)) {
24
30
  params.delete('page');
25
31
  }
32
+ for (const key of ONE_SHOT_PARAMS)
33
+ params.delete(key);
26
34
  for (const [key, value] of Object.entries(patch)) {
27
35
  if (value === null)
28
36
  params.delete(key);
@@ -47,7 +55,7 @@ export function buildListUrl(currentUrl, patch) {
47
55
  * `page` is always excluded too, since any new search/filter resets it.
48
56
  */
49
57
  export function hiddenParams(currentUrl, exclude) {
50
- const excluded = new Set([...exclude, 'page']);
58
+ const excluded = new Set([...exclude, 'page', ...ONE_SHOT_PARAMS]);
51
59
  const out = [];
52
60
  for (const [key, value] of currentUrl.searchParams) {
53
61
  if (excluded.has(key))
@@ -1,4 +1,5 @@
1
1
  import type { Schema, Model } from './types/schema.js';
2
+ import type { ActiveSort } from './query/sortQuery.js';
2
3
  import { type RelationGraph } from './introspection/relations.js';
3
4
  import type { ViewModel } from './views/types.js';
4
5
  import type { DataAdapter, SchemaIntrospector, Filter } from './adapters/types.js';
@@ -33,6 +34,10 @@ export interface AdminRuntime {
33
34
  config: AdminHandlerConfig;
34
35
  basePath: string;
35
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;
36
41
  selectThreshold: number;
37
42
  filterLinkThreshold: number;
38
43
  labelFieldCandidates: string[];
@@ -1,4 +1,6 @@
1
1
  import { isSensitiveFieldName } from './introspection/parser.js';
2
+ import { resolveListColumns } from './query/listColumns.js';
3
+ import { resolvePageSizes } from './query/pageSize.js';
2
4
  import { buildRelationGraph } from './introspection/relations.js';
3
5
  import { primaryKeyOf } from './data.js';
4
6
  import { validateListFilterConfig } from './query/filterDetection.js';
@@ -94,6 +96,13 @@ export function createAdminRuntime(config) {
94
96
  catch (e) {
95
97
  console.warn('[sveltekit-admin] Could not introspect schema:', e);
96
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();
97
106
  const models = schema?.models.filter((m) => {
98
107
  // Exclude explicitly excluded models
99
108
  if (exclude.includes(m.name))
@@ -116,6 +125,31 @@ export function createAdminRuntime(config) {
116
125
  if (entries)
117
126
  validateListFilterConfig(m.name, entries, m, relationGraph, hiddenFieldsOf(m));
118
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)]));
119
153
  const labelOf = (m) => {
120
154
  const configured = modelsConfig[m.name]?.label;
121
155
  if (configured)
@@ -130,10 +164,27 @@ export function createAdminRuntime(config) {
130
164
  label: labelOf(m),
131
165
  fields: m.fields,
132
166
  primaryKey: primaryKeyOf(m),
167
+ enums: schemaEnums,
133
168
  // Non-null par construction : `m` vient toujours de `models`,
134
169
  // dérivé du schéma qu'on vient de parser avec succès.
135
170
  relationGraph: relationGraph
136
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);
137
188
  const selectThreshold = config.relationDefaults?.selectThreshold ?? 200;
138
189
  const filterLinkThreshold = config.listFilterDefaults?.linkThreshold ?? 20;
139
190
  const labelFieldCandidates = config.relationDefaults?.labelFields ?? [
@@ -196,7 +247,9 @@ export function createAdminRuntime(config) {
196
247
  modelList,
197
248
  config,
198
249
  basePath,
199
- perPage: 20,
250
+ perPage,
251
+ pageSizes,
252
+ defaultSortOf: (m) => defaultSorts.get(m.name),
200
253
  selectThreshold,
201
254
  filterLinkThreshold,
202
255
  labelFieldCandidates,
@@ -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;