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,4 +1,4 @@
1
- import { and, asc, count, desc, eq, getTableColumns } from "drizzle-orm";
1
+ import { and, asc, count, desc, eq, getTableColumns, inArray } from "drizzle-orm";
2
2
  import { coerceId, primaryKeyOf } from "../../data.js";
3
3
  import { withWriteRetry } from "../retry.js";
4
4
  import { compileFilterToDrizzle } from "./filterCompiler.js";
@@ -12,6 +12,21 @@ function tableFor(ctx, model) {
12
12
  function primaryKeyColumn(table, model) {
13
13
  return getTableColumns(table)[primaryKeyOf(model)];
14
14
  }
15
+ /**
16
+ * Résolution d'une colonne par nom, partagée par les deux chemins de tri
17
+ * (`listRecords` pour `?sort=`, `findMany` pour un `orderBy` de config). Un
18
+ * nom inconnu lève : côté `?sort=` il ne peut pas arriver — `sortQuery.ts` ne
19
+ * laisse sortir que des colonnes rendues — donc s'il arrive, c'est la table
20
+ * Drizzle qui ne correspond pas au schéma introspecté, et échouer fort vaut
21
+ * mieux qu'un tri silencieusement absent.
22
+ */
23
+ function columnFor(table, field) {
24
+ const column = getTableColumns(table)[field];
25
+ if (!column) {
26
+ throw new Error(`[sveltekit-admin] unknown field '${field}' on Drizzle table`);
27
+ }
28
+ return column;
29
+ }
15
30
  /**
16
31
  * Ordre de verrouillage déterministe. Les guards m2m sont empilés dans l'ordre
17
32
  * des ids soumis par le formulaire, donc sous contrôle du client : deux
@@ -140,12 +155,23 @@ export function createDrizzleDataAdapter(db, ctx) {
140
155
  const table = tableFor(ctx, model);
141
156
  const where = compileHere(table, opts.filter);
142
157
  const primaryKey = primaryKeyColumn(table, model);
158
+ const requested = opts.orderBy;
159
+ // Départage systématique par la clé primaire, sauf quand c'est elle qu'on
160
+ // trie : deux lignes de même valeur doivent garder le même rang d'une
161
+ // requête à l'autre, sinon la fenêtre skip/take fait sauter des lignes.
162
+ const orderBy = !requested
163
+ ? [desc(primaryKey)]
164
+ : (() => {
165
+ const column = columnFor(table, requested.field);
166
+ const primary = column === primaryKey ? [] : [desc(primaryKey)];
167
+ return [requested.dir === "asc" ? asc(column) : desc(column), ...primary];
168
+ })();
143
169
  const [rows, totals] = await Promise.all([
144
170
  db
145
171
  .select()
146
172
  .from(table)
147
173
  .where(where)
148
- .orderBy(desc(primaryKey))
174
+ .orderBy(...orderBy)
149
175
  .limit(opts.take)
150
176
  .offset(opts.skip),
151
177
  db.select({ n: count() }).from(table).where(where),
@@ -154,12 +180,8 @@ export function createDrizzleDataAdapter(db, ctx) {
154
180
  },
155
181
  async findMany(model, opts) {
156
182
  const table = tableFor(ctx, model);
157
- const columns = getTableColumns(table);
158
183
  const orderBy = Object.entries(opts.orderBy ?? {}).map(([field, direction]) => {
159
- const column = columns[field];
160
- if (!column) {
161
- throw new Error(`[sveltekit-admin] unknown field '${field}' on Drizzle table`);
162
- }
184
+ const column = columnFor(table, field);
163
185
  return direction === "asc" ? asc(column) : desc(column);
164
186
  });
165
187
  let query = db
@@ -314,6 +336,53 @@ export function createDrizzleDataAdapter(db, ctx) {
314
336
  }
315
337
  }, { isolationLevel: "serializable" }));
316
338
  },
339
+ async deleteMany(model, ids, authorizationFilter) {
340
+ const table = tableFor(ctx, model);
341
+ const primaryKey = primaryKeyColumn(table, model);
342
+ const coerced = ids.map((id) => coerceId(String(id), model));
343
+ const scopedWhere = and(inArray(primaryKey, coerced), compileHere(table, authorizationFilter));
344
+ const links = [...ctx.m2m.entries()]
345
+ .filter(([key]) => key.startsWith(`${model.name}.`))
346
+ .map(([, link]) => link);
347
+ /**
348
+ * Les ids réellement concernés sont LUS avant de toucher quoi que ce
349
+ * soit, à l'intérieur de la transaction. Ce n'est pas une vérification
350
+ * défensive : les pivots m2m doivent être supprimés pour ces lignes-là et
351
+ * pas pour les autres. Composer le scope directement dans le DELETE des
352
+ * pivots effacerait les liaisons d'une ligne hors portée que le DELETE du
353
+ * parent, lui, ne toucherait pas — une ligne d'un autre tenant amputée de
354
+ * ses relations sans que rien ne l'indique.
355
+ *
356
+ * `deleteRecord` peut s'en passer parce qu'il vise UNE ligne et lève quand
357
+ * elle n'est pas dans la portée, ce qui annule tout. Ici une portée
358
+ * partielle est un résultat normal, pas une erreur : le compte renvoyé est
359
+ * celui des lignes supprimées.
360
+ */
361
+ if (ctx.dialect === "sqlite") {
362
+ return db.transaction((tx) => {
363
+ const matched = tx.select({ id: primaryKey }).from(table).where(scopedWhere).all()
364
+ .map((row) => row.id);
365
+ if (matched.length === 0)
366
+ return 0;
367
+ for (const link of links) {
368
+ tx.delete(link.pivot).where(inArray(link.selfColumn, matched)).run();
369
+ }
370
+ tx.delete(table).where(inArray(primaryKey, matched)).run();
371
+ return matched.length;
372
+ }, { behavior: "immediate" });
373
+ }
374
+ return withWriteRetry(() => db.transaction(async (tx) => {
375
+ const rows = await tx.select({ id: primaryKey }).from(table).where(scopedWhere);
376
+ const matched = rows.map((row) => row.id);
377
+ if (matched.length === 0)
378
+ return 0;
379
+ for (const link of links) {
380
+ await tx.delete(link.pivot).where(inArray(link.selfColumn, matched));
381
+ }
382
+ await tx.delete(table).where(inArray(primaryKey, matched));
383
+ return matched.length;
384
+ }, { isolationLevel: "serializable" }));
385
+ },
317
386
  async getM2mSelectedIds(model, edge, _targetModel, recordId) {
318
387
  const link = ctx.m2m.get(`${model.name}.${edge.field}`);
319
388
  if (!link)
@@ -49,8 +49,17 @@ export function createPrismaDataAdapter(prisma, opts = {}) {
49
49
  const key = toPrismaModel(model.name);
50
50
  const primaryKey = primaryKeyOf(model);
51
51
  const where = compileHere(listOpts.filter);
52
+ const requested = listOpts.orderBy;
53
+ // Objet (et non tableau à un élément) quand rien n'est demandé : c'est la
54
+ // forme historique, et la garder évite de faire bouger des attentes de
55
+ // tests qui ne parlent pas de tri.
56
+ const orderBy = !requested
57
+ ? { [primaryKey]: 'desc' }
58
+ : requested.field === primaryKey
59
+ ? [{ [primaryKey]: requested.dir }]
60
+ : [{ [requested.field]: requested.dir }, { [primaryKey]: 'desc' }];
52
61
  const [rows, total] = await Promise.all([
53
- prisma[key].findMany({ where, skip: listOpts.skip, take: listOpts.take, orderBy: { [primaryKey]: 'desc' } }),
62
+ prisma[key].findMany({ where, skip: listOpts.skip, take: listOpts.take, orderBy }),
54
63
  prisma[key].count({ where })
55
64
  ]);
56
65
  return { rows, total };
@@ -119,6 +128,15 @@ export function createPrismaDataAdapter(prisma, opts = {}) {
119
128
  : { [primaryKey]: coerceId(String(id), model) };
120
129
  await prisma[toPrismaModel(model.name)].delete({ where });
121
130
  },
131
+ async deleteMany(model, ids, authorizationFilter) {
132
+ const primaryKey = primaryKeyOf(model);
133
+ const coerced = ids.map((id) => coerceId(String(id), model));
134
+ const where = authorizationFilter
135
+ ? { [primaryKey]: { in: coerced }, AND: [compileHere(authorizationFilter)] }
136
+ : { [primaryKey]: { in: coerced } };
137
+ const { count } = await prisma[toPrismaModel(model.name)].deleteMany({ where });
138
+ return count;
139
+ },
122
140
  async getM2mSelectedIds(model, edge, targetModel, recordId) {
123
141
  try {
124
142
  const primaryKey = primaryKeyOf(model);
@@ -26,17 +26,36 @@ export interface TargetGuard {
26
26
  targetPk: string | number;
27
27
  filter?: Filter;
28
28
  }
29
+ /** Tri demandé par `?sort=`, déjà validé contre les colonnes que la vue rend. */
30
+ export interface ListOrder {
31
+ field: string;
32
+ dir: 'asc' | 'desc';
33
+ }
29
34
  /**
30
35
  * Per-request CRUD + relation-read surface `handler.ts` talks to instead of
31
36
  * a raw ORM client. See docs/superpowers/specs/2026-08-13-db-adapter-abstraction-design.md
32
37
  * for the rationale behind each method's shape.
33
38
  */
34
39
  export interface DataAdapter {
35
- /** Vue liste paginée : toujours tri PK desc, toujours count + fetch ensemble. */
40
+ /**
41
+ * Vue liste paginée : toujours count + fetch ensemble.
42
+ *
43
+ * `orderBy` absent = clé primaire décroissante, l'ordre historique. Présent,
44
+ * il est TOUJOURS départagé par la clé primaire décroissante, sauf quand
45
+ * c'est elle qu'on trie : sans ce départage, deux lignes de même valeur
46
+ * peuvent changer de page d'une requête à l'autre, et une fenêtre
47
+ * `skip`/`take` posée par-dessus perd son sens (une ligne vue deux fois, une
48
+ * autre jamais). C'est à l'adapter de le composer — lui seul sait nommer la
49
+ * clé primaire dans le langage de son moteur.
50
+ *
51
+ * `field` n'est jamais une chaîne libre : `sortQuery.ts` ne le laisse sortir
52
+ * que s'il appartient aux colonnes réellement rendues par la liste.
53
+ */
36
54
  listRecords(model: Model, opts: {
37
55
  filter?: Filter;
38
56
  skip: number;
39
57
  take: number;
58
+ orderBy?: ListOrder;
40
59
  }): Promise<{
41
60
  rows: Record<string, unknown>[];
42
61
  total: number;
@@ -81,6 +100,19 @@ export interface DataAdapter {
81
100
  targetGuards?: TargetGuard[];
82
101
  }, authorizationFilter?: Filter): Promise<Record<string, unknown>>;
83
102
  deleteRecord(model: Model, id: string | number, authorizationFilter?: Filter): Promise<void>;
103
+ /**
104
+ * Suppression en masse, en UNE opération et non une boucle de
105
+ * `deleteRecord` : une boucle qui casse au septième id sur une contrainte de
106
+ * clé étrangère laisse six lignes supprimées et rien pour revenir en
107
+ * arrière. Ici les deux seules réponses possibles sont « tout est supprimé »
108
+ * et « rien ne l'est, parce que telle ligne est encore référencée ».
109
+ *
110
+ * `authorizationFilter` est composé avec les ids DANS le `where` plutôt que
111
+ * vérifié à part : un id hors portée ne matche simplement pas, sans erreur,
112
+ * donc rien ne distingue « n'existe pas » de « appartient à un autre
113
+ * tenant ». Le compte renvoyé est celui des lignes réellement supprimées.
114
+ */
115
+ deleteMany(model: Model, ids: Array<string | number>, authorizationFilter?: Filter): Promise<number>;
84
116
  /** `targetModel` est fourni par l'appelant : chaque site d'appel actuel l'a déjà résolu. */
85
117
  getM2mSelectedIds(model: Model, edge: RelationEdge, targetModel: Model, recordId: string | number): Promise<Array<string | number>>;
86
118
  }
@@ -16,8 +16,24 @@ export declare function primaryKeyOf(model: PrismaModel): string;
16
16
  * côté intégration.
17
17
  */
18
18
  export declare function coerceId(id: string, model: PrismaModel): string | number;
19
- /** Convertit un FormData en payload Prisma, en ignorant les champs auto-gérés. */
20
- export declare function formDataToPrisma(formData: FormData, model: PrismaModel): Record<string, unknown>;
19
+ /**
20
+ * Convertit un FormData en payload Prisma, en ignorant les champs auto-gérés.
21
+ *
22
+ * Renvoie aussi la liste des champs dont la valeur soumise ne se convertit pas
23
+ * vers le type de la colonne : un JSON illisible, un nombre qui n'en est pas
24
+ * un. Leur clé est laissée HORS du payload, et c'est `mutations.ts` — le seul
25
+ * producteur de refus côté bibliothèque, cf. `errors.ts` — qui décide quoi en
26
+ * faire. Cette fonction ne lève pas : elle convertit et rapporte.
27
+ *
28
+ * Ces valeurs étaient auparavant écrites en `null` sans un mot. Sur une colonne
29
+ * nullable, la saisie et la valeur déjà stockée disparaissaient ensemble
30
+ * derrière un `303` d'apparence réussie ; sur une colonne obligatoire, le
31
+ * pilote répondait par un message générique ne nommant aucun champ.
32
+ */
33
+ export declare function formDataToPrisma(formData: FormData, model: PrismaModel): {
34
+ data: Record<string, unknown>;
35
+ invalid: string[];
36
+ };
21
37
  /**
22
38
  * Traduit le paramètre `?page=` en fenêtre Prisma. Toute entrée qui n'est pas un
23
39
  * entier sûr >= 1 retombe sur la première page : sans ce garde-fou, `?page=abc`
@@ -22,9 +22,23 @@ export function coerceId(id, model) {
22
22
  const pkField = model.fields.find((f) => f.name === primaryKeyOf(model));
23
23
  return pkField?.type === 'Int' ? parseInt(id) : id;
24
24
  }
25
- /** Convertit un FormData en payload Prisma, en ignorant les champs auto-gérés. */
25
+ /**
26
+ * Convertit un FormData en payload Prisma, en ignorant les champs auto-gérés.
27
+ *
28
+ * Renvoie aussi la liste des champs dont la valeur soumise ne se convertit pas
29
+ * vers le type de la colonne : un JSON illisible, un nombre qui n'en est pas
30
+ * un. Leur clé est laissée HORS du payload, et c'est `mutations.ts` — le seul
31
+ * producteur de refus côté bibliothèque, cf. `errors.ts` — qui décide quoi en
32
+ * faire. Cette fonction ne lève pas : elle convertit et rapporte.
33
+ *
34
+ * Ces valeurs étaient auparavant écrites en `null` sans un mot. Sur une colonne
35
+ * nullable, la saisie et la valeur déjà stockée disparaissaient ensemble
36
+ * derrière un `303` d'apparence réussie ; sur une colonne obligatoire, le
37
+ * pilote répondait par un message générique ne nommant aucun champ.
38
+ */
26
39
  export function formDataToPrisma(formData, model) {
27
40
  const data = {};
41
+ const invalid = [];
28
42
  for (const field of model.fields) {
29
43
  if (field.isId || field.isUpdatedAt || field.isCreatedAt || field.relation)
30
44
  continue;
@@ -36,13 +50,23 @@ export function formDataToPrisma(formData, model) {
36
50
  }
37
51
  switch (field.type) {
38
52
  case 'Int':
39
- case 'BigInt':
40
- data[field.name] = value ? parseInt(value.toString()) : null;
53
+ case 'BigInt': {
54
+ const parsed = value ? parseInt(value.toString()) : null;
55
+ if (Number.isNaN(parsed))
56
+ invalid.push(field.name);
57
+ else
58
+ data[field.name] = parsed;
41
59
  break;
60
+ }
42
61
  case 'Float':
43
- case 'Decimal':
44
- data[field.name] = value ? parseFloat(value.toString()) : null;
62
+ case 'Decimal': {
63
+ const parsed = value ? parseFloat(value.toString()) : null;
64
+ if (Number.isNaN(parsed))
65
+ invalid.push(field.name);
66
+ else
67
+ data[field.name] = parsed;
45
68
  break;
69
+ }
46
70
  case 'Boolean':
47
71
  data[field.name] = value === 'on' || value === 'true' || value === '1';
48
72
  break;
@@ -54,14 +78,21 @@ export function formDataToPrisma(formData, model) {
54
78
  data[field.name] = value ? JSON.parse(value.toString()) : null;
55
79
  }
56
80
  catch {
57
- data[field.name] = null;
81
+ invalid.push(field.name);
58
82
  }
59
83
  break;
60
84
  default:
61
- data[field.name] = value.toString();
85
+ // Le vide vaut `null`, comme pour les types au-dessus. Écrire `''`
86
+ // rendait une chaîne vide indistinguable d'une valeur voulue, violait
87
+ // une colonne `String? @unique` dès la deuxième ligne vidée, et
88
+ // n'était de toute façon pas une valeur qu'un type enum déclare.
89
+ // Un champ ABSENT du formulaire ne passe pas par ici (`continue`
90
+ // au-dessus) : c'est cette distinction que `mutations.ts` exploite
91
+ // pour séparer « non soumis » de « vidé ».
92
+ data[field.name] = value.toString() || null;
62
93
  }
63
94
  }
64
- return data;
95
+ return { data, invalid };
65
96
  }
66
97
  /**
67
98
  * Traduit le paramètre `?page=` en fenêtre Prisma. Toute entrée qui n'est pas un
@@ -153,7 +153,42 @@ export interface AdminHandlerConfig {
153
153
  * doit échouer fort plutôt que produire un filtre silencieusement mort.
154
154
  */
155
155
  listFilter?: import('./query/filterDetection.js').ListFilterConfigEntry[];
156
+ /**
157
+ * Ordre d'arrivée sur la liste, avant tout `?sort=` dans l'URL. `dir` vaut
158
+ * `'asc'` par défaut.
159
+ *
160
+ * Volontairement explicite plutôt qu'auto-détecté : deviner « trie par
161
+ * `name` s'il y en a un » changerait l'ordre de toutes les listes
162
+ * existantes sans que personne l'ait demandé, et l'heuristique dériverait
163
+ * de ce que la vue affiche réellement.
164
+ *
165
+ * `field` doit être une colonne que la liste AFFICHE — sinon aucun en-tête
166
+ * ne peut annoncer le tri ni permettre d'en sortir. Validé au démarrage :
167
+ * une colonne inexistante ou non affichée lève, plutôt que de produire un
168
+ * tri mort à chaque rendu.
169
+ */
170
+ defaultSort?: {
171
+ field: string;
172
+ dir?: 'asc' | 'desc';
173
+ };
156
174
  }>;
175
+ /**
176
+ * Lignes par page de la vue liste (défaut : 20). Entier de 1 à 200 : au-delà
177
+ * ce n'est plus une page, c'est un export — et une requête qui tient la
178
+ * connexion sur une table volumineuse. Validé au démarrage.
179
+ */
180
+ perPage?: number;
181
+ /**
182
+ * Tailles de page qu'un visiteur peut choisir (défaut : `[10, 20, 50, 100]`).
183
+ * `perPage` y est ajouté d'office s'il n'y figure pas, sinon la taille active
184
+ * n'apparaîtrait pas dans le sélecteur.
185
+ *
186
+ * Un `?perPage=` n'est honoré que s'il appartient à cette liste : sans cette
187
+ * règle, `?perPage=100000` est un `take` non borné, donc un déni de service à
188
+ * un paramètre près. `[]` désactive entièrement le mécanisme — aucun
189
+ * sélecteur rendu, `?perPage=` sans effet.
190
+ */
191
+ pageSizeOptions?: number[];
157
192
  /** Models to exclude from admin */
158
193
  exclude?: string[];
159
194
  /** Hide pivot/junction tables automatically (default: true) */
@@ -8,6 +8,9 @@ import { paginate, coerceId } from './data.js';
8
8
  import { parseListQuery, buildWhere, resolveSearchFields } from './query/listQuery.js';
9
9
  import { normalizeScope } from './adapters/filter.js';
10
10
  import { resolveListFilters } from './query/filterDetection.js';
11
+ import { resolveListColumns } from './query/listColumns.js';
12
+ import { parseSortQuery } from './query/sortQuery.js';
13
+ import { parsePageSize } from './query/pageSize.js';
11
14
  import { escapeHtml, toLabel } from './views/html.js';
12
15
  import NotFound from './views/NotFound.svelte';
13
16
  import Layout from './views/Layout.svelte';
@@ -20,6 +23,7 @@ import { handleSearch } from './search.js';
20
23
  import { handleMutation } from './mutations.js';
21
24
  import { AdminMutationError, AdminConfigError } from './errors.js';
22
25
  import { verifyOrigin, resolveCsrfConfig } from './csrf.js';
26
+ import { readSubmittedForm } from './submitted.js';
23
27
  import { resolvePluginRegistry, actionsForModel } from './pluginRegistry.js';
24
28
  import { createPluginPageContext } from './pluginAccess.js';
25
29
  // ============================================
@@ -95,7 +99,9 @@ export function createAdminHandler(config) {
95
99
  let currentModel;
96
100
  let extraStyles = '';
97
101
  let extraScripts = '';
102
+ /** Renseignés ensemble : un formulaire re-rendu porte toujours les deux. */
98
103
  let mutationError;
104
+ let submitted;
99
105
  try {
100
106
  // Handle POST requests (create, update, delete). Unrecognised actions fall
101
107
  // through to the GET rendering below, as they always have.
@@ -105,26 +111,29 @@ export function createAdminHandler(config) {
105
111
  // field, never `route.view`, so it cannot be confused by a plugin's
106
112
  // view id landing here.
107
113
  if (event.request.method === 'POST') {
114
+ // Lu ici et non dans `handleMutation` : un corps de requête ne se lit
115
+ // qu'une fois, et il faut le garder pour re-rendre le formulaire si la
116
+ // mutation échoue.
117
+ const formData = await event.request.formData();
108
118
  // `try` propre au chemin de mutation, et non le `catch` partagé plus
109
119
  // bas : celui-ci couvre aussi le rendu GET et les pages de plugin,
110
120
  // dont le contrat (rendre le message levé) ne change pas ici.
111
121
  try {
112
- const mutationResponse = await handleMutation(runtime, event, route);
122
+ const mutationResponse = await handleMutation(runtime, event, route, formData);
113
123
  if (mutationResponse)
114
124
  return mutationResponse;
115
125
  }
116
126
  catch (e) {
117
- // Plus de classification ici : `handleMutation` a déjà consommé le
118
- // corps de la requête, donc ce site ne peut plus lire `_action` et
119
- // ne saurait pas distinguer une création d'une suppression. La
120
- // classification par code pilote se fait désormais dans
121
- // `mutations.ts`, au site d'appel qui connaît l'action réelle
122
- // ce `catch` ne fait plus qu'un aiguillage à trois branches sur le
123
- // type de l'erreur déjà classée.
127
+ // Pas de classification ici, même si `formData` est maintenant à
128
+ // portée : ce site verrait bien `_action`, mais `reference` et
129
+ // `restrict` se distinguent par l'écriture qui a effectivement
130
+ // échoué, pas par l'action demandée. Seul `mutations.ts` le sait,
131
+ // au site d'appel de chaque `*Record`. Ce `catch` n'est donc qu'un
132
+ // aiguillage à trois branches sur le type de l'erreur déjà classée.
124
133
  if (e instanceof AdminMutationError) {
125
134
  // Message construit par la bibliothèque (validation, scope, ou
126
135
  // code pilote reconnu) : sûr à rendre tel quel.
127
- mutationError = e.message;
136
+ mutationError = { message: e.message, field: e.field };
128
137
  }
129
138
  else if (e instanceof AdminConfigError) {
130
139
  // Mauvaise configuration côté consommateur : message écrit par la
@@ -136,8 +145,20 @@ export function createAdminHandler(config) {
136
145
  // Tout le reste est présumé venir du moteur : son texte peut porter le nom
137
146
  // de la table, un fragment de requête ou un dump d'arguments. Jamais rendu.
138
147
  console.error('[sveltekit-admin] mutation failed:', e);
139
- mutationError = 'The change could not be saved.';
148
+ mutationError = { message: 'The change could not be saved.' };
140
149
  }
150
+ // Placé après le `throw` de la branche AdminConfigError : une erreur
151
+ // de montage remplace la page entière (contrat inchangé) et ne
152
+ // re-rend aucun formulaire, donc rien à conserver pour elle.
153
+ //
154
+ // `findModel` peut ne rien rendre — un POST sur un modèle inconnu est
155
+ // justement ce que la branche `notFound` de `handleMutation` lève. Ce
156
+ // cas ne rend aucun formulaire, la liste de champs masqués y est donc
157
+ // sans objet. Pas de garde sur `route.model` en revanche : sans
158
+ // modèle, `handleMutation` rend `null` sans lever et ce `catch` n'est
159
+ // pas atteint.
160
+ const errorModel = runtime.findModel(route.model);
161
+ submitted = readSubmittedForm(formData, errorModel ? runtime.hiddenFieldsOf(errorModel) : new Set());
141
162
  }
142
163
  }
143
164
  // GET requests - render views
@@ -220,7 +241,8 @@ export function createAdminHandler(config) {
220
241
  }
221
242
  else if (route.view === 'list') {
222
243
  const modelsConfig = runtime.config.models ?? {};
223
- const { page } = paginate(event.url.searchParams.get('page'), runtime.perPage);
244
+ const perPage = parsePageSize(event.url.searchParams, runtime.perPage, runtime.pageSizes);
245
+ const { page } = paginate(event.url.searchParams.get('page'), perPage);
224
246
  const modelSearchConfig = modelsConfig[model.name]?.searchFields;
225
247
  const searchFields = resolveSearchFields(model, modelSearchConfig, runtime.labelFieldCandidates, runtime.hiddenFieldsOf(model));
226
248
  const filterableFields = runtime.resolveFilterableFields(model);
@@ -232,7 +254,20 @@ export function createAdminHandler(config) {
232
254
  : modelScope ?? listScope;
233
255
  // Adapter compiles case-sensitivity; this arg is unused by buildWhere.
234
256
  const filter = buildWhere(listQuery, scope, false, model);
235
- const { rows: items, total } = await runtime.adapter.data.listRecords(model, { filter, skip: (page - 1) * runtime.perPage, take: runtime.perPage });
257
+ // Whitelist de tri = colonnes RÉELLEMENT rendues, résolues par la même
258
+ // fonction que la vue et à partir de la même config. Un `?sort=` ne
259
+ // peut donc ordonner que des valeurs déjà lisibles à l'écran.
260
+ const sortableColumns = resolveListColumns(model.fields, {
261
+ hidden: modelsConfig[model.name]?.hidden,
262
+ listFields: modelsConfig[model.name]?.listFields
263
+ }).map((f) => f.name);
264
+ const sort = parseSortQuery(event.url.searchParams, sortableColumns, runtime.defaultSortOf(model));
265
+ const { rows: items, total } = await runtime.adapter.data.listRecords(model, {
266
+ filter,
267
+ skip: (page - 1) * perPage,
268
+ take: perPage,
269
+ orderBy: sort.active ?? undefined
270
+ });
236
271
  const listFilters = resolveListFilters(model, runtime.schema.enums, modelsConfig[model.name]?.listFilter, toLabel, runtime.relationGraph, runtime.hiddenFieldsOf(model), runtime.config.listFilterDefaults?.autoDetect ?? true);
237
272
  // Un filtre FK ne dépend pas de l'autre : résolus en parallèle
238
273
  // plutôt qu'un par un (même raisonnement que loadRelationOptions).
@@ -248,13 +283,15 @@ export function createAdminHandler(config) {
248
283
  props: {
249
284
  model: runtime.viewModel(model),
250
285
  items,
251
- pagination: { page, perPage: runtime.perPage, total },
286
+ pagination: { page, perPage, total },
287
+ pageSizes: runtime.pageSizes,
252
288
  basePath: runtime.basePath,
253
289
  config: runtime.config,
254
290
  query: listQuery,
255
291
  currentUrl: event.url,
256
292
  listFilters,
257
293
  fkFilterMeta,
294
+ sort,
258
295
  recordActions: actionsForModel(registry, model.name).map((action) => ({
259
296
  label: action.label,
260
297
  hrefFor: (id) => action.href({ model: model.name, id, basePath: runtime.basePath })
@@ -283,7 +320,9 @@ export function createAdminHandler(config) {
283
320
  basePath: runtime.basePath,
284
321
  config: runtime.config,
285
322
  item: itemPrefill,
286
- recordActions: []
323
+ recordActions: [],
324
+ submitted,
325
+ mutationError
287
326
  }
288
327
  }).body;
289
328
  }
@@ -313,6 +352,8 @@ export function createAdminHandler(config) {
313
352
  basePath: runtime.basePath,
314
353
  config: runtime.config,
315
354
  item,
355
+ submitted,
356
+ mutationError,
316
357
  recordActions: actionsForModel(registry, model.name).map((action) => ({
317
358
  label: action.label,
318
359
  href: action.href({
@@ -338,7 +379,7 @@ export function createAdminHandler(config) {
338
379
  // convention d'alerte dans toute la page (cf. handler.test.ts, l'alerte
339
380
  // « modèle inconnu en POST »).
340
381
  content =
341
- `<div class="ska-alert ska-alert--error">Error: ${escapeHtml(mutationError)}</div>` +
382
+ `<div class="ska-alert ska-alert--error">Error: ${escapeHtml(mutationError.message)}</div>` +
342
383
  content;
343
384
  }
344
385
  const html = render(Layout, {
@@ -352,6 +393,17 @@ export function createAdminHandler(config) {
352
393
  }
353
394
  }).body;
354
395
  return new Response(html, {
396
+ // 422 pour un formulaire refusé : la requête était bien formée, son
397
+ // contenu a été rejeté. Un 200 fait passer l'échec pour un succès aux
398
+ // yeux de tout ce qui lit le statut sans lire le HTML — un test, un
399
+ // client, un log, un cache. Uniforme sur tous les `kind` : la nuance
400
+ // entre 409 (conflit) et 403 (scope) n'apporterait rien à un formulaire
401
+ // rendu en HTML, et le seul consommateur est un navigateur qui affiche
402
+ // la page.
403
+ //
404
+ // Le `catch` partagé plus haut garde son 200 : il couvre aussi le rendu
405
+ // GET et les pages de plugin, dont le contrat ne change pas ici.
406
+ status: mutationError ? 422 : 200,
355
407
  headers: {
356
408
  'Content-Type': 'text/html; charset=utf-8'
357
409
  }
@@ -16,6 +16,21 @@ export declare function parseSchemaContent(content: string): PrismaSchema;
16
16
  * cherchable/filtrable par URL forgée.
17
17
  */
18
18
  export declare function isSensitiveFieldName(name: string): boolean;
19
+ /**
20
+ * Restreint `isSensitiveFieldName` aux colonnes de texte, pour le chemin
21
+ * formulaire/écriture.
22
+ *
23
+ * Le prédicat de nom matche par sous-chaîne : sans le filtre de type, un
24
+ * `tokenCount` ou un `hashtagCount` (`Int`) serait traité comme un secret et
25
+ * deviendrait impossible à éditer dans l'admin. Un mot de passe, un secret,
26
+ * un token sont du texte ; un compteur ne l'est pas.
27
+ *
28
+ * Volontairement plus étroit que `isSensitiveFieldName` seul, qui reste le
29
+ * prédicat utilisé pour l'affichage, la recherche, les filtres et l'audit :
30
+ * là, ne pas montrer un `tokenCount` est sans conséquence, alors qu'ici ça
31
+ * retirerait une capacité.
32
+ */
33
+ export declare function isSensitiveStringField(field: Pick<PrismaField, 'name' | 'type'>): boolean;
19
34
  /**
20
35
  * Get display fields for a model (fields suitable for list view)
21
36
  */
@@ -193,6 +193,23 @@ export function isSensitiveFieldName(name) {
193
193
  const lower = name.toLowerCase();
194
194
  return SENSITIVE_FIELD_NAMES.some((hidden) => lower.includes(hidden));
195
195
  }
196
+ /**
197
+ * Restreint `isSensitiveFieldName` aux colonnes de texte, pour le chemin
198
+ * formulaire/écriture.
199
+ *
200
+ * Le prédicat de nom matche par sous-chaîne : sans le filtre de type, un
201
+ * `tokenCount` ou un `hashtagCount` (`Int`) serait traité comme un secret et
202
+ * deviendrait impossible à éditer dans l'admin. Un mot de passe, un secret,
203
+ * un token sont du texte ; un compteur ne l'est pas.
204
+ *
205
+ * Volontairement plus étroit que `isSensitiveFieldName` seul, qui reste le
206
+ * prédicat utilisé pour l'affichage, la recherche, les filtres et l'audit :
207
+ * là, ne pas montrer un `tokenCount` est sans conséquence, alors qu'ici ça
208
+ * retirerait une capacité.
209
+ */
210
+ export function isSensitiveStringField(field) {
211
+ return field.type === 'String' && isSensitiveFieldName(field.name);
212
+ }
196
213
  /**
197
214
  * Get display fields for a model (fields suitable for list view)
198
215
  */
@@ -1,9 +1,13 @@
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 type { ParsedRoute } from './router.js';
8
12
  import { type AdminRuntime } from './runtime.js';
9
- export declare function handleMutation(runtime: AdminRuntime, event: any, route: ParsedRoute): Promise<Response | null>;
13
+ export declare function handleMutation(runtime: AdminRuntime, event: any, route: ParsedRoute, formData: FormData): Promise<Response | null>;