sveltekit-admin 0.5.3 → 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 (39) hide show
  1. package/README.md +32 -1
  2. package/dist/index.d.ts +3 -0
  3. package/dist/index.js +1 -0
  4. package/dist/server/adapters/drizzle/dataAdapter.d.ts +11 -0
  5. package/dist/server/adapters/drizzle/dataAdapter.js +243 -0
  6. package/dist/server/adapters/drizzle/filterCompiler.d.ts +8 -0
  7. package/dist/server/adapters/drizzle/filterCompiler.js +61 -0
  8. package/dist/server/adapters/drizzle/index.d.ts +13 -0
  9. package/dist/server/adapters/drizzle/index.js +23 -0
  10. package/dist/server/adapters/drizzle/inspect.d.ts +23 -0
  11. package/dist/server/adapters/drizzle/inspect.js +220 -0
  12. package/dist/server/adapters/drizzle/introspector.d.ts +3 -0
  13. package/dist/server/adapters/drizzle/introspector.js +3 -0
  14. package/dist/server/adapters/filter.d.ts +12 -0
  15. package/dist/server/adapters/filter.js +72 -0
  16. package/dist/server/adapters/prisma/dataAdapter.d.ts +13 -0
  17. package/dist/server/adapters/prisma/dataAdapter.js +96 -0
  18. package/dist/server/adapters/prisma/filterCompiler.d.ts +9 -0
  19. package/dist/server/adapters/prisma/filterCompiler.js +42 -0
  20. package/dist/server/adapters/prisma/index.d.ts +28 -0
  21. package/dist/server/adapters/prisma/index.js +32 -0
  22. package/dist/server/adapters/prisma/introspector.d.ts +4 -0
  23. package/dist/server/adapters/prisma/introspector.js +9 -0
  24. package/dist/server/adapters/types.d.ts +79 -0
  25. package/dist/server/adapters/types.js +1 -0
  26. package/dist/server/data.d.ts +0 -8
  27. package/dist/server/data.js +0 -41
  28. package/dist/server/handler.d.ts +18 -2
  29. package/dist/server/handler.js +110 -84
  30. package/dist/server/introspection/parser.d.ts +4 -40
  31. package/dist/server/introspection/relations.d.ts +1 -1
  32. package/dist/server/introspection/relations.js +1 -1
  33. package/dist/server/query/listQuery.d.ts +6 -12
  34. package/dist/server/query/listQuery.js +31 -53
  35. package/dist/server/types/schema.d.ts +40 -0
  36. package/dist/server/types/schema.js +8 -0
  37. package/dist/server/views/Form.svelte +1 -1
  38. package/dist/server/views/RelationCheckboxes.svelte +1 -1
  39. package/package.json +23 -2
@@ -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,7 +158,7 @@ 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
  */
