sveltekit-admin 0.8.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist/server/adapters/drizzle/dataAdapter.js +76 -7
  2. package/dist/server/adapters/prisma/dataAdapter.js +19 -1
  3. package/dist/server/adapters/types.d.ts +33 -1
  4. package/dist/server/data.d.ts +18 -2
  5. package/dist/server/data.js +39 -8
  6. package/dist/server/handler.d.ts +35 -0
  7. package/dist/server/handler.js +67 -15
  8. package/dist/server/introspection/parser.d.ts +15 -0
  9. package/dist/server/introspection/parser.js +17 -0
  10. package/dist/server/mutations.d.ts +8 -4
  11. package/dist/server/mutations.js +203 -22
  12. package/dist/server/query/listColumns.d.ts +19 -0
  13. package/dist/server/query/listColumns.js +43 -0
  14. package/dist/server/query/pageSize.d.ts +19 -0
  15. package/dist/server/query/pageSize.js +26 -0
  16. package/dist/server/query/sortQuery.d.ts +31 -0
  17. package/dist/server/query/sortQuery.js +33 -0
  18. package/dist/server/query/urls.js +9 -1
  19. package/dist/server/runtime.d.ts +5 -0
  20. package/dist/server/runtime.js +54 -1
  21. package/dist/server/submitted.d.ts +20 -0
  22. package/dist/server/submitted.js +54 -0
  23. package/dist/server/views/FieldInput.svelte +114 -8
  24. package/dist/server/views/FieldInput.svelte.d.ts +7 -0
  25. package/dist/server/views/Form.svelte +71 -4
  26. package/dist/server/views/Form.svelte.d.ts +17 -0
  27. package/dist/server/views/Layout.svelte +5 -2
  28. package/dist/server/views/List.svelte +128 -23
  29. package/dist/server/views/List.svelte.d.ts +5 -0
  30. package/dist/server/views/RelationCheckboxes.svelte +26 -5
  31. package/dist/server/views/RelationCheckboxes.svelte.d.ts +9 -0
  32. package/dist/server/views/RelationSelect.svelte +14 -3
  33. package/dist/server/views/RelationSelect.svelte.d.ts +2 -0
  34. package/dist/server/views/html.d.ts +19 -0
  35. package/dist/server/views/html.js +25 -0
  36. package/dist/server/views/pagination.d.ts +9 -0
  37. package/dist/server/views/pagination.js +31 -0
  38. package/dist/server/views/theme.js +147 -3
  39. package/dist/server/views/types.d.ts +7 -0
  40. package/package.json +2 -1
@@ -2,6 +2,7 @@ import type { AdminHandlerConfig } from '../handler.js';
2
2
  import type { ViewModel, ListRecordAction, FkFilterMeta } from './types.js';
3
3
  import type { ListQuery } from '../query/listQuery.js';
4
4
  import type { ResolvedFilterField } from '../query/filterDetection.js';
