sveltekit-admin 0.5.0 → 0.6.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 (41) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +32 -1
  3. package/dist/index.d.ts +3 -0
  4. package/dist/index.js +1 -0
  5. package/dist/server/adapters/drizzle/dataAdapter.d.ts +11 -0
  6. package/dist/server/adapters/drizzle/dataAdapter.js +243 -0
  7. package/dist/server/adapters/drizzle/filterCompiler.d.ts +8 -0
  8. package/dist/server/adapters/drizzle/filterCompiler.js +61 -0
  9. package/dist/server/adapters/drizzle/index.d.ts +13 -0
  10. package/dist/server/adapters/drizzle/index.js +23 -0
  11. package/dist/server/adapters/drizzle/inspect.d.ts +23 -0
  12. package/dist/server/adapters/drizzle/inspect.js +220 -0
  13. package/dist/server/adapters/drizzle/introspector.d.ts +3 -0
  14. package/dist/server/adapters/drizzle/introspector.js +3 -0
  15. package/dist/server/adapters/filter.d.ts +12 -0
  16. package/dist/server/adapters/filter.js +72 -0
  17. package/dist/server/adapters/prisma/dataAdapter.d.ts +13 -0
  18. package/dist/server/adapters/prisma/dataAdapter.js +96 -0
  19. package/dist/server/adapters/prisma/filterCompiler.d.ts +9 -0
  20. package/dist/server/adapters/prisma/filterCompiler.js +42 -0
  21. package/dist/server/adapters/prisma/index.d.ts +28 -0
  22. package/dist/server/adapters/prisma/index.js +32 -0
  23. package/dist/server/adapters/prisma/introspector.d.ts +4 -0
  24. package/dist/server/adapters/prisma/introspector.js +9 -0
  25. package/dist/server/adapters/types.d.ts +79 -0
  26. package/dist/server/adapters/types.js +1 -0
  27. package/dist/server/data.d.ts +0 -8
  28. package/dist/server/data.js +0 -41
  29. package/dist/server/handler.d.ts +18 -2
  30. package/dist/server/handler.js +170 -134
  31. package/dist/server/introspection/parser.d.ts +4 -40
  32. package/dist/server/introspection/relations.d.ts +1 -1
  33. package/dist/server/introspection/relations.js +1 -1
  34. package/dist/server/query/listQuery.d.ts +10 -13
  35. package/dist/server/query/listQuery.js +47 -55
  36. package/dist/server/types/schema.d.ts +40 -0
  37. package/dist/server/types/schema.js +8 -0
  38. package/dist/server/views/Form.svelte +1 -1
  39. package/dist/server/views/RelationCheckboxes.svelte +1 -1
  40. package/dist/server/views/theme.js +72 -19
  41. package/package.json +24 -3
@@ -73,44 +73,3 @@ export function paginate(pageParam, perPage) {
73
73
  const page = Number.isSafeInteger(parsed) && parsed >= 1 ? parsed : 1;
74
74
  return { page, skip: (page - 1) * perPage, take: perPage };
75
75
  }
76
- export async function listRecords(prisma, model, page, perPage, where) {
77
- const key = toPrismaModel(model.name);
78
- const primaryKey = primaryKeyOf(model);
79
- // Le calcul `(page - 1) * perPage` est dupliqué avec `paginate` DÉLIBÉRÉMENT.
80
- // Router `listRecords` vers `paginate(String(page), perPage)` serait lossy : la
81
- // conversion aller-retour passe par `parseInt`, et `String(1e24)` donne
82
- // `'1e+24'`, que `parseInt` ramène à `1`. `listRecords` reçoit un `number` déjà
83
- // validé par l'appelant ; ne pas « simplifier » en réintroduisant un parse.
84
- const [items, total] = await Promise.all([
85
- prisma[key].findMany({
86
- where,
87
- skip: (page - 1) * perPage,
88
- take: perPage,
89
- orderBy: { [primaryKey]: 'desc' }
90
- }),
91
- prisma[key].count({ where })
92
- ]);
93
- return { items, total };
94
- }
95
- export function getRecord(prisma, model, id) {
96
- const primaryKey = primaryKeyOf(model);
97
- return prisma[toPrismaModel(model.name)].findUnique({
98
- where: { [primaryKey]: coerceId(id, model) }
99
- });
100
- }
101
- export async function createRecord(prisma, model, data) {
102
- await prisma[toPrismaModel(model.name)].create({ data });
103
- }
104
- export async function updateRecord(prisma, model, id, data) {
105
- const primaryKey = primaryKeyOf(model);
106
- await prisma[toPrismaModel(model.name)].update({
107
- where: { [primaryKey]: coerceId(id, model) },
108
- data
109
- });
110
- }
111
- export async function deleteRecord(prisma, model, id) {
112
- const primaryKey = primaryKeyOf(model);
113
- await prisma[toPrismaModel(model.name)].delete({
114
- where: { [primaryKey]: coerceId(id, model) }
115
- });
116
- }
@@ -2,11 +2,27 @@
2
2
  * SvelteKit Admin - Standalone Handler