@@ -144,7 +166,7 @@ export function createAdminHandler(config) {
144
166
  const edges = [...relationGraph.edges.values()].filter((edge) => {
145
167
  if (edge.model !== model.name)
146
168
  return false;
147
- if (edge.kind !== 'to-one-owning' && edge.kind !== 'm2m-implicit')
169
+ if (edge.kind !== 'to-one-owning' && edge.kind !== 'm2m')
148
170
  return false;
149
171
  if (edge.unsupported)
150
172
  return false;
@@ -158,26 +180,22 @@ export function createAdminHandler(config) {
158
180
  const key = `${edge.model}.${edge.field}`;
159
181
  const relConfig = modelsConfig[model.name]?.relations?.[edge.field];
160
182
  const targetModel = schema.models.find((m) => m.name === edge.target);
161
- const where = relConfig?.where ? relConfig.where(ctx) : undefined;
162
- const prismaKey = toPrismaModel(edge.target);
183
+ const filter = scopeFrom(relConfig, ctx);
163
184
  try {
164
- const total = await prisma[prismaKey].count({ where });
185
+ const total = await adapter.data.countRecords(targetModel, filter);
165
186
  if (total > selectThreshold || relConfig?.widget === 'raw-id') {
166
- const selectedIds = edge.kind === 'm2m-implicit' && currentId
167
- ? await loadSelectedIds(model, edge, currentId, targetModel)
187
+ const selectedIds = edge.kind === 'm2m' && currentId
188
+ ? await adapter.data.getM2mSelectedIds(model, edge, targetModel, currentId)
168
189
  : undefined;
169
190
  return [key, { tooMany: true, options: [], selectedIds }];
170
191
  }
171
- const rows = await prisma[prismaKey].findMany({
172
- where,
173
- orderBy: relConfig?.orderBy
174
- });
192
+ const rows = await adapter.data.findMany(targetModel, { filter, orderBy: relConfig?.orderBy });
175
193
  const options = rows.map((row) => ({
176
194
  id: row[primaryKeyOf(targetModel)],
177
195
  label: resolveLabel(targetModel, row, relConfig?.labelTemplate)
178
196
  }));
179
- const selectedIds = edge.kind === 'm2m-implicit' && currentId
180
- ? await loadSelectedIds(model, edge, currentId, targetModel)
197
+ const selectedIds = edge.kind === 'm2m' && currentId
198
+ ? await adapter.data.getM2mSelectedIds(model, edge, targetModel, currentId)
181
199
  : undefined;
182
200
  return [key, { tooMany: false, options, selectedIds }];
183
201
  }
@@ -208,22 +226,18 @@ export function createAdminHandler(config) {
208
226
  // schéma parsé avec succès — une arête ne peut pas cibler un modèle qui
209
227
  // n'existe pas dans `schema.models`.
210
228
  const relConfig = modelsConfig[model.name]?.relations?.[edge.field];
211
- const scope = relConfig?.where ? relConfig.where(ctx) : undefined;
212
- const prismaKey = toPrismaModel(edge.target);
229
+ const scope = scopeFrom(relConfig, ctx);
213
230
  // Options de la sidebar (comptées puis chargées si sous le seuil) et
214
231
  // label du chip actif (§6.3.b) sont deux requêtes indépendantes — l'une
215
232
  // ne dépend pas du résultat de l'autre — donc en parallèle plutôt qu'en
216
233
  // série.
217
234
  const loadOptions = async () => {
218
235
  try {
219
- const total = await prisma[prismaKey].count({ where: scope });
236
+ const total = await adapter.data.countRecords(targetModel, scope);
220
237
  if (total > selectThreshold) {
221
238
  return { options: [], tooMany: true };
222
239
  }
223
- const rows = await prisma[prismaKey].findMany({
224
- where: scope,
225
- orderBy: relConfig?.orderBy
226
- });
240
+ const rows = await adapter.data.findMany(targetModel, { filter: scope, orderBy: relConfig?.orderBy });
227
241
  const options = rows.map((row) => ({
228
242
  id: row[primaryKeyOf(targetModel)],
229
243
  label: resolveLabel(targetModel, row, relConfig?.labelTemplate)
@@ -242,9 +256,9 @@ export function createAdminHandler(config) {
242
256
  return undefined;
243
257
  const activeId = coerceId(activeRawValue, targetModel);
244
258
  try {
245
- const row = await prisma[prismaKey].findFirst({
246
- where: scope ? { AND: [{ [primaryKeyOf(targetModel)]: activeId }, scope] } : { [primaryKeyOf(targetModel)]: activeId }
247
- });
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);
248
262
  return row ? resolveLabel(targetModel, row, relConfig?.labelTemplate) : undefined;
249
263
  }
250
264
  catch {
@@ -268,20 +282,6 @@ export function createAdminHandler(config) {
268
282
  : undefined
269
283
  };
270
284
  };
271
- /** IDs liés côté N-N implicite, via une requête sur le join field Prisma. */
272
- const loadSelectedIds = async (model, edge, currentId, targetModel) => {
273
- try {
274
- const current = await prisma[toPrismaModel(model.name)].findUnique({
275
- where: { [primaryKeyOf(model)]: coerceId(currentId, model) },
276
- include: { [edge.field]: true }
277
- });
278
- const linked = current?.[edge.field] ?? [];
279
- return linked.map((row) => row[primaryKeyOf(targetModel)]);
280
- }
281
- catch {
282
- return [];
283
- }
284
- };
285
285
  /**
286
286
  * Compte, pour chaque relation inverse (1-N, 1-1) d'un modèle, le nombre
287
287
  * d'enregistrements liés côté cible. Résilient : une cible dont le client
@@ -298,9 +298,12 @@ export function createAdminHandler(config) {
298
298
  return undefined;
299
299
  const scalarName = owning.scalarFields[0];
300
300
  const key = `${edge.model}.${edge.field}`;
301
+ const targetModel = schema.models.find((m) => m.name === edge.target);
301
302
  try {
302
- const count = await prisma[toPrismaModel(edge.target)].count({
303
- where: { [scalarName]: coerceId(currentId, model) }
303
+ const count = await adapter.data.countRecords(targetModel, {
304
+ op: 'eq',
305
+ field: scalarName,
306
+ value: coerceId(currentId, model)
304
307
  });
305
308
  return [key, count];
306
309
  }
@@ -312,7 +315,7 @@ export function createAdminHandler(config) {
312
315
  };
313
316
  /**
314
317
  * Endpoint de recherche `GET {basePath}/_search?rel=Model.field&q=...&page=N`.
315
- * 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
316
319
  * paginé — la voie prévue pour un futur widget autocomplete côté client
317
320
  * quand le nombre d'options dépasse `selectThreshold`. Respecte le `where`
318
321
  * de scoping configuré sur la relation, comme le select et la validation
@@ -327,7 +330,7 @@ export function createAdminHandler(config) {
327
330
  const edge = model && relationGraph
328
331
  ? relationGraph.edges.get(`${model.name}.${fieldName}`)
329
332
  : undefined;
330
- 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) {
331
334
  return new Response(JSON.stringify({ error: 'unknown relation' }), {
332
335
  status: 404,
333
336
  headers: { 'Content-Type': 'application/json' }
@@ -335,23 +338,33 @@ export function createAdminHandler(config) {
335
338
  }
336
339
  const targetModel = schema.models.find((m) => m.name === edge.target);
337
340
  const relConfig = modelsConfig[model.name]?.relations?.[edge.field];
338
- const configWhere = relConfig?.where ? relConfig.where({ locals: event.locals }) : {};
341
+ const configWhere = scopeFrom(relConfig, { locals: event.locals });
339
342
  // Recherche sur le premier champ String candidat du modèle cible — le
340
343
  // même champ que celui utilisé pour construire le label par défaut.
341
344
  const searchField = labelFieldCandidates.find((c) => targetModel.fields.some((f) => f.name === c && f.type === 'String'));
342
- const where = {
343
- ...configWhere,
344
- ...(q && searchField ? { [searchField]: { contains: q } } : {})
345
- };
346
- 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;
347
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.
348
361
  const [total, rows] = await Promise.all([
349
- prisma[prismaKey].count({ where }),
350
- prisma[prismaKey].findMany({
351
- where,
362
+ adapter.data.countRecords(targetModel, searchFilter),
363
+ adapter.data.findMany(targetModel, {
364
+ filter: searchFilter,
365
+ orderBy: relConfig?.orderBy,
352
366
  skip: (page - 1) * PER_PAGE,
353
- take: PER_PAGE,
354
- orderBy: relConfig?.orderBy
367
+ take: PER_PAGE
355
368
  })
356
369
  ]);
357
370
  const options = rows.map((row) => ({
@@ -416,11 +429,12 @@ export function createAdminHandler(config) {
416
429
  throw new Error(`Model "${route.model}" not found`);
417
430
  }
418
431
  if (action === 'delete' && route.id) {
419
- await deleteRecord(prisma, model, route.id);
432
+ await adapter.data.deleteRecord(model, route.id);
420
433
  return redirectToList(route.model);
421
434
  }
422
435
  if (action === 'create' || action === 'update') {
423
436
  const data = formDataToPrisma(formData, model);
437
+ const m2mInput = {};
424
438
  // Validation des FK owning : coercion + existence + self-ref.
425
439
  // Rejoue le `where` de scoping : un ID hors du where est rejeté,
426
440
  // pas seulement caché du select (IDOR par POST forgé).
@@ -462,11 +476,10 @@ export function createAdminHandler(config) {
462
476
  // peut porter des conditions arbitraires (scoping multi-tenant).
463
477
  // Si le client ne sait pas répondre, on ne bloque pas l'écriture.
464
478
  try {
465
- const where = {
466
- [primaryKeyOf(targetModel)]: coerced,
467
- ...(relConfig?.where ? relConfig.where({ locals: event.locals }) : {})
468
- };
469
- 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);
470
483
  if (!found) {
471
484
  throw new Error(`${edge.field}: invalid value`);
472
485
  }
@@ -474,6 +487,14 @@ export function createAdminHandler(config) {
474
487
  catch (e) {
475
488
  if (e?.message?.includes('invalid value'))
476
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
+ }
477
498
  // Client incapable de vérifier (mock partiel, etc.) : on laisse passer.
478
499
  }
479
500
  data[scalarName] = coerced;
@@ -484,11 +505,11 @@ export function createAdminHandler(config) {
484
505
  // Avec le sentinelle mais zéro valeur cochée → vider la
485
506
  // relation (`set: []` / rien à connecter en création).
486
507
  for (const edge of relationGraph.edges.values()) {
487
- if (edge.model !== model.name || edge.kind !== 'm2m-implicit')
508
+ if (edge.model !== model.name || edge.kind !== 'm2m')
488
509
  continue;
489
510
  // Pas de garde `edge.unsupported` ici : par construction du
490
511
  // graphe, `unsupported` n'est jamais posé sur une arête
491
- // m2m-implicite (seulement sur to-one-owning / groupes
512
+ // m2m (seulement sur to-one-owning / groupes
492
513
  // ambigus, qui retombent toujours en to-one-owning).
493
514
  const present = formData.get(`__rel_present__${edge.field}`);
494
515
  if (present === null)
@@ -509,12 +530,11 @@ export function createAdminHandler(config) {
509
530
  // soumis. Un compte différent = au moins un ID invalide ou
510
531
  // hors scoping — IDOR bloqué au même titre que pour les FK.
511
532
  if (ids.length > 0) {
512
- const where = {
513
- [targetPk]: { in: ids },
514
- ...(relConfig?.where ? relConfig.where({ locals: event.locals }) : {})
515
- };
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;
516
536
  try {
517
- const found = await prisma[toPrismaModel(edge.target)].findMany({ where });
537
+ const found = await adapter.data.findMany(targetModel, { filter });
518
538
  if (found.length !== new Set(ids.map(String)).size) {
519
539
  throw new Error(`${edge.field}: invalid value`);
520
540
  }
@@ -522,19 +542,25 @@ export function createAdminHandler(config) {
522
542
  catch (e) {
523
543
  if (e?.message?.includes('invalid value'))
524
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
+ }
525
553
  // Client incapable de vérifier : on laisse passer.
526
554
  }
527
555
  }
528
- const idRefs = ids.map((id) => ({ [targetPk]: id }));
529
- data[edge.field] =
530
- action === 'create' ? { connect: idRefs } : { set: idRefs };
556
+ m2mInput[edge.field] = { targetPkField: targetPk, ids };
531
557
  }
532
558
  }
533
559
  if (action === 'create') {
534
- await createRecord(prisma, model, data);
560
+ await adapter.data.createRecord(model, { scalars: data, m2m: m2mInput });
535
561
  }
536
562
  else if (route.id) {
537
- await updateRecord(prisma, model, route.id, data);
563
+ await adapter.data.updateRecord(model, route.id, { scalars: data, m2m: m2mInput });
538
564
  }
539
565
  return redirectToList(route.model);
540
566
  }
@@ -548,7 +574,7 @@ export function createAdminHandler(config) {
548
574
  const modelsWithCounts = await Promise.all(filteredModels.map(async (m) => {
549
575
  let count = 0;
550
576
  try {
551
- count = await prisma[toPrismaModel(m.name)].count();
577
+ count = await adapter.data.countRecords(m);
552
578
  }
553
579
  catch {
554
580
  // model absent from the database
@@ -593,8 +619,8 @@ export function createAdminHandler(config) {
593
619
  `scope entirely if there is genuinely nothing to scope by for this request, or a ` +
594
620
  `condition that actually restricts rows otherwise.`);
595
621
  }
596
- const where = buildWhere(listQuery, listScope, caseInsensitiveSearch, model);
597
- 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 });
598
624
  const listFilters = resolveListFilters(model, schema.enums, modelsConfig[model.name]?.listFilter, toLabel, relationGraph, hiddenFieldsOf(model), config.listFilterDefaults?.autoDetect ?? true);
599
625
  // Un filtre FK ne dépend pas de l'autre : résolus en parallèle
600
626
  // plutôt qu'un par un (même raisonnement que loadRelationOptions).
@@ -650,7 +676,7 @@ export function createAdminHandler(config) {
650
676
  // atteint ce `else` — or 'edit' est la branche à 2 segments, donc `id` y est
651
677
  // toujours défini. La variante 'notFound' ne porte pas de `model` : elle est
652
678
  // interceptée en amont et ne peut pas arriver ici.
653
- const item = await getRecord(prisma, model, route.id);
679
+ const item = await adapter.data.getRecord(model, route.id);
654
680
  const relationOptions = await loadRelationOptions(model, { locals: event.locals }, route.id);
655
681
  const relatedCounts = item ? await loadRelatedCounts(model, route.id) : undefined;
656
682
  content = item
@@ -2,46 +2,10 @@
2
2
  * Prisma Schema Parser
3
3
  * Extracts model information from Prisma schema files
4
4
  */
5
- export interface PrismaField {
6
- name: string;
7
- type: string;
8
- isRequired: boolean;
9
- isList: boolean;
10
- isUnique: boolean;
11
- isId: boolean;
12
- isUpdatedAt: boolean;
13
- isCreatedAt: boolean;
14
- hasDefault: boolean;
15
- defaultValue?: string;
16
- /** true si `type` correspond à un `enum` déclaré dans le même schéma. */
17
- isEnum?: boolean;
18
- relation?: {
19
- name?: string;
20
- model: string;
21
- fields?: string[];
22
- references?: string[];
23
- };
24
- documentation?: string;
25
- }
26
- export interface PrismaModel {
27
- name: string;
28
- fields: PrismaField[];
29
- documentation?: string;
30
- primaryKey?: string;
31
- isPivotTable?: boolean;
32
- }
33
- export interface PrismaSchema {
34
- models: PrismaModel[];
35
- enums: Map<string, string[]>;
36
- /**
37
- * Provider du bloc `datasource` (ex. "postgresql", "sqlite"), tel qu'écrit
38
- * littéralement dans le schéma. `undefined` si absent ou si la valeur est
39
- * une expression (`env("...")`, un provider non littéral) — dans ce cas
40
- * le code appelant doit dégrader vers le comportement le plus prudent
41
- * (voir `caseInsensitiveSearch` dans query/listQuery.ts).
42
- */
43
- provider?: string;
44
- }
5
+ import type { Field, Model, Schema } from '../types/schema.js';
6
+ export type PrismaField = Field;
7
+ export type PrismaModel = Model;
8
+ export type PrismaSchema = Schema;
45
9
  export declare function parsePrismaSchema(schemaPath: string): PrismaSchema;
46
10
  export declare function parseSchemaContent(content: string): PrismaSchema;
47
11
  /**
@@ -13,7 +13,7 @@
13
13
  * par nom de relation, que Prisma garantit unique pour un couple de modèles.
14
14
  */
15
15
  import type { PrismaModel, PrismaSchema } from './parser.js';
16
- export type RelationKind = 'to-one-owning' | 'to-one-inverse' | 'to-many-inverse' | 'm2m-implicit';
16
+ export type RelationKind = 'to-one-owning' | 'to-one-inverse' | 'to-many-inverse' | 'm2m';
17
17
  export type UnsupportedReason = 'composite-fk' | 'ambiguous';
18
18
  export interface RelationEdge {
19
19
  /** Modèle porteur du champ */
@@ -74,7 +74,7 @@ export function buildRelationGraph(schema) {
74
74
  for (const c of group) {
75
75
  let kind;
76
76
  if (isImplicitM2M) {
77
- kind = 'm2m-implicit';
77
+ kind = 'm2m';
78
78
  }
79
79
  else if (c.field.isList) {
80
80
  kind = 'to-many-inverse';
@@ -12,6 +12,7 @@
12
12
  * string) becomes the Prisma operator key.
13
13
  */
14
14
  import type { PrismaModel } from '../introspection/parser.js';
15
+ import type { Filter } from '../adapters/types.js';
15
16
  export type FilterOp = 'equals' | 'contains' | 'startsWith' | 'gte' | 'lte' | 'isnull';
16
17
  export interface ActiveFilter {
17
18
  field: string;
@@ -76,17 +77,10 @@ export declare function resolveDateShortcut(raw: string, now?: () => Date): Date
76
77
  * is known to be eligible.
77
78
  */
78
79
  export declare function parseListQuery(searchParams: URLSearchParams, model: PrismaModel, enums: Map<string, string[]>, searchFields: string[], filterableFields: Set<string>, now?: () => Date): ListQuery;
79
- /** A Prisma `where` clause built from a `ListQuery`. Opaque to callers — pass straight to Prisma. */
80
- export type PrismaWhere = Record<string, unknown>;
81
80
  /**
82
- * Compose the final Prisma `where`: `AND: [scope, ...filters, {OR: search}]`.
83
- * NEVER a spread a spread of `{...scope, ...filterWhere}` lets a filter
84
- * on the same field as the developer's scoping silently overwrite it
85
- * (docs/design §0.c, the exact IDOR the previous `?filter=` had). Two
86
- * clauses on the same field inside `AND` intersect; they never merge.
87
- *
88
- * Returns `undefined` when nothing is active, so the query shape sent to
89
- * Prisma is byte-for-byte identical to today's unfiltered call — no
90
- * regression on existing snapshots/assertions.
81
+ * Compose the final generic `Filter`: `and: [scope, ...filters, {or: search}]`.
82
+ * NEVER a spread. Flat `{ tenantId: 1 }` scopes become `eq` leaves via
83
+ * `normalizeScope`; nested Prisma where objects stay opaque for the Prisma
84
+ * compiler. Drizzle's compiler throws on those opaques.
91
85
  */
92
- export declare function buildWhere(query: ListQuery, scope: Record<string, unknown> | undefined, caseInsensitiveSearch: boolean, model: PrismaModel): PrismaWhere | undefined;
86
+ export declare function buildWhere(query: ListQuery, scope: Record<string, unknown> | undefined, caseInsensitiveSearch: boolean, model: PrismaModel): Filter | Record<string, unknown> | undefined;