5
+ import type { SortState } from '../query/sortQuery.js';
5
6
  type $$ComponentProps = {
6
7
  model: ViewModel;
7
8
  items: any[];
@@ -20,6 +21,10 @@ type $$ComponentProps = {
20
21
  listFilters?: ResolvedFilterField[];
21
22
  /** Métadonnées async (options scopées + label actif) pour les filtres FK configurés. */
22
23
  fkFilterMeta?: Map<string, FkFilterMeta>;
24
+ /** Tri actif + drapeau « colonne refusée ». Absent = en-têtes non cliquables. */
25
+ sort?: SortState;
26
+ /** Tailles de page proposées. Vide/absent = pas de sélecteur. */
27
+ pageSizes?: number[];
23
28
  recordActions?: ListRecordAction[];
24
29
  };
25
30
  declare const List: import("svelte").Component<$$ComponentProps, {}, "">;
@@ -1,19 +1,33 @@
1
1
  <script lang="ts">
2
2
  import type { RelationEdge } from '../introspection/relations.js';
3
3
  import type { RelationMeta } from './types.js';
4
- import { toLabel } from './html.js';
4
+ import { toLabel, fieldErrorAttrs } from './html.js';
5
5
 
6
6
  let {
7
7
  edge,
8
- meta
8
+ meta,
9
+ submittedIds,
10
+ errorMessage
9
11
  }: {
10
12
  edge: RelationEdge;
11
13
  meta: RelationMeta;
14
+ /**
15
+ * IDs cochés au dernier POST, quand la mutation a échoué. Un tableau vide
16
+ * dit « tout décoché » et doit gagner sur `meta.selectedIds` ; `undefined`
17
+ * dit « pas un re-rendu d'erreur ». C'est la même distinction que le
18
+ * sentinelle `__rel_present__` côté écriture.
19
+ */
20
+ submittedIds?: string[];
21
+ /** Défini seulement si c'est cette relation que l'erreur désigne. */
22
+ errorMessage?: string;
12
23
  } = $props();
13
24
 
14
25
  const label = $derived(toLabel(edge.field));
15
26
  const inputName = $derived(`__rel__${edge.field}`);
16
- const selected = $derived(new Set((meta.selectedIds ?? []).map(String)));
27
+ const selected = $derived(
28
+ new Set((submittedIds ?? meta.selectedIds ?? []).map(String))
29
+ );
30
+ const err = $derived(fieldErrorAttrs(inputName, errorMessage));
17
31
  </script>
18
32
 
19
33
  <!--
@@ -29,13 +43,18 @@
29
43
  <div class="ska-field">
30
44
  <label class="ska-label" for={inputName}>{label} (IDs séparés par des virgules)</label>
31
45
  <input type="hidden" name="__rel_present__{edge.field}" value="1" />
32
- <input id={inputName} type="text" name={inputName} value={[...selected].join(',')} class="ska-input" />
46
+ <input id={inputName} type="text" name={inputName} value={[...selected].join(',')} class="ska-input" aria-invalid={err.ariaInvalid} aria-describedby={err.describedBy} />
47
+ <!-- eslint-disable-next-line svelte/no-at-html-tags -- fieldErrorAttrs échappe l'id et le message -->
48
+ {@html err.html}
33
49
  </div>
34
50
  {:else}
35
51
  <div class="ska-field">
36
52
  <label class="ska-label" for={inputName}>{label}</label>
37
53
  <input type="hidden" name="__rel_present__{edge.field}" value="1" />
38
- <fieldset id={inputName} class="ska-checkbox-group">
54
+ <!-- Pas d'`aria-invalid` ici : l'attribut n'est pas permis sur le rôle
55
+ `group` implicite d'un `<fieldset>`. Le rattachement du message se fait
56
+ par `aria-describedby` seul, qui l'est. -->
57
+ <fieldset id={inputName} class="ska-checkbox-group" aria-describedby={err.describedBy}>
39
58
  {#each meta.options as opt (opt.id)}
40
59
  <label class="ska-checkbox-wrap">
41
60
  <input
@@ -49,5 +68,7 @@
49
68
  </label>
50
69
  {/each}
51
70
  </fieldset>
71
+ <!-- eslint-disable-next-line svelte/no-at-html-tags -- fieldErrorAttrs échappe l'id et le message -->
72
+ {@html err.html}
52
73
  </div>
53
74
  {/if}
@@ -3,6 +3,15 @@ import type { RelationMeta } from './types.js';
3
3
  type $$ComponentProps = {
4
4
  edge: RelationEdge;
5
5
  meta: RelationMeta;
6
+ /**
7
+ * IDs cochés au dernier POST, quand la mutation a échoué. Un tableau vide
8
+ * dit « tout décoché » et doit gagner sur `meta.selectedIds` ; `undefined`
9
+ * dit « pas un re-rendu d'erreur ». C'est la même distinction que le
10
+ * sentinelle `__rel_present__` côté écriture.
11
+ */
12
+ submittedIds?: string[];
13
+ /** Défini seulement si c'est cette relation que l'erreur désigne. */
14
+ errorMessage?: string;
6
15
  };
7
16
  declare const RelationCheckboxes: import("svelte").Component<$$ComponentProps, {}, "">;
8
17
  type RelationCheckboxes = ReturnType<typeof RelationCheckboxes>;
@@ -2,18 +2,21 @@
2
2
  import type { AdminHandlerConfig } from '../handler.js';
3
3
  import type { RelationEdge } from '../introspection/relations.js';
4
4
  import type { RelationMeta } from './types.js';
5
- import { toLabel } from './html.js';
5
+ import { toLabel, fieldErrorAttrs } from './html.js';
6
6
 
7
7
  let {
8
8
  edge,
9
9
  meta,
10
10
  currentValue,
11
- config
11
+ config,
12
+ errorMessage
12
13
  }: {
13
14
  edge: RelationEdge;
14
15
  meta: RelationMeta;
15
16
  currentValue: unknown;
16
17
  config: AdminHandlerConfig;
18
+ /** Défini seulement si c'est cette relation que l'erreur désigne. */
19
+ errorMessage?: string;
17
20
  } = $props();
18
21
 
19
22
  const relConfig = $derived(config.models?.[edge.model]?.relations?.[edge.field]);
@@ -23,6 +26,8 @@
23
26
  // Toujours défini pour une arête to-one-owning : c'est ce qui la distingue
24
27
  // des autres kinds dans le graphe (voir buildRelationGraph).
25
28
  const scalarName = $derived(edge.scalarFields[0]);
29
+ // Rattaché au scalaire : c'est lui qui porte l'`id` et le `name` du widget.
30
+ const err = $derived(fieldErrorAttrs(scalarName, errorMessage));
26
31
  </script>
27
32
 
28
33
  {#if meta.tooMany}
@@ -36,12 +41,16 @@
36
41
  value={currentValue ?? ''}
37
42
  class="ska-input"
38
43
  required={required}
44
+ aria-invalid={err.ariaInvalid}
45
+ aria-describedby={err.describedBy}
39
46
  />
47
+ <!-- eslint-disable-next-line svelte/no-at-html-tags -- fieldErrorAttrs échappe l'id et le message -->
48
+ {@html err.html}
40
49
  </div>
41
50
  {:else}
42
51
  <div class="ska-field">
43
52
  <label class="ska-label" for={scalarName}>{label}{required ? ' *' : ''}</label>
44
- <select id={scalarName} name={scalarName} class="ska-input" required={required}>
53
+ <select id={scalarName} name={scalarName} class="ska-input" required={required} aria-invalid={err.ariaInvalid} aria-describedby={err.describedBy}>
45
54
  {#if !required}
46
55
  <option value="">{nullLabel}</option>
47
56
  {/if}
@@ -49,5 +58,7 @@
49
58
  <option value={opt.id} selected={String(opt.id) === String(currentValue ?? '')}>{opt.label}</option>
50
59
  {/each}
51
60
  </select>
61
+ <!-- eslint-disable-next-line svelte/no-at-html-tags -- fieldErrorAttrs échappe l'id et le message -->
62
+ {@html err.html}
52
63
  </div>
53
64
  {/if}
@@ -6,6 +6,8 @@ type $$ComponentProps = {
6
6
  meta: RelationMeta;
7
7
  currentValue: unknown;
8
8
  config: AdminHandlerConfig;
9
+ /** Défini seulement si c'est cette relation que l'erreur désigne. */
10
+ errorMessage?: string;
9
11
  };
10
12
  declare const RelationSelect: import("svelte").Component<$$ComponentProps, {}, "">;
11
13
  type RelationSelect = ReturnType<typeof RelationSelect>;
@@ -3,3 +3,22 @@ export declare function escapeHtml(str: string): string;
3
3
  export declare function toLabel(name: string): string;
4
4
  export declare function adjustColor(hex: string, percent: number): string;
5
5
  export declare function formatValue(value: any, type: string): string;
6
+ /**
7
+ * Markup du message d'erreur d'un champ, et les attributs qui l'y rattachent.
8
+ *
9
+ * Rendu comme une chaîne passée à `{@html}` plutôt qu'en `{#if}` : la SSR de
10
+ * Svelte 5 pose de toute façon un commentaire de frontière autour des deux, et
11
+ * les deux coûtent la même chose sur un formulaire sans erreur (mesuré : 18
12
+ * octets par champ pour `{@html}`, 19 pour `{#if}`). Le `{@html}` est retenu
13
+ * pour la cohérence avec `recordActionsHtml` dans `Form.svelte`, qui documente
14
+ * déjà ce compromis, et parce qu'il garde l'échappement explicite ici plutôt
15
+ * que réparti dans quatre branches de gabarit.
16
+ *
17
+ * `inputId` doit être l'`id` du champ décrit : c'est lui qui fabrique la cible
18
+ * d'`aria-describedby`.
19
+ */
20
+ export declare function fieldErrorAttrs(inputId: string, message: string | undefined): {
21
+ ariaInvalid: 'true' | undefined;
22
+ describedBy: string | undefined;
23
+ html: string;
24
+ };
@@ -39,3 +39,28 @@ export function formatValue(value, type) {
39
39
  }
40
40
  return escapeHtml(str);
41
41
  }
42
+ /**
43
+ * Markup du message d'erreur d'un champ, et les attributs qui l'y rattachent.
44
+ *
45
+ * Rendu comme une chaîne passée à `{@html}` plutôt qu'en `{#if}` : la SSR de
46
+ * Svelte 5 pose de toute façon un commentaire de frontière autour des deux, et
47
+ * les deux coûtent la même chose sur un formulaire sans erreur (mesuré : 18
48
+ * octets par champ pour `{@html}`, 19 pour `{#if}`). Le `{@html}` est retenu
49
+ * pour la cohérence avec `recordActionsHtml` dans `Form.svelte`, qui documente
50
+ * déjà ce compromis, et parce qu'il garde l'échappement explicite ici plutôt
51
+ * que réparti dans quatre branches de gabarit.
52
+ *
53
+ * `inputId` doit être l'`id` du champ décrit : c'est lui qui fabrique la cible
54
+ * d'`aria-describedby`.
55
+ */
56
+ export function fieldErrorAttrs(inputId, message) {
57
+ if (message === undefined) {
58
+ return { ariaInvalid: undefined, describedBy: undefined, html: '' };
59
+ }
60
+ const errorId = `${inputId}-error`;
61
+ return {
62
+ ariaInvalid: 'true',
63
+ describedBy: errorId,
64
+ html: `<p class="ska-field__error" id="${escapeHtml(errorId)}">${escapeHtml(message)}</p>`
65
+ };
66
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Fenêtre de numéros de page : « 1 … 8 9 [10] 11 12 … 20 ».
3
+ *
4
+ * Pure, et volontairement hors du composant : les cas limites (premiers et
5
+ * derniers écrans, trous d'une seule page) se testent sur des nombres plutôt
6
+ * qu'en fouillant du HTML.
7
+ */
8
+ export type PageEntry = number | 'gap';
9
+ export declare function paginationWindow(page: number, totalPages: number): PageEntry[];
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Fenêtre de numéros de page : « 1 … 8 9 [10] 11 12 … 20 ».
3
+ *
4
+ * Pure, et volontairement hors du composant : les cas limites (premiers et
5
+ * derniers écrans, trous d'une seule page) se testent sur des nombres plutôt
6
+ * qu'en fouillant du HTML.
7
+ */
8
+ /** Nombre de pages affichées de part et d'autre de la page courante. */
9
+ const SPAN = 2;
10
+ export function paginationWindow(page, totalPages) {
11
+ if (totalPages <= 1)
12
+ return [];
13
+ const first = 1;
14
+ const last = totalPages;
15
+ const from = Math.max(first, page - SPAN);
16
+ const to = Math.min(last, page + SPAN);
17
+ const entries = [];
18
+ for (let n = from; n <= to; n++)
19
+ entries.push(n);
20
+ // Un trou qui ne cacherait qu'une seule page ne gagne aucune place et coûte
21
+ // un clic : on montre la page à la place. D'où la comparaison à 1 et non 0.
22
+ if (from - first > 1)
23
+ entries.unshift(first, 'gap');
24
+ else if (from > first)
25
+ entries.unshift(first);
26
+ if (last - to > 1)
27
+ entries.push('gap', last);
28
+ else if (to < last)
29
+ entries.push(last);
30
+ return entries;
31
+ }
@@ -182,6 +182,45 @@ export function styles(primaryColor) {
182
182
  letter-spacing: 0.05em;
183
183
  }
184
184
 
185
+ .ska-pagination__pages {
186
+ display: flex;
187
+ align-items: center;
188
+ gap: 0.25rem;
189
+ flex-wrap: wrap;
190
+ }
191
+ .ska-pagination__gap { padding: 0 0.25rem; color: #64748b; }
192
+ .ska-bulk { margin-bottom: 0.75rem; }
193
+ .ska-table__select { width: 2.5rem; text-align: center; }
194
+ .ska-alert--success {
195
+ background: #f0fdf4;
196
+ border-color: #bbf7d0;
197
+ color: #166534;
198
+ }
199
+ .ska-pagination__sizes {
200
+ display: flex;
201
+ align-items: center;
202
+ gap: 0.25rem;
203
+ flex-wrap: wrap;
204
+ }
205
+ .ska-pagination__current {
206
+ background: var(--ska-primary);
207
+ color: #fff;
208
+ border-color: var(--ska-primary);
209
+ cursor: default;
210
+ }
211
+ .ska-table th a.ska-th-sort {
212
+ color: inherit;
213
+ text-decoration: none;
214
+ display: inline-flex;
215
+ align-items: center;
216
+ gap: 0.25rem;
217
+ }
218
+ .ska-table th a.ska-th-sort:hover { text-decoration: underline; }
219
+ .ska-table th a.ska-th-sort:focus-visible {
220
+ outline: 2px solid var(--ska-primary);
221
+ outline-offset: 2px;
222
+ border-radius: 2px;
223
+ }
185
224
  .ska-table td {
186
225
  padding: 0.75rem 1rem;
187
226
  border-bottom: 1px solid #e2e8f0;
@@ -220,10 +259,15 @@ export function styles(primaryColor) {
220
259
  transition: all 0.15s;
221
260
  }
222
261
 
223
- .ska-input:focus {
224
- outline: none;
262
+ /* 'outline: none' remplacé par un box-shadow à 10 % d'opacité donnait un
263
+ contraste d'environ 1,1:1 contre le fond blanc — soit, en pratique, aucun
264
+ indicateur de focus. Le contour reprend la couleur de marque, qui est
265
+ elle contrastée, et le box-shadow ne fait plus que l'accompagner. */
266
+ .ska-input:focus-visible {
267
+ outline: 2px solid var(--ska-primary);
268
+ outline-offset: 1px;
225
269
  border-color: var(--ska-primary);
226
- box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
270
+ box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.15);
227
271
  }
228
272
 
229
273
  .ska-input[readonly] {
@@ -231,6 +275,25 @@ export function styles(primaryColor) {
231
275
  color: #6b7280;
232
276
  }
233
277
 
278
+ /* Sélecteur d'attribut et non une classe : aria-invalid est déjà la source
279
+ de vérité pour les lecteurs d'écran, une classe parallèle finirait par en
280
+ diverger. */
281
+ .ska-input[aria-invalid='true'] {
282
+ border-color: #dc2626;
283
+ }
284
+
285
+ .ska-input[aria-invalid='true']:focus-visible {
286
+ outline-color: #dc2626;
287
+ border-color: #dc2626;
288
+ box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.15);
289
+ }
290
+
291
+ .ska-field__error {
292
+ margin-top: 0.375rem;
293
+ font-size: 0.8125rem;
294
+ color: #b91c1c;
295
+ }
296
+
234
297
  .ska-checkbox-wrap {
235
298
  display: flex;
236
299
  align-items: center;
@@ -533,5 +596,86 @@ export function styles(primaryColor) {
533
596
  color: #16a34a;
534
597
  border: 1px solid #bbf7d0;
535
598
  }
599
+
600
+ /* Hors flux tant qu'il n'est pas ciblé : 'display: none' le retirerait de
601
+ l'ordre de tabulation, ce qui le rendrait inatteignable — donc inutile. */
602
+ .ska-skip {
603
+ position: absolute;
604
+ left: -9999px;
605
+ top: 0;
606
+ background: var(--ska-primary);
607
+ color: #fff;
608
+ padding: 0.625rem 1rem;
609
+ border-radius: 0 0 0.375rem 0;
610
+ z-index: 100;
611
+ }
612
+
613
+ .ska-skip:focus {
614
+ left: 0;
615
+ }
616
+
617
+ /* Un seul contour, de la couleur de marque, sur tout ce qui prend le focus.
618
+ 'focus-visible' et non 'focus' : le contour apparaît au clavier sans
619
+ s'afficher au clic à la souris. La cible du lien d'évitement porte
620
+ tabindex="-1" pour être focusable par programme, mais elle ne doit pas
621
+ dessiner de contour pour autant. */
622
+ .ska-btn:focus-visible,
623
+ .ska-nav__link:focus-visible,
624
+ .ska-logo:focus-visible,
625
+ .ska-back:focus-visible,
626
+ .ska-checkbox:focus-visible,
627
+ .ska-logout__btn:focus-visible,
628
+ .ska-skip:focus-visible {
629
+ outline: 2px solid var(--ska-primary);
630
+ outline-offset: 2px;
631
+ }
632
+
633
+ .ska-main:focus-visible { outline: none; }
634
+
635
+ /* La sidebar était fixe à 260px et le contenu décalé d'autant : sur un
636
+ écran de 375px il restait 115px utilisables. Elle repasse dans le flux et
637
+ la navigation devient horizontale. */
638
+ @media (max-width: 900px) {
639
+ .ska-layout { flex-direction: column; }
640
+
641
+ .ska-sidebar {
642
+ position: static;
643
+ width: auto;
644
+ height: auto;
645
+ border-right: none;
646
+ border-bottom: 1px solid #e2e8f0;
647
+ padding: 1rem;
648
+ }
649
+
650
+ .ska-nav {
651
+ display: flex;
652
+ flex-wrap: wrap;
653
+ gap: 0.25rem;
654
+ }
655
+
656
+ .ska-nav__item { margin-bottom: 0; }
657
+
658
+ .ska-main {
659
+ margin-left: 0;
660
+ padding: 1rem;
661
+ }
662
+
663
+ .ska-header {
664
+ flex-direction: column;
665
+ gap: 0.75rem;
666
+ }
667
+
668
+ .ska-form { max-width: none; }
669
+ }
670
+
671
+ /* Cinq 'transition: all' vivent dans cette feuille. Le réglage système est
672
+ une demande explicite, pas une préférence esthétique. */
673
+ @media (prefers-reduced-motion: reduce) {
674
+ * {
675
+ transition-duration: 0.01ms !important;
676
+ animation-duration: 0.01ms !important;
677
+ scroll-behavior: auto !important;
678
+ }
679
+ }
536
680
  `;
537
681
  }
@@ -16,6 +16,13 @@ export interface ViewModel {
16
16
  label: string;
17
17
  fields: PrismaField[];
18
18
  primaryKey: string;
19
+ /**
20
+ * Valeurs déclarées de chaque enum du schéma, indexées par nom de type.
21
+ * Portées par le ViewModel plutôt que passées à part comme le fait
22
+ * `filterDetection` : le formulaire résout un enum champ par champ, et un
23
+ * second chemin d'acheminement vers les mêmes valeurs finirait par diverger.
24
+ */
25
+ enums?: Map<string, string[]>;
19
26
  relationGraph?: RelationGraph;
20
27
  /** Options résolues pour chaque arête to-one-owning, indexées par "Model.field" */
21
28
  relationOptions?: Map<string, RelationMeta>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sveltekit-admin",
3
- "version": "0.8.1",
3
+ "version": "0.9.0",
4
4
  "description": "Django-like admin panel for SvelteKit + Prisma",
5
5
  "type": "module",
6
6
  "svelte": "./dist/index.js",
@@ -89,6 +89,7 @@
89
89
  "build": "pnpm run package",
90
90
  "package": "svelte-kit sync && svelte-package -o dist",
91
91
  "size": "pnpm run package && node scripts/package-size.mjs",
92
+ "smoke:packaged": "node scripts/consumer-smoke.mjs",
92
93
  "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
93
94
  "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
94
95
  "test:gen": "prisma generate --schema tests/fixtures/prisma/schema.prisma",