3
3
  * Zero files needed in routes - everything handled via hook
4
4
  */
5
+ import type { DataAdapter, SchemaIntrospector } from './adapters/types.js';
5
6
  export interface AdminHandlerConfig {
6
- /** Prisma client instance */
7
- prisma: any;
7
+ /**
8
+ * Prisma client instance. Required unless `adapter` is provided directly —
9
+ * exactly one of the two must be set. Kept required-looking here (not `?`)
10
+ * for source compatibility with every existing call site; passing neither
11
+ * throws at handler-creation time (see the boot block).
12
+ */
13
+ prisma?: any;
8
14
  /** Path to Prisma schema file */
9
15
  prismaSchemaPath?: string;
16
+ /**
17
+ * Explicit adapter, built via `createPrismaAdapter(...)` (or, in a future
18
+ * release, a Drizzle/other adapter). Takes priority over `prisma`/
19
+ * `prismaSchemaPath` when both are somehow set. Most consumers never touch
20
+ * this — passing `prisma`/`prismaSchemaPath` builds one internally.
21
+ */
22
+ adapter?: {
23
+ introspector: SchemaIntrospector;
24
+ data: DataAdapter;
25
+ };
10
26
  /** Base path for admin routes (default: /admin) */
11
27
  basePath?: string;
12
28
  /** Authentication check - return true if user can access admin */
@@ -3,11 +3,15 @@
3
3
  * Zero files needed in routes - everything handled via hook
4
4
  */
5
5
  import { render } from 'svelte/server';
6
- import { parsePrismaSchema, isSensitiveFieldName } from './introspection/parser.js';
6
+ import { isSensitiveFieldName } from './introspection/parser.js';
7
7
  import { buildRelationGraph } from './introspection/relations.js';
8
8
  import { parseRoute } from './router.js';
9
- import { primaryKeyOf, toPrismaModel, coerceId, formDataToPrisma, paginate, listRecords, getRecord, createRecord, updateRecord, deleteRecord } from './data.js';
9
+ import { primaryKeyOf, coerceId, formDataToPrisma, paginate } from './data.js';
10
10
  import { parseListQuery, buildWhere, resolveSearchFields } from './query/listQuery.js';
11
+ import { createPrismaIntrospector } from './adapters/prisma/introspector.js';
12
+ import { createPrismaDataAdapter } from './adapters/prisma/dataAdapter.js';
13
+ import { resolveCaseInsensitiveSearch } from './adapters/prisma/index.js';
14
+ import { normalizeScope, OPAQUE_FILTER_ERROR } from './adapters/filter.js';
11
15
  import { resolveListFilters, validateListFilterConfig, findFkEdge } from './query/filterDetection.js';
12
16
  import { escapeHtml, toLabel } from './views/html.js';
13
17
  import NotFound from './views/NotFound.svelte';
@@ -16,23 +20,37 @@ import Dashboard from './views/Dashboard.svelte';
16
20
  import Form from './views/Form.svelte';
17
21
  import List from './views/List.svelte';
18
22
  const PER_PAGE = 20;
23
+ function scopeFrom(relConfig, ctx) {
24
+ return relConfig?.where ? normalizeScope(relConfig.where(ctx)) : undefined;
25
+ }
19
26
  // ============================================
20
27
  // Main Handler
21
28
  // ============================================
22
29
  export function createAdminHandler(config) {
23
30
  const { prisma, prismaSchemaPath = './prisma/schema.prisma', basePath = '/admin', authCheck, logout, logoutRedirectTo = '/', exclude = [], hidePivotTables = true, models: modelsConfig = {} } = config;
24
- // Parse schema once at startup
31
+ if (!config.adapter && !prisma) {
32
+ throw new Error('[sveltekit-admin] createAdminHandler requires either `prisma` (with optional `prismaSchemaPath`) or `adapter` — neither was provided.');
33
+ }
34
+ const introspector = config.adapter?.introspector ?? createPrismaIntrospector({ schemaPath: prismaSchemaPath });
35
+ // Introspect the schema once at startup — same failure handling as before:
36
+ // a broken/missing schema source degrades to "no models known" rather than
37
+ // throwing out of `createAdminHandler` itself.
25
38
  let schema = null;
26
39
  let relationGraph = null;
27
40
  try {
28
- schema = parsePrismaSchema(prismaSchemaPath);
41
+ const introspected = introspector.introspect();
42
+ if (introspected instanceof Promise) {
43
+ throw new Error('[sveltekit-admin] SchemaIntrospector.introspect() returned a Promise — ' +
44
+ 'createAdminHandler only supports synchronous introspection today.');
45
+ }
46
+ schema = introspected;
29
47
  relationGraph = buildRelationGraph(schema);
30
48
  for (const d of relationGraph.diagnostics) {
31
49
  console.warn(`[sveltekit-admin] ${d}`);
32
50
  }
33
51
  }
34
52
  catch (e) {
35
- console.warn('[sveltekit-admin] Could not parse Prisma schema:', e);
53
+ console.warn('[sveltekit-admin] Could not introspect schema:', e);
36
54
  }
37
55
  const filteredModels = schema?.models.filter((m) => {
38
56
  // Exclude explicitly excluded models
@@ -56,7 +74,13 @@ export function createAdminHandler(config) {
56
74
  if (entries)
57
75
  validateListFilterConfig(m.name, entries, m, relationGraph, hiddenFieldsOf(m));
58
76
  }
59
- const labelOf = (m) => modelsConfig[m.name]?.label || toLabel(m.name);
77
+ const labelOf = (m) => {
78
+ const configured = modelsConfig[m.name]?.label;
79
+ if (configured)
80
+ return configured;
81
+ const label = toLabel(m.name);
82
+ return label.charAt(0).toUpperCase() + label.slice(1);
83
+ };
60
84
  const modelList = filteredModels.map((m) => ({ name: m.name, label: labelOf(m) }));
61
85
  const findModel = (name) => filteredModels.find((m) => m.name.toLowerCase() === name?.toLowerCase());
62
86
  const viewModel = (m) => ({
@@ -82,10 +106,8 @@ export function createAdminHandler(config) {
82
106
  // lève une erreur Prisma dure. Détection auto via le provider extrait du
83
107
  // schéma ; `search.mode` permet de forcer le comportement (provider non
84
108
  // littéral dans le schéma, index citext, etc.). Voir docs/design §2.5.
85
- const searchMode = config.search?.mode ?? 'auto';
86
- const caseInsensitiveSearch = searchMode === 'insensitive' ||
87
- (searchMode === 'auto' &&
88
- ['postgresql', 'cockroachdb', 'mongodb'].includes(schema?.provider ?? ''));
109
+ const caseInsensitiveSearch = resolveCaseInsensitiveSearch(schema, config.search?.mode);
110
+ const adapter = config.adapter ?? { introspector, data: createPrismaDataAdapter(prisma, { caseInsensitiveSearch }) };
89
111
  /**
90
112
  * Champs qu'un `?f.<field>=` est autorisé à cibler pour ce modèle : tout
91
113
  * champ scalaire non-liste, non-relation, de type filtrable
@@ -136,54 +158,54 @@ export function createAdminHandler(config) {
136
158
  return String(row[primaryKeyOf(targetModel)]);
137
159
  };
138
160
  /**
139
- * Charge les options pour toutes les arêtes to-one-owning et m2m-implicite
161
+ * Charge les options pour toutes les arêtes to-one-owning et m2m
140
162
  * d'un modèle. Une requête COUNT par relation avant le findMany : évite de
141
163
  * charger 10k lignes pour découvrir qu'il y en a 10k.
142
164
  */
143
165
  const loadRelationOptions = async (model, ctx, currentId) => {
144
- const out = new Map();
145
- for (const edge of relationGraph.edges.values()) {
166
+ const edges = [...relationGraph.edges.values()].filter((edge) => {
146
167
  if (edge.model !== model.name)
147
- continue;
148
- if (edge.kind !== 'to-one-owning' && edge.kind !== 'm2m-implicit')
149
- continue;
168
+ return false;
169
+ if (edge.kind !== 'to-one-owning' && edge.kind !== 'm2m')
170
+ return false;
150
171
  if (edge.unsupported)
151
- continue;
172
+ return false;
173
+ const relConfig = modelsConfig[model.name]?.relations?.[edge.field];
174
+ return relConfig?.widget !== 'hidden';
175
+ });
176
+ // Une relation ne dépend pas de l'autre : chargées en parallèle plutôt
177
+ // qu'en série (un modèle avec N relations ne doit pas payer N
178
+ // aller-retours DB empilés pour afficher un seul formulaire).
179
+ const entries = await Promise.all(edges.map(async (edge) => {
180
+ const key = `${edge.model}.${edge.field}`;
152
181
  const relConfig = modelsConfig[model.name]?.relations?.[edge.field];
153
- if (relConfig?.widget === 'hidden')
154
- continue;
155
182
  const targetModel = schema.models.find((m) => m.name === edge.target);
156
- const where = relConfig?.where ? relConfig.where(ctx) : undefined;
157
- const prismaKey = toPrismaModel(edge.target);
183
+ const filter = scopeFrom(relConfig, ctx);
158
184
  try {
159
- const total = await prisma[prismaKey].count({ where });
185
+ const total = await adapter.data.countRecords(targetModel, filter);
160
186
  if (total > selectThreshold || relConfig?.widget === 'raw-id') {
161
- const selectedIds = edge.kind === 'm2m-implicit' && currentId
162
- ? await loadSelectedIds(model, edge, currentId, targetModel)
187
+ const selectedIds = edge.kind === 'm2m' && currentId
188
+ ? await adapter.data.getM2mSelectedIds(model, edge, targetModel, currentId)
163
189
  : undefined;
164
- out.set(`${edge.model}.${edge.field}`, { tooMany: true, options: [], selectedIds });
165
- continue;
190
+ return [key, { tooMany: true, options: [], selectedIds }];
166
191
  }
167
- const rows = await prisma[prismaKey].findMany({
168
- where,
169
- orderBy: relConfig?.orderBy
170
- });
192
+ const rows = await adapter.data.findMany(targetModel, { filter, orderBy: relConfig?.orderBy });
171
193
  const options = rows.map((row) => ({
172
194
  id: row[primaryKeyOf(targetModel)],
173
195
  label: resolveLabel(targetModel, row, relConfig?.labelTemplate)
174
196
  }));
175
- const selectedIds = edge.kind === 'm2m-implicit' && currentId
176
- ? await loadSelectedIds(model, edge, currentId, targetModel)
197
+ const selectedIds = edge.kind === 'm2m' && currentId
198
+ ? await adapter.data.getM2mSelectedIds(model, edge, targetModel, currentId)
177
199
  : undefined;
178
- out.set(`${edge.model}.${edge.field}`, { tooMany: false, options, selectedIds });
200
+ return [key, { tooMany: false, options, selectedIds }];
179
201
  }
180
202
  catch {
181
203
  // Cible absente de la base ou client incomplet : repli raw-id pour
182
204
  // garder le champ éditable plutôt que de faire échouer tout le form.
183
- out.set(`${edge.model}.${edge.field}`, { tooMany: true, options: [] });
205
+ return [key, { tooMany: true, options: [] }];
184
206
  }
185
- }
186
- return out;
207
+ }));
208
+ return new Map(entries);
187
209
  };
188
210
  /**
189
211
  * Options d'un filtre FK : charge et scope les valeurs possibles pour la
@@ -204,45 +226,46 @@ export function createAdminHandler(config) {
204
226
  // schéma parsé avec succès — une arête ne peut pas cibler un modèle qui
205
227
  // n'existe pas dans `schema.models`.
206
228
  const relConfig = modelsConfig[model.name]?.relations?.[edge.field];
207
- const scope = relConfig?.where ? relConfig.where(ctx) : undefined;
208
- const prismaKey = toPrismaModel(edge.target);
209
- // Options de la sidebar : scopées, comptées puis chargées si sous le seuil.
210
- let options = [];
211
- let tooMany = false;
212
- try {
213
- const total = await prisma[prismaKey].count({ where: scope });
214
- if (total > selectThreshold) {
215
- tooMany = true;
216
- }
217
- else {
218
- const rows = await prisma[prismaKey].findMany({
219
- where: scope,
220
- orderBy: relConfig?.orderBy
221
- });
222
- options = rows.map((row) => ({
229
+ const scope = scopeFrom(relConfig, ctx);
230
+ // Options de la sidebar (comptées puis chargées si sous le seuil) et
231
+ // label du chip actif (§6.3.b) sont deux requêtes indépendantes l'une
232
+ // ne dépend pas du résultat de l'autre — donc en parallèle plutôt qu'en
233
+ // série.
234
+ const loadOptions = async () => {
235
+ try {
236
+ const total = await adapter.data.countRecords(targetModel, scope);
237
+ if (total > selectThreshold) {
238
+ return { options: [], tooMany: true };
239
+ }
240
+ const rows = await adapter.data.findMany(targetModel, { filter: scope, orderBy: relConfig?.orderBy });
241
+ const options = rows.map((row) => ({
223
242
  id: row[primaryKeyOf(targetModel)],
224
243
  label: resolveLabel(targetModel, row, relConfig?.labelTemplate)
225
244
  }));
245
+ return { options, tooMany: false };
226
246
  }
227
- }
228
- catch {
229
- tooMany = true;
230
- }
231
- // Label du chip actif : résolu via findFirst scopé (§6.3.b). Un ID hors
232
- // scope retourne null ici le composant affiche l'ID brut, pas de label.
233
- let activeLabel;
234
- if (activeRawValue !== undefined) {
247
+ catch {
248
+ return { options: [], tooMany: true };
249
+ }
250
+ };
251
+ // Un ID hors scope retourne null ici le composant affiche l'ID brut,
252
+ // jamais le label (sinon c'est un oracle sur le nom d'un enregistrement
253
+ // d'un autre tenant).
254
+ const loadActiveLabel = async () => {
255
+ if (activeRawValue === undefined)
256
+ return undefined;
235
257
  const activeId = coerceId(activeRawValue, targetModel);
236
258
  try {
237
- const row = await prisma[prismaKey].findFirst({
238
- where: scope ? { AND: [{ [primaryKeyOf(targetModel)]: activeId }, scope] } : { [primaryKeyOf(targetModel)]: activeId }
239
- });
240
- activeLabel = row ? resolveLabel(targetModel, row, relConfig?.labelTemplate) : undefined;
259
+ const idFilter = { op: 'eq', field: primaryKeyOf(targetModel), value: activeId };
260
+ const filter = scope ? { op: 'and', clauses: [idFilter, scope] } : idFilter;
261
+ const row = await adapter.data.findFirst(targetModel, filter);
262
+ return row ? resolveLabel(targetModel, row, relConfig?.labelTemplate) : undefined;
241
263
  }
242
264
  catch {
243
- activeLabel = undefined;
265
+ return undefined;
244
266
  }
245
- }
267
+ };
268
+ const [{ options, tooMany }, activeLabel] = await Promise.all([loadOptions(), loadActiveLabel()]);
246
269
  return {
247
270
  field: fkFieldName,
248
271
  label,
@@ -259,20 +282,6 @@ export function createAdminHandler(config) {
259
282
  : undefined
260
283
  };
261
284
  };
262
- /** IDs liés côté N-N implicite, via une requête sur le join field Prisma. */
263
- const loadSelectedIds = async (model, edge, currentId, targetModel) => {
264
- try {
265
- const current = await prisma[toPrismaModel(model.name)].findUnique({
266
- where: { [primaryKeyOf(model)]: coerceId(currentId, model) },
267
- include: { [edge.field]: true }
268
- });
269
- const linked = current?.[edge.field] ?? [];
270
- return linked.map((row) => row[primaryKeyOf(targetModel)]);
271
- }
272
- catch {
273
- return [];
274
- }
275
- };
276
285
  /**
277
286
  * Compte, pour chaque relation inverse (1-N, 1-1) d'un modèle, le nombre
278
287
  * d'enregistrements liés côté cible. Résilient : une cible dont le client
@@ -280,31 +289,33 @@ export function createAdminHandler(config) {
280
289
  * le rendu du formulaire.
281
290
  */
282
291
  const loadRelatedCounts = async (model, currentId) => {
283
- const out = new Map();
284
- for (const edge of relationGraph.edges.values()) {
285
- if (edge.model !== model.name)
286
- continue;
287
- if (edge.kind !== 'to-many-inverse' && edge.kind !== 'to-one-inverse')
288
- continue;
292
+ const edges = [...relationGraph.edges.values()].filter((edge) => edge.model === model.name && (edge.kind === 'to-many-inverse' || edge.kind === 'to-one-inverse'));
293
+ // Un count par relation inverse, indépendants entre eux : en parallèle
294
+ // plutôt qu'empilés un par un (même raisonnement que loadRelationOptions).
295
+ const entries = await Promise.all(edges.map(async (edge) => {
289
296
  const owning = [...relationGraph.edges.values()].find((o) => o.model === edge.target && o.kind === 'to-one-owning' && o.relationName === edge.relationName);
290
297
  if (!owning || owning.unsupported)
291
- continue;
298
+ return undefined;
292
299
  const scalarName = owning.scalarFields[0];
300
+ const key = `${edge.model}.${edge.field}`;
301
+ const targetModel = schema.models.find((m) => m.name === edge.target);
293
302
  try {
294
- const count = await prisma[toPrismaModel(edge.target)].count({
295
- where: { [scalarName]: coerceId(currentId, model) }
303
+ const count = await adapter.data.countRecords(targetModel, {
304
+ op: 'eq',
305
+ field: scalarName,
306
+ value: coerceId(currentId, model)
296
307
  });
297
- out.set(`${edge.model}.${edge.field}`, count);
308
+ return [key, count];
298
309
  }
299
310
  catch {
300
- out.set(`${edge.model}.${edge.field}`, 0);
311
+ return [key, 0];
301
312
  }
302
- }
303
- return out;
313
+ }));
314
+ return new Map(entries.filter((e) => e !== undefined));
304
315
  };
305
316
  /**
306
317
  * Endpoint de recherche `GET {basePath}/_search?rel=Model.field&q=...&page=N`.
307
- * Sert les options d'une relation to-one-owning ou m2m-implicite en JSON
318
+ * Sert les options d'une relation to-one-owning ou m2m en JSON
308
319
  * paginé — la voie prévue pour un futur widget autocomplete côté client
309
320
  * quand le nombre d'options dépasse `selectThreshold`. Respecte le `where`
310
321
  * de scoping configuré sur la relation, comme le select et la validation
@@ -319,7 +330,7 @@ export function createAdminHandler(config) {
319
330
  const edge = model && relationGraph
320
331
  ? relationGraph.edges.get(`${model.name}.${fieldName}`)
321
332
  : undefined;
322
- if (!model || !edge || (edge.kind !== 'to-one-owning' && edge.kind !== 'm2m-implicit') || edge.unsupported) {
333
+ if (!model || !edge || (edge.kind !== 'to-one-owning' && edge.kind !== 'm2m') || edge.unsupported) {
323
334
  return new Response(JSON.stringify({ error: 'unknown relation' }), {
324
335
  status: 404,
325
336
  headers: { 'Content-Type': 'application/json' }
@@ -327,23 +338,33 @@ export function createAdminHandler(config) {
327
338
  }
328
339
  const targetModel = schema.models.find((m) => m.name === edge.target);
329
340
  const relConfig = modelsConfig[model.name]?.relations?.[edge.field];
330
- const configWhere = relConfig?.where ? relConfig.where({ locals: event.locals }) : {};
341
+ const configWhere = scopeFrom(relConfig, { locals: event.locals });
331
342
  // Recherche sur le premier champ String candidat du modèle cible — le
332
343
  // même champ que celui utilisé pour construire le label par défaut.
333
344
  const searchField = labelFieldCandidates.find((c) => targetModel.fields.some((f) => f.name === c && f.type === 'String'));
334
- const where = {
335
- ...configWhere,
336
- ...(q && searchField ? { [searchField]: { contains: q } } : {})
337
- };
338
- const prismaKey = toPrismaModel(edge.target);
345
+ // `_search` must stay case-sensitive on every adapter/provider. A
346
+ // `{ op: 'contains' }` leaf would pick up the adapter-wide
347
+ // `caseInsensitiveSearch` flag (Prisma `mode: 'insensitive'`, Drizzle
348
+ // `ilike`). `containsExact` compiles to `{ contains }` / `LIKE` with
349
+ // no case-folding — same observable Prisma behavior as the previous
350
+ // opaque `{ [field]: { contains: q } }` pass-through.
351
+ const containsFilter = q && searchField
352
+ ? { op: 'containsExact', field: searchField, value: q }
353
+ : undefined;
354
+ const searchFilter = containsFilter && configWhere
355
+ ? { op: 'and', clauses: [configWhere, containsFilter] }
356
+ : containsFilter ?? configWhere;
339
357
  try {
358
+ // Count + fetch are independent reads — run them in parallel (as this
359
+ // endpoint always has, pre-refactor) rather than doubling latency with
360
+ // two sequential awaits.
340
361
  const [total, rows] = await Promise.all([
341
- prisma[prismaKey].count({ where }),
342
- prisma[prismaKey].findMany({
343
- where,
362
+ adapter.data.countRecords(targetModel, searchFilter),
363
+ adapter.data.findMany(targetModel, {
364
+ filter: searchFilter,
365
+ orderBy: relConfig?.orderBy,
344
366
  skip: (page - 1) * PER_PAGE,
345
- take: PER_PAGE,
346
- orderBy: relConfig?.orderBy
367
+ take: PER_PAGE
347
368
  })
348
369
  ]);
349
370
  const options = rows.map((row) => ({
@@ -408,11 +429,12 @@ export function createAdminHandler(config) {
408
429
  throw new Error(`Model "${route.model}" not found`);
409
430
  }
410
431
  if (action === 'delete' && route.id) {
411
- await deleteRecord(prisma, model, route.id);
432
+ await adapter.data.deleteRecord(model, route.id);
412
433
  return redirectToList(route.model);
413
434
  }
414
435
  if (action === 'create' || action === 'update') {
415
436
  const data = formDataToPrisma(formData, model);
437
+ const m2mInput = {};
416
438
  // Validation des FK owning : coercion + existence + self-ref.
417
439
  // Rejoue le `where` de scoping : un ID hors du where est rejeté,
418
440
  // pas seulement caché du select (IDOR par POST forgé).
@@ -454,11 +476,10 @@ export function createAdminHandler(config) {
454
476
  // peut porter des conditions arbitraires (scoping multi-tenant).
455
477
  // Si le client ne sait pas répondre, on ne bloque pas l'écriture.
456
478
  try {
457
- const where = {
458
- [primaryKeyOf(targetModel)]: coerced,
459
- ...(relConfig?.where ? relConfig.where({ locals: event.locals }) : {})
460
- };
461
- const found = await prisma[toPrismaModel(edge.target)].findFirst({ where });
479
+ const idFilter = { op: 'eq', field: primaryKeyOf(targetModel), value: coerced };
480
+ const scopeFilter = scopeFrom(relConfig, { locals: event.locals });
481
+ const filter = scopeFilter ? { op: 'and', clauses: [idFilter, scopeFilter] } : idFilter;
482
+ const found = await adapter.data.findFirst(targetModel, filter);
462
483
  if (!found) {
463
484
  throw new Error(`${edge.field}: invalid value`);
464
485
  }
@@ -466,6 +487,14 @@ export function createAdminHandler(config) {
466
487
  catch (e) {
467
488
  if (e?.message?.includes('invalid value'))
468
489
  throw e;
490
+ if (e?.message &&
491
+ (e.message.includes(OPAQUE_FILTER_ERROR) ||
492
+ OPAQUE_FILTER_ERROR.startsWith(e.message))) {
493
+ throw new Error(`${edge.field}: invalid value`);
494
+ }
495
+ if (e?.message?.includes('unknown field')) {
496
+ throw new Error(`${edge.field}: invalid value`);
497
+ }
469
498
  // Client incapable de vérifier (mock partiel, etc.) : on laisse passer.
470
499
  }
471
500
  data[scalarName] = coerced;
@@ -476,11 +505,11 @@ export function createAdminHandler(config) {
476
505
  // Avec le sentinelle mais zéro valeur cochée → vider la
477
506
  // relation (`set: []` / rien à connecter en création).
478
507
  for (const edge of relationGraph.edges.values()) {
479
- if (edge.model !== model.name || edge.kind !== 'm2m-implicit')
508
+ if (edge.model !== model.name || edge.kind !== 'm2m')
480
509
  continue;
481
510
  // Pas de garde `edge.unsupported` ici : par construction du
482
511
  // graphe, `unsupported` n'est jamais posé sur une arête
483
- // m2m-implicite (seulement sur to-one-owning / groupes
512
+ // m2m (seulement sur to-one-owning / groupes
484
513
  // ambigus, qui retombent toujours en to-one-owning).
485
514
  const present = formData.get(`__rel_present__${edge.field}`);
486
515
  if (present === null)
@@ -501,12 +530,11 @@ export function createAdminHandler(config) {
501
530
  // soumis. Un compte différent = au moins un ID invalide ou
502
531
  // hors scoping — IDOR bloqué au même titre que pour les FK.
503
532
  if (ids.length > 0) {
504
- const where = {
505
- [targetPk]: { in: ids },
506
- ...(relConfig?.where ? relConfig.where({ locals: event.locals }) : {})
507
- };
533
+ const inFilter = { op: 'in', field: targetPk, value: ids };
534
+ const scopeFilter = scopeFrom(relConfig, { locals: event.locals });
535
+ const filter = scopeFilter ? { op: 'and', clauses: [inFilter, scopeFilter] } : inFilter;
508
536
  try {
509
- const found = await prisma[toPrismaModel(edge.target)].findMany({ where });
537
+ const found = await adapter.data.findMany(targetModel, { filter });
510
538
  if (found.length !== new Set(ids.map(String)).size) {
511
539
  throw new Error(`${edge.field}: invalid value`);
512
540
  }
@@ -514,19 +542,25 @@ export function createAdminHandler(config) {
514
542
  catch (e) {
515
543
  if (e?.message?.includes('invalid value'))
516
544
  throw e;
545
+ if (e?.message &&
546
+ (e.message.includes(OPAQUE_FILTER_ERROR) ||
547
+ OPAQUE_FILTER_ERROR.startsWith(e.message))) {
548
+ throw new Error(`${edge.field}: invalid value`);
549
+ }
550
+ if (e?.message?.includes('unknown field')) {
551
+ throw new Error(`${edge.field}: invalid value`);
552
+ }
517
553
  // Client incapable de vérifier : on laisse passer.
518
554
  }
519
555
  }
520
- const idRefs = ids.map((id) => ({ [targetPk]: id }));
521
- data[edge.field] =
522
- action === 'create' ? { connect: idRefs } : { set: idRefs };
556
+ m2mInput[edge.field] = { targetPkField: targetPk, ids };
523
557
  }
524
558
  }
525
559
  if (action === 'create') {
526
- await createRecord(prisma, model, data);
560
+ await adapter.data.createRecord(model, { scalars: data, m2m: m2mInput });
527
561
  }
528
562
  else if (route.id) {
529
- await updateRecord(prisma, model, route.id, data);
563
+ await adapter.data.updateRecord(model, route.id, { scalars: data, m2m: m2mInput });
530
564
  }
531
565
  return redirectToList(route.model);
532
566
  }
@@ -540,7 +574,7 @@ export function createAdminHandler(config) {
540
574
  const modelsWithCounts = await Promise.all(filteredModels.map(async (m) => {
541
575
  let count = 0;
542
576
  try {
543
- count = await prisma[toPrismaModel(m.name)].count();
577
+ count = await adapter.data.countRecords(m);
544
578
  }
545
579
  catch {
546
580
  // model absent from the database
@@ -585,17 +619,19 @@ export function createAdminHandler(config) {
585
619
  `scope entirely if there is genuinely nothing to scope by for this request, or a ` +
586
620
  `condition that actually restricts rows otherwise.`);
587
621
  }
588
- const where = buildWhere(listQuery, listScope, caseInsensitiveSearch, model);
589
- const { items, total } = await listRecords(prisma, model, page, PER_PAGE, where);
622
+ const filter = buildWhere(listQuery, listScope, caseInsensitiveSearch, model);
623
+ const { rows: items, total } = await adapter.data.listRecords(model, { filter, skip: (page - 1) * PER_PAGE, take: PER_PAGE });
590
624
  const listFilters = resolveListFilters(model, schema.enums, modelsConfig[model.name]?.listFilter, toLabel, relationGraph, hiddenFieldsOf(model), config.listFilterDefaults?.autoDetect ?? true);
591
- const fkFilterMeta = new Map();
592
- for (const filter of listFilters) {
593
- if (filter.kind !== 'fk')
594
- continue;
625
+ // Un filtre FK ne dépend pas de l'autre : résolus en parallèle
626
+ // plutôt qu'un par un (même raisonnement que loadRelationOptions).
627
+ const fkFilterEntries = await Promise.all(listFilters
628
+ .filter((filter) => filter.kind === 'fk')
629
+ .map(async (filter) => {
595
630
  const activeRawValue = listQuery.filters.find((f) => f.field === filter.field && f.op === 'equals')?.raw;
596
631
  const meta = await resolveFkFilterOptions(model, filter.field, filter.label, { locals: event.locals }, activeRawValue);
597
- fkFilterMeta.set(filter.field, meta);
598
- }
632
+ return [filter.field, meta];
633
+ }));
634
+ const fkFilterMeta = new Map(fkFilterEntries);
599
635
  content = render(List, {
600
636
  props: {
601
637
  model: viewModel(model),
@@ -640,7 +676,7 @@ export function createAdminHandler(config) {
640
676
  // atteint ce `else` — or 'edit' est la branche à 2 segments, donc `id` y est
641
677
  // toujours défini. La variante 'notFound' ne porte pas de `model` : elle est
642
678
  // interceptée en amont et ne peut pas arriver ici.
643
- const item = await getRecord(prisma, model, route.id);
679
+ const item = await adapter.data.getRecord(model, route.id);
644
680
  const relationOptions = await loadRelationOptions(model, { locals: event.locals }, route.id);
645
681
  const relatedCounts = item ? await loadRelatedCounts(model, route.id) : undefined;
646
682
  content = item