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.
- package/dist/server/adapters/drizzle/dataAdapter.js +76 -7
- package/dist/server/adapters/prisma/dataAdapter.js +19 -1
- package/dist/server/adapters/types.d.ts +33 -1
- package/dist/server/data.d.ts +18 -2
- package/dist/server/data.js +39 -8
- package/dist/server/handler.d.ts +35 -0
- package/dist/server/handler.js +67 -15
- package/dist/server/introspection/parser.d.ts +15 -0
- package/dist/server/introspection/parser.js +17 -0
- package/dist/server/mutations.d.ts +8 -4
- package/dist/server/mutations.js +203 -22
- package/dist/server/query/listColumns.d.ts +19 -0
- package/dist/server/query/listColumns.js +43 -0
- package/dist/server/query/pageSize.d.ts +19 -0
- package/dist/server/query/pageSize.js +26 -0
- package/dist/server/query/sortQuery.d.ts +31 -0
- package/dist/server/query/sortQuery.js +33 -0
- package/dist/server/query/urls.js +9 -1
- package/dist/server/runtime.d.ts +5 -0
- package/dist/server/runtime.js +54 -1
- package/dist/server/submitted.d.ts +20 -0
- package/dist/server/submitted.js +54 -0
- package/dist/server/views/FieldInput.svelte +114 -8
- package/dist/server/views/FieldInput.svelte.d.ts +7 -0
- package/dist/server/views/Form.svelte +71 -4
- package/dist/server/views/Form.svelte.d.ts +17 -0
- package/dist/server/views/Layout.svelte +5 -2
- package/dist/server/views/List.svelte +128 -23
- package/dist/server/views/List.svelte.d.ts +5 -0
- package/dist/server/views/RelationCheckboxes.svelte +26 -5
- package/dist/server/views/RelationCheckboxes.svelte.d.ts +9 -0
- package/dist/server/views/RelationSelect.svelte +14 -3
- package/dist/server/views/RelationSelect.svelte.d.ts +2 -0
- package/dist/server/views/html.d.ts +19 -0
- package/dist/server/views/html.js +25 -0
- package/dist/server/views/pagination.d.ts +9 -0
- package/dist/server/views/pagination.js +31 -0
- package/dist/server/views/theme.js +147 -3
- package/dist/server/views/types.d.ts +7 -0
- 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(
|
|
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 =
|
|
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
|
|
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
|
-
/**
|
|
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
|
}
|
package/dist/server/data.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
20
|
-
|
|
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`
|
package/dist/server/data.js
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
81
|
+
invalid.push(field.name);
|
|
58
82
|
}
|
|
59
83
|
break;
|
|
60
84
|
default:
|
|
61
|
-
|
|
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
|
package/dist/server/handler.d.ts
CHANGED
|
@@ -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) */
|
package/dist/server/handler.js
CHANGED
|
@@ -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
|
-
//
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
//
|
|
122
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
|
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`.
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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>;